mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afeb51806f | ||
|
|
af1d9151ec | ||
|
|
f6f90dc279 | ||
|
|
92539bbd1b | ||
|
|
311a613c0c | ||
|
|
e76d093c57 | ||
|
|
7a95eed7c2 | ||
|
|
96795f99d1 | ||
|
|
e6b77215fd | ||
|
|
403bfcab50 | ||
|
|
df5ee95898 | ||
|
|
14396181b8 | ||
|
|
0076b8816a | ||
|
|
99928cebeb | ||
|
|
de1fb49951 | ||
|
|
5c8e238eef | ||
|
|
68a1a47322 | ||
|
|
0f5a7f143c | ||
|
|
4a6dcda402 | ||
|
|
6e6c32627b | ||
|
|
d30e6f1c37 | ||
|
|
7c64489c69 | ||
|
|
73f622326f | ||
|
|
95cf355b25 | ||
|
|
54d5fe65b4 | ||
|
|
9237eb82b6 | ||
|
|
62a95e31b0 | ||
|
|
a1bbcd7194 | ||
|
|
b6f0a8ebbd | ||
|
|
23ce2a7af7 | ||
|
|
c880b7ad73 | ||
|
|
499f7f2e44 | ||
|
|
53086911fd | ||
|
|
850c4b5db5 | ||
|
|
c57062b788 | ||
|
|
26e5b6ad01 | ||
|
|
010b761286 | ||
|
|
519f94232f | ||
|
|
d0e0d4e4f3 | ||
|
|
d451290d46 | ||
|
|
29c859b7b0 | ||
|
|
91035ddd8e | ||
|
|
4eb0b6e1c3 | ||
|
|
3cff8c9ffc | ||
|
|
0003bef4e7 | ||
|
|
b82a97dd1d | ||
|
|
6968f1dca3 | ||
|
|
ecd14ab332 | ||
|
|
174ea10844 | ||
|
|
c87628398f | ||
|
|
e02e62c310 | ||
|
|
fee799a7ed | ||
|
|
1e7379c29d | ||
|
|
0b579f2110 | ||
|
|
1ea049d9c2 | ||
|
|
8e5d9e815d | ||
|
|
cba9390d2b | ||
|
|
34ca109ec4 | ||
|
|
4c7386f921 | ||
|
|
224c6e61d3 | ||
|
|
b44e9af7e5 | ||
|
|
e7a8a1320d | ||
|
|
7f51ad178b | ||
|
|
8664212c45 | ||
|
|
4ec73b2441 | ||
|
|
acd567721c | ||
|
|
a72c392b68 | ||
|
|
2e15cc9d59 | ||
|
|
eaba743bc5 | ||
|
|
157809c4d7 | ||
|
|
1447b62908 | ||
|
|
d3b5111ca8 | ||
|
|
a306a07f82 | ||
|
|
15101b87f4 | ||
|
|
6f989b09c5 | ||
|
|
110c5ece63 | ||
|
|
7837083eba | ||
|
|
f14ce69798 | ||
|
|
bfda54539b | ||
|
|
ee20852d1b | ||
|
|
a7a256adc1 | ||
|
|
0466fef9d6 | ||
|
|
dc68694cdb | ||
|
|
a1029786ad | ||
|
|
50a658e434 | ||
|
|
9a4f56e4cf | ||
|
|
06634777c1 | ||
|
|
7bd9c13f01 | ||
|
|
107456d605 | ||
|
|
f19c00f141 | ||
|
|
2ba719ebab | ||
|
|
7cb64f55e0 | ||
|
|
201423138e | ||
|
|
787fe8a0bf | ||
|
|
9d0890f92a | ||
|
|
79efac68a3 | ||
|
|
608527502a | ||
|
|
61baf002e9 | ||
|
|
0f89cec5ea | ||
|
|
6482375b72 | ||
|
|
5f2eac7053 | ||
|
|
f61118b8fe | ||
|
|
8ecdc894a8 | ||
|
|
faab493b02 | ||
|
|
d7bd736a85 | ||
|
|
4e3a1aea57 | ||
|
|
afa8b710f4 | ||
|
|
02702c8378 | ||
|
|
863d07be20 | ||
|
|
2b105dd575 | ||
|
|
7902c9321d | ||
|
|
7e2a14ef91 | ||
|
|
7135b32e14 | ||
|
|
acc954a506 | ||
|
|
4e440421af | ||
|
|
a9928d3ef5 | ||
|
|
74b9dd77b6 | ||
|
|
1e8fc36577 | ||
|
|
ec814165dd | ||
|
|
4b4b976fcd | ||
|
|
aad432d693 | ||
|
|
a6807da7f8 | ||
|
|
bf24e28377 | ||
|
|
31d3b7c0e7 | ||
|
|
63fbad01d3 | ||
|
|
d2e91a8a11 | ||
|
|
aeb6431f73 | ||
|
|
98ee87101b | ||
|
|
27b86732c2 | ||
|
|
912bbd86ae | ||
|
|
939975881e | ||
|
|
746d06800a | ||
|
|
bd31c9e10e | ||
|
|
f55083fa62 | ||
|
|
23b429d3d5 | ||
|
|
a87adced3c | ||
|
|
573f58a1c2 | ||
|
|
b7d1e95c50 | ||
|
|
a8fcd8c1c8 | ||
|
|
3307be74d9 | ||
|
|
639736f5fa | ||
|
|
2c7ad4973e | ||
|
|
eefcb09038 | ||
|
|
02a1741a3d | ||
|
|
0f2ce6529f | ||
|
|
9bc6344fa1 | ||
|
|
dbced6638a | ||
|
|
253faa132f | ||
|
|
606d782379 | ||
|
|
ad3d06343d | ||
|
|
ac5f37f40b | ||
|
|
9b5c870bd3 | ||
|
|
2b29e2d92f | ||
|
|
bafd9db24d | ||
|
|
8ff0b64884 | ||
|
|
8de3dba0ff | ||
|
|
d2ec3a1d68 | ||
|
|
9b70f121c0 | ||
|
|
a71a850213 | ||
|
|
95d06df682 | ||
|
|
addd1125d9 | ||
|
|
08cf2b4585 | ||
|
|
052708f6f4 | ||
|
|
554e881a69 | ||
|
|
4e6db19690 | ||
|
|
044936cd5b | ||
|
|
48f9b9418a | ||
|
|
8ac66a4b18 | ||
|
|
b99c08a52c | ||
|
|
61e943beb3 | ||
|
|
9e9d9e6e44 | ||
|
|
f7c6048fa4 | ||
|
|
42ee48f214 | ||
|
|
6acdf3edb3 | ||
|
|
5d63d8e29a | ||
|
|
26ef771c7a | ||
|
|
bf53799e8a | ||
|
|
ee9d976622 | ||
|
|
d5ba463964 | ||
|
|
4762cd33d6 | ||
|
|
9838a27280 | ||
|
|
484bca854f | ||
|
|
9ff0b20589 | ||
|
|
a2eaf80601 | ||
|
|
cf2a8b061b | ||
|
|
fcd6180976 | ||
|
|
6cbffaa78b | ||
|
|
9ee254b2ec | ||
|
|
cae565c158 | ||
|
|
3255803636 | ||
|
|
c8ac697010 | ||
|
|
8d8bccd334 | ||
|
|
3b0b9c0546 | ||
|
|
3608146829 | ||
|
|
3b3c9dba57 | ||
|
|
e1c2f46e8a | ||
|
|
798c7ce85a | ||
|
|
2ade3cd50d | ||
|
|
da348d552e | ||
|
|
96431bc31f | ||
|
|
2b56585f8f | ||
|
|
4ea38a7a22 | ||
|
|
5b8a9c0cd5 | ||
|
|
1f4d811ac3 | ||
|
|
7cab46ac3a | ||
|
|
0a38be6852 | ||
|
|
0ae11ebdef | ||
|
|
fdb99115d8 | ||
|
|
2bcac618d7 | ||
|
|
25184026bc | ||
|
|
3a1d04ae23 | ||
|
|
e73221c8b7 | ||
|
|
bd9923d644 | ||
|
|
6892b5a035 | ||
|
|
885ad5627f | ||
|
|
e702eb082e | ||
|
|
ef1d287fc0 | ||
|
|
ebd73457da | ||
|
|
3f037fa745 | ||
|
|
f83b84b896 | ||
|
|
be59e66976 | ||
|
|
d924d998a0 | ||
|
|
8b04b164dc | ||
|
|
118fe34378 | ||
|
|
170771fc3a | ||
|
|
06fc964bc9 | ||
|
|
6643299111 | ||
|
|
72ce3d8d93 | ||
|
|
0aad3bd186 | ||
|
|
1858974dc7 | ||
|
|
ac2f6695c5 | ||
|
|
69d0185ff6 | ||
|
|
4d8caaa944 | ||
|
|
5e40ed682b | ||
|
|
1284d1c6c1 | ||
|
|
3093f03585 | ||
|
|
f39f06896f | ||
|
|
cb1a0bfd18 | ||
|
|
c757d24483 | ||
|
|
c38b0801cc | ||
|
|
8d42210d7c | ||
|
|
191772931f | ||
|
|
5b81febdab | ||
|
|
c58805afea | ||
|
|
44876d2607 | ||
|
|
8ecdf392ad |
No files matched your search
@@ -0,0 +1,49 @@
|
||||
name: Deploy accounts worker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main-v2]
|
||||
paths:
|
||||
- 'workers/accounts/**'
|
||||
- '.github/workflows/deploy-accounts-worker.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-accounts-worker
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
cache-dependency-path: workers/accounts/pnpm-lock.yaml
|
||||
- name: Deploy
|
||||
working-directory: workers/accounts
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
npx wrangler deploy
|
||||
- name: Sync RESEND_API_KEY to the worker
|
||||
working-directory: workers/accounts
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
|
||||
run: |
|
||||
if [ -n "$RESEND_API_KEY" ]; then
|
||||
printf '%s' "$RESEND_API_KEY" | npx wrangler secret put RESEND_API_KEY
|
||||
else
|
||||
echo "RESEND_API_KEY not set in repo secrets; skipping (worker stays in stub email mode)."
|
||||
fi
|
||||
@@ -0,0 +1,38 @@
|
||||
name: Deploy forum worker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main-v2]
|
||||
paths:
|
||||
- 'workers/forum/**'
|
||||
- '.github/workflows/deploy-forum-worker.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-forum-worker
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
cache-dependency-path: workers/forum/pnpm-lock.yaml
|
||||
- name: Deploy
|
||||
working-directory: workers/forum
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
npx wrangler deploy
|
||||
@@ -8,7 +8,8 @@ name: Release desktop
|
||||
# Wails cannot cross-compile a CGO/WebKit binary, so build/ fans out to one native
|
||||
# runner per platform. Artifacts are minisign-signed (MINISIGN_* secrets), a
|
||||
# latest.json manifest is generated, and everything is published to a GitHub
|
||||
# release and mirrored to R2 (the updater reads R2 first, GitHub as fallback).
|
||||
# release and mirrored to R2 (the updater reads R2 first, then the crash worker
|
||||
# release gateway; GitHub's repository-wide "latest" is reserved for the CLI line).
|
||||
#
|
||||
# The same build/sign/manifest pipeline serves two channels. A `desktop-v*` tag
|
||||
# (or manual stable dispatch) publishes a GitHub release and moves R2's latest/
|
||||
@@ -260,9 +261,9 @@ jobs:
|
||||
working-directory: desktop
|
||||
run: go run ./cmd/sign manifest ../dist "${{ steps.ver.outputs.version }}" "${{ steps.ver.outputs.tag }}"
|
||||
|
||||
# Canary is R2-only and never appears on the GitHub releases page; the mirror
|
||||
# job picks up the signed dist via the canary-dist artifact below. Stable
|
||||
# publishes a GitHub release as usual.
|
||||
# Canary never appears on the GitHub releases page; the mirror job picks up
|
||||
# the signed dist via the canary-dist artifact below. Stable publishes a
|
||||
# GitHub release as usual.
|
||||
- name: Publish GitHub release
|
||||
if: steps.ver.outputs.channel != 'canary'
|
||||
env:
|
||||
@@ -285,7 +286,7 @@ jobs:
|
||||
needs: publish
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # gh release download
|
||||
contents: write # gh release download + compatibility manifest upload
|
||||
actions: write # dispatch pages.yml to re-bake the site version
|
||||
# Skip cleanly when R2 isn't configured; GitHub release still works as fallback.
|
||||
if: ${{ github.repository_owner == 'esengine' }}
|
||||
@@ -366,6 +367,53 @@ jobs:
|
||||
aws s3 cp assets/ "s3://${R2_BUCKET}/latest/" --recursive --endpoint-url "$ENDPOINT"
|
||||
fi
|
||||
|
||||
- name: Smoke desktop release pointers
|
||||
if: env.HAS_R2 == 'true'
|
||||
env:
|
||||
TAG: ${{ steps.ver.outputs.tag }}
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
CHANNEL: ${{ steps.ver.outputs.channel }}
|
||||
PRERELEASE: ${{ steps.ver.outputs.prerelease }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
f=assets/latest.json
|
||||
jq -e --arg version "$VERSION" --arg prefix "https://dl.reasonix.io/${TAG}/" '
|
||||
.version == $version and
|
||||
.download_page == "https://reasonix.io/#start" and
|
||||
([.platforms[] | (.url, .sig)] |
|
||||
all(type == "string" and startswith($prefix) and (contains("/releases/latest/") | not)))
|
||||
' "$f" >/dev/null
|
||||
|
||||
curl --retry 5 --retry-delay 2 -fsSL "https://dl.reasonix.io/${TAG}/latest.json" >/tmp/reasonix-desktop-tag-latest.json
|
||||
if [ "$CHANNEL" = "canary" ]; then
|
||||
pointer="canary"
|
||||
elif [ "$PRERELEASE" = "true" ]; then
|
||||
pointer=""
|
||||
else
|
||||
pointer="latest"
|
||||
fi
|
||||
if [ -n "$pointer" ]; then
|
||||
curl --retry 5 --retry-delay 2 -fsSL "https://dl.reasonix.io/${pointer}/latest.json" >/tmp/reasonix-desktop-pointer-latest.json
|
||||
jq -e --arg version "$VERSION" '.version == $version' /tmp/reasonix-desktop-pointer-latest.json >/dev/null
|
||||
fi
|
||||
|
||||
jq -r '.platforms[] | .url, .sig' "$f" | while IFS= read -r asset; do
|
||||
curl --retry 5 --retry-delay 2 -fsSI "$asset" >/dev/null
|
||||
done
|
||||
|
||||
- name: Attach desktop manifest to matching CLI release
|
||||
if: env.HAS_R2 == 'true' && steps.ver.outputs.channel != 'canary' && steps.ver.outputs.prerelease != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if gh release view "$VERSION" >/dev/null 2>&1; then
|
||||
gh release upload "$VERSION" assets/latest.json --clobber
|
||||
else
|
||||
echo "CLI release $VERSION does not exist yet; release.yml will attach the compatibility latest.json when it publishes."
|
||||
fi
|
||||
|
||||
# Stable release moved R2 latest/ — rebuild the site so its build-time baked
|
||||
# version + JSON-LD follow (site.js's runtime .rxv refresh can't touch first paint / SEO).
|
||||
- name: Refresh site to the new version
|
||||
|
||||
@@ -48,3 +48,23 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
|
||||
- name: Attach desktop manifest compatibility asset
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$TAG" in
|
||||
*-*)
|
||||
echo "prerelease $TAG — GitHub latest does not move here; skipping desktop manifest compatibility asset"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
curl --retry 5 --retry-delay 2 -fsSL https://dl.reasonix.io/latest/latest.json -o latest.raw.json
|
||||
jq '.download_page = "https://reasonix.io/#start"' latest.raw.json > latest.json
|
||||
jq -e '
|
||||
([.platforms[] | (.url, .sig)] |
|
||||
all(type == "string" and startswith("https://dl.reasonix.io/") and (contains("/releases/latest/") | not)))
|
||||
' latest.json >/dev/null
|
||||
gh release upload "$TAG" latest.json --clobber
|
||||
+10
-40
@@ -10,6 +10,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"reasonix/internal/shellparse"
|
||||
)
|
||||
|
||||
type diffOpts struct {
|
||||
@@ -546,57 +548,25 @@ func failingTestNames(out string) []string {
|
||||
}
|
||||
|
||||
func runTests(repo, testCmd string, pkgs []string) (bool, string) {
|
||||
fields := splitShellFields(testCmd)
|
||||
test, err := shellparse.ParseStaticCommand(testCmd, shellparse.StaticCommandPolicy{AllowEnvAssignments: true, AllowStderrToStdout: true})
|
||||
if err != nil {
|
||||
return false, "invalid test command: " + err.Error()
|
||||
}
|
||||
fields := test.Argv
|
||||
if len(fields) == 0 {
|
||||
fields = []string{"go", "test"}
|
||||
}
|
||||
args := append(fields[1:], pkgs...)
|
||||
cmd := exec.Command(fields[0], args...)
|
||||
cmd.Dir = repo
|
||||
if len(test.Env) > 0 {
|
||||
cmd.Env = append(os.Environ(), test.Env...)
|
||||
}
|
||||
cmd.WaitDelay = 5 * time.Minute // bound the wait if `go test` hangs
|
||||
out, err := cmd.CombinedOutput()
|
||||
return err == nil, string(out)
|
||||
}
|
||||
|
||||
// splitShellFields splits on whitespace, honoring single/double-quoted spans and stripping the quotes.
|
||||
func splitShellFields(s string) []string {
|
||||
var out []string
|
||||
var cur strings.Builder
|
||||
inSingle, inDouble := false, false
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case inSingle:
|
||||
if c == '\'' {
|
||||
inSingle = false
|
||||
} else {
|
||||
cur.WriteByte(c)
|
||||
}
|
||||
case inDouble:
|
||||
if c == '"' {
|
||||
inDouble = false
|
||||
} else {
|
||||
cur.WriteByte(c)
|
||||
}
|
||||
case c == '\'':
|
||||
inSingle = true
|
||||
case c == '"':
|
||||
inDouble = true
|
||||
case c == ' ' || c == '\t' || c == '\n':
|
||||
if cur.Len() > 0 {
|
||||
out = append(out, cur.String())
|
||||
cur.Reset()
|
||||
}
|
||||
default:
|
||||
cur.WriteByte(c)
|
||||
}
|
||||
}
|
||||
if cur.Len() > 0 {
|
||||
out = append(out, cur.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// changedGoFiles lists .go files changed by base...HEAD, excluding *_test.go
|
||||
// when includeTests is false (we want the source under test).
|
||||
func changedGoFiles(repo, base string, includeTests bool) []string {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunTestsAllowsStaticEnvAssignment(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
writeE2EFile(t, filepath.Join(repo, "go.mod"), "module example.com/e2ebenchtest\n\ngo 1.23\n")
|
||||
writeE2EFile(t, filepath.Join(repo, "env_test.go"), `package e2ebenchtest
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnv(t *testing.T) {
|
||||
if got := os.Getenv("REASONIX_E2E_ENV"); got != "ok" {
|
||||
t.Fatalf("REASONIX_E2E_ENV = %q", got)
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
ok, out := runTests(repo, "GOWORK=off REASONIX_E2E_ENV=ok go test", []string{"./..."})
|
||||
if !ok {
|
||||
t.Fatalf("runTests failed:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTestsRejectsDynamicEnvAssignment(t *testing.T) {
|
||||
ok, out := runTests(t.TempDir(), "REASONIX_E2E_ENV=$(echo ok) go test", []string{"./..."})
|
||||
if ok {
|
||||
t.Fatal("runTests accepted dynamic env assignment")
|
||||
}
|
||||
if !strings.Contains(out, "invalid test command: shell expansion") {
|
||||
t.Fatalf("output = %q, want shell expansion rejection", out)
|
||||
}
|
||||
}
|
||||
|
||||
func writeE2EFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
+9
-4
@@ -101,7 +101,9 @@ Desktop releases ride their own tag namespace, `desktop-v<semver>` (plain `v*`
|
||||
tags are the CLI release). Pushing one triggers `.github/workflows/release-desktop.yml`,
|
||||
which builds on a native runner per platform (Wails can't cross-compile a
|
||||
CGO/WebKit binary), packages each artifact, signs it with minisign, generates a
|
||||
`latest.json` manifest, publishes a GitHub release, and mirrors everything to R2.
|
||||
`latest.json` manifest, publishes a GitHub release, mirrors everything to R2,
|
||||
and attaches the current desktop manifest to the matching CLI release for old
|
||||
clients that still ask GitHub's repository-wide `latest` release for it.
|
||||
The Linux artifact links against WebKitGTK 4.1 (`-tags webkit2_41`), so it needs
|
||||
`libwebkit2gtk-4.1-0` at runtime — present by default on Ubuntu 22.04+, Fedora 40+.
|
||||
|
||||
@@ -109,9 +111,12 @@ The Linux artifact links against WebKitGTK 4.1 (`-tags webkit2_41`), so it needs
|
||||
git tag desktop-v1.1.0 && git push origin desktop-v1.1.0
|
||||
```
|
||||
|
||||
The app checks `latest.json` on startup (R2 first, GitHub as fallback) and shows
|
||||
an update banner when a newer version is published; **Settings → Software update**
|
||||
has a manual check. Self-update behavior by platform:
|
||||
The app checks `latest.json` on startup (R2 first, then the
|
||||
`crash.reasonix.io` desktop release gateway) and shows an update banner when a
|
||||
newer version is published; **Settings → Software update** has a manual check.
|
||||
The gateway resolves only the desktop `desktop-v*` release line and never uses
|
||||
GitHub's repository-wide `/releases/latest` shortcut, because plain `v*` tags are
|
||||
the CLI release line. Self-update behavior by platform:
|
||||
|
||||
- **Linux / Windows** — download, verify the minisign signature, then update in
|
||||
place: Linux replaces the binary and relaunches; Windows runs the per-user NSIS
|
||||
|
||||
+312
-46
@@ -43,7 +43,9 @@ import (
|
||||
"reasonix/internal/i18n"
|
||||
"reasonix/internal/mcpdiag"
|
||||
"reasonix/internal/memory"
|
||||
"reasonix/internal/notify"
|
||||
"reasonix/internal/plugin"
|
||||
"reasonix/internal/pluginpkg"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/skill"
|
||||
)
|
||||
@@ -141,6 +143,9 @@ type App struct {
|
||||
|
||||
metrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics
|
||||
|
||||
notificationSenderOnce sync.Once
|
||||
notificationSender notify.Sender
|
||||
|
||||
runtimeEvents asyncRuntimeEmitter
|
||||
|
||||
// promptHistoryTape is a lazy, cursor-addressed view of prompt history. It
|
||||
@@ -635,7 +640,9 @@ func (a *App) snapshotAllTabs() {
|
||||
tabs := a.runtimeTabsLocked()
|
||||
a.mu.RUnlock()
|
||||
for _, t := range tabs {
|
||||
_ = a.snapshotTab(t)
|
||||
if err := a.snapshotTab(t); err != nil {
|
||||
slog.Warn("desktop: snapshot all tabs failed", "tab", t.ID, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -657,8 +664,11 @@ func (a *App) shutdown(context.Context) {
|
||||
a.mu.RUnlock()
|
||||
for _, t := range tabs {
|
||||
if t.Ctrl != nil {
|
||||
_ = a.snapshotTab(t)
|
||||
if err := a.snapshotTab(t); err != nil {
|
||||
slog.Warn("desktop: shutdown snapshot failed", "tab", t.ID, "err", err)
|
||||
}
|
||||
t.Ctrl.Close()
|
||||
t.releaseSessionLease()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -739,6 +749,7 @@ func (a *App) SubmitToTab(tabID, input string) error {
|
||||
if ctrl == nil {
|
||||
return workspaceNotReadyErr(tab)
|
||||
}
|
||||
a.ensureTabTopicIndexedForUserTurn(tab)
|
||||
ctrl.SubmitDisplay(input, input)
|
||||
return nil
|
||||
}
|
||||
@@ -755,6 +766,7 @@ func (a *App) submitUserTurnToTab(tabID, input string) bool {
|
||||
if ctrl == nil {
|
||||
return false
|
||||
}
|
||||
a.ensureTabTopicIndexedForUserTurn(tab)
|
||||
ctrl.SubmitUserTurn(input, input)
|
||||
return true
|
||||
}
|
||||
@@ -780,6 +792,7 @@ func (a *App) RunShellForTab(tabID, command string) error {
|
||||
if ctrl == nil {
|
||||
return workspaceNotReadyErr(tab)
|
||||
}
|
||||
a.ensureTabTopicIndexedForUserTurn(tab)
|
||||
ctrl.RunShell(command)
|
||||
return nil
|
||||
}
|
||||
@@ -805,6 +818,7 @@ func (a *App) SubmitDisplayToTab(tabID, display, input string) error {
|
||||
if ctrl == nil {
|
||||
return workspaceNotReadyErr(tab)
|
||||
}
|
||||
a.ensureTabTopicIndexedForUserTurn(tab)
|
||||
ctrl.SubmitDisplay(display, input)
|
||||
return nil
|
||||
}
|
||||
@@ -824,6 +838,7 @@ func (a *App) SubmitEditedDisplayToTab(tabID, display, input, original string) e
|
||||
if ctrl == nil {
|
||||
return workspaceNotReadyErr(tab)
|
||||
}
|
||||
a.ensureTabTopicIndexedForUserTurn(tab)
|
||||
ctrl.SubmitEditedDisplay(display, input, original)
|
||||
return nil
|
||||
}
|
||||
@@ -910,6 +925,43 @@ func (a *App) snapshotTab(tab *WorkspaceTab) error {
|
||||
return ctrl.Snapshot()
|
||||
}
|
||||
|
||||
func snapshotTabDirect(tab *WorkspaceTab) error {
|
||||
if tab == nil || tab.ReadOnly || tab.Ctrl == nil {
|
||||
return nil
|
||||
}
|
||||
return tab.Ctrl.Snapshot()
|
||||
}
|
||||
|
||||
func (a *App) snapshotTabForAction(tab *WorkspaceTab, action string) error {
|
||||
if err := a.snapshotTab(tab); err != nil {
|
||||
a.reportTabSnapshotError(tab, action, err)
|
||||
if strings.TrimSpace(action) == "" {
|
||||
return fmt.Errorf("save current session: %w", err)
|
||||
}
|
||||
return fmt.Errorf("save current session before %s: %w", action, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) reportTabSnapshotError(tab *WorkspaceTab, action string, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
tabID := ""
|
||||
if tab != nil {
|
||||
tabID = tab.ID
|
||||
}
|
||||
slog.Warn("desktop: session snapshot failed", "tab", tabID, "action", action, "err", err)
|
||||
if tab == nil || tab.sink == nil {
|
||||
return
|
||||
}
|
||||
prefix := "Session autosave failed"
|
||||
if strings.TrimSpace(action) != "" && action != "autosave" {
|
||||
prefix = "Session save failed before " + action
|
||||
}
|
||||
tab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: prefix + ": " + err.Error()})
|
||||
}
|
||||
|
||||
func (a *App) reconciledSessionPathForTab(tab *WorkspaceTab) string {
|
||||
if tab == nil {
|
||||
return ""
|
||||
@@ -1311,6 +1363,45 @@ func (a *App) assignFreshSessionTopic(tab *WorkspaceTab) {
|
||||
_ = setTopicCreatedAt(topicTitleRoot(scope, workspaceRoot), topicID, time.Now().UnixMilli())
|
||||
}
|
||||
|
||||
func (a *App) ensureTabTopicIndexedForUserTurn(tab *WorkspaceTab) {
|
||||
if tab == nil || strings.TrimSpace(tab.TopicID) != "" {
|
||||
return
|
||||
}
|
||||
scope := tab.Scope
|
||||
workspaceRoot := tab.WorkspaceRoot
|
||||
if strings.TrimSpace(scope) == "global" {
|
||||
scope = "global"
|
||||
workspaceRoot = ""
|
||||
} else {
|
||||
scope = "project"
|
||||
workspaceRoot = normalizeProjectRoot(workspaceRoot)
|
||||
}
|
||||
topicID := newTopicID()
|
||||
a.mu.Lock()
|
||||
if current := a.tabs[tab.ID]; current == tab {
|
||||
if strings.TrimSpace(tab.TopicID) != "" {
|
||||
a.mu.Unlock()
|
||||
return
|
||||
}
|
||||
tab.TopicID = topicID
|
||||
tab.TopicTitle = defaultTopicTitle
|
||||
a.saveTabsLocked()
|
||||
} else {
|
||||
if strings.TrimSpace(tab.TopicID) != "" {
|
||||
a.mu.Unlock()
|
||||
return
|
||||
}
|
||||
tab.TopicID = topicID
|
||||
tab.TopicTitle = defaultTopicTitle
|
||||
}
|
||||
a.mu.Unlock()
|
||||
|
||||
_ = ensureTopicIndexed(scope, workspaceRoot, topicID, defaultTopicTitle, topicTitleSourceAuto)
|
||||
_ = setTopicCreatedAt(topicTitleRoot(scope, workspaceRoot), topicID, time.Now().UnixMilli())
|
||||
a.persistTabSessionPath(tab, tab.currentSessionPath())
|
||||
a.emitProjectTreeChanged()
|
||||
}
|
||||
|
||||
func messagesHaveConversationContent(messages []provider.Message) bool {
|
||||
for _, msg := range messages {
|
||||
if msg.Role != provider.RoleSystem {
|
||||
@@ -1345,6 +1436,9 @@ func (a *App) ClearSession() error {
|
||||
if err := ctrl.ClearSession(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tab.ensureSessionLease(ctrl.SessionPath()); err != nil {
|
||||
return err
|
||||
}
|
||||
tab.resetTelemetry()
|
||||
a.persistTabSessionPath(tab, ctrl.SessionPath())
|
||||
a.invalidatePromptHistoryCache()
|
||||
@@ -1389,6 +1483,8 @@ func (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.Sessi
|
||||
TokenMode: currentTabTokenMode(tab),
|
||||
SharedHost: sharedHost,
|
||||
CleanupPendingReconciler: reconcileDesktopCleanupPending,
|
||||
SessionRecoveryMeta: a.tabSessionRecoveryMeta(tab),
|
||||
OnSessionRecovered: a.handleTabSessionRecovered(tab),
|
||||
})
|
||||
if err != nil {
|
||||
if teardownTimedOut {
|
||||
@@ -1420,6 +1516,10 @@ func (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.Sessi
|
||||
applyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)
|
||||
newCtrl.SetGoal(tab.goal)
|
||||
path := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label())
|
||||
if err := tab.ensureSessionLease(path); err != nil {
|
||||
newCtrl.Close()
|
||||
return err
|
||||
}
|
||||
newCtrl.SetSessionPath(path)
|
||||
|
||||
a.mu.Lock()
|
||||
@@ -1461,13 +1561,17 @@ func removeDesktopSessionArtifacts(path string) error {
|
||||
type CheckpointMeta struct {
|
||||
Turn int `json:"turn"`
|
||||
Prompt string `json:"prompt"`
|
||||
Files []string `json:"files"` // cumulative files RestoreCode would affect from this turn
|
||||
Files []string `json:"files"` // stable preview of cumulative files RestoreCode would affect from this turn
|
||||
FileCount int `json:"fileCount"` // full cumulative file count, including entries omitted from Files
|
||||
FilesTruncated bool `json:"filesTruncated,omitempty"`
|
||||
TurnFileCount int `json:"turnFileCount"` // files changed during this turn only
|
||||
Time int64 `json:"time"` // unix milliseconds
|
||||
CanCode bool `json:"canCode"`
|
||||
CanConversation bool `json:"canConversation"`
|
||||
}
|
||||
|
||||
const checkpointFilePreviewLimit = 60
|
||||
|
||||
// Checkpoints lists the session's rewind points, oldest first, for the rewind UI.
|
||||
func (a *App) Checkpoints() []CheckpointMeta {
|
||||
return a.CheckpointsForTab("")
|
||||
@@ -1503,23 +1607,48 @@ func (a *App) CheckpointsForTab(tabID string) []CheckpointMeta {
|
||||
// files RestoreCode would actually affect from this turn.
|
||||
hasCodeAfter := false
|
||||
codeFileSet := make(map[string]bool, len(metas)*2)
|
||||
codeFilePreview := []string{}
|
||||
for i := len(out) - 1; i >= 0; i-- {
|
||||
if len(out[i].Files) > 0 {
|
||||
hasCodeAfter = true
|
||||
}
|
||||
for _, f := range out[i].Files {
|
||||
if codeFileSet[f] {
|
||||
continue
|
||||
}
|
||||
codeFileSet[f] = true
|
||||
codeFilePreview = insertCheckpointFilePreview(codeFilePreview, f, checkpointFilePreviewLimit)
|
||||
}
|
||||
out[i].CanCode = hasCodeAfter
|
||||
out[i].Files = make([]string, 0, len(codeFileSet))
|
||||
for f := range codeFileSet {
|
||||
out[i].Files = append(out[i].Files, f)
|
||||
}
|
||||
sort.Strings(out[i].Files) // map iteration is unordered; keep the list stable
|
||||
out[i].FileCount = len(codeFileSet)
|
||||
out[i].Files = append([]string{}, codeFilePreview...)
|
||||
out[i].FilesTruncated = out[i].FileCount > len(out[i].Files)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func insertCheckpointFilePreview(preview []string, path string, limit int) []string {
|
||||
if limit <= 0 || path == "" {
|
||||
return preview
|
||||
}
|
||||
idx := sort.SearchStrings(preview, path)
|
||||
if idx < len(preview) && preview[idx] == path {
|
||||
return preview
|
||||
}
|
||||
if len(preview) < limit {
|
||||
preview = append(preview, "")
|
||||
copy(preview[idx+1:], preview[idx:])
|
||||
preview[idx] = path
|
||||
return preview
|
||||
}
|
||||
if idx >= limit {
|
||||
return preview
|
||||
}
|
||||
copy(preview[idx+1:], preview[idx:limit-1])
|
||||
preview[idx] = path
|
||||
return preview
|
||||
}
|
||||
|
||||
// ToolResultForTab returns the full arguments and output for one tool call that
|
||||
// were elided from the frontend's in-memory items[] for memory efficiency. The
|
||||
// caller (frontend ToolCard) loads this on demand when the user expands a
|
||||
@@ -1787,7 +1916,11 @@ func (a *App) ListSessions() []SessionMeta {
|
||||
out := make([]SessionMeta, 0, len(infos))
|
||||
for _, s := range infos {
|
||||
_, isOpen := open[s.Path]
|
||||
meta := sessionMetaFromInfo(s, titles[filepath.Base(s.Path)], s.Path == active, isOpen, 0)
|
||||
title := strings.TrimSpace(s.CustomTitle)
|
||||
if title == "" {
|
||||
title = titles[filepath.Base(s.Path)]
|
||||
}
|
||||
meta := sessionMetaFromInfo(s, title, s.Path == active, isOpen, 0)
|
||||
if route, ok := channelRoutes[sessionRuntimeKey(s.Path)]; ok {
|
||||
applyChannelSessionRoute(&meta, route)
|
||||
}
|
||||
@@ -1812,7 +1945,11 @@ func (a *App) ListTrashedSessions() []SessionMeta {
|
||||
continue
|
||||
}
|
||||
deletedAt := trashedSessionDeletedAt(path)
|
||||
out = append(out, sessionMetaFromInfo(infos[0], titles[filepath.Base(path)], false, false, deletedAt))
|
||||
title := strings.TrimSpace(infos[0].CustomTitle)
|
||||
if title == "" {
|
||||
title = titles[filepath.Base(path)]
|
||||
}
|
||||
out = append(out, sessionMetaFromInfo(infos[0], title, false, false, deletedAt))
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
@@ -2179,7 +2316,11 @@ func (a *App) prepareRemovedSessionRuntimes(removed []removedSessionRuntime) err
|
||||
continue
|
||||
}
|
||||
if err := item.ctrl.Snapshot(); err != nil {
|
||||
return err
|
||||
if !errors.Is(err, agent.ErrSessionSnapshotConflict) {
|
||||
return err
|
||||
}
|
||||
slog.Warn("desktop: skipping stale runtime snapshot before removing session",
|
||||
"session", item.sessionPath, "err", err)
|
||||
}
|
||||
item.ctrl.SetSessionPath("")
|
||||
a.quiesceTabAutosave(item.tab)
|
||||
@@ -2288,6 +2429,7 @@ func (a *App) closeRemovedSessionRuntime(item removedSessionRuntime, closed map[
|
||||
releasedTabs[item.tab] = true
|
||||
}
|
||||
a.releaseTabSharedHost(item.tab)
|
||||
item.tab.releaseSessionLease()
|
||||
}
|
||||
}
|
||||
if item.ctrl == nil {
|
||||
@@ -2315,11 +2457,7 @@ func (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error {
|
||||
root = ""
|
||||
}
|
||||
if topicID == "" {
|
||||
topic, err := a.CreateTopic(scope, root, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
topicID = topic.ID
|
||||
return a.openTransientBlankRuntime(scope, root)
|
||||
}
|
||||
var err error
|
||||
if a.singleSurfaceLayoutEnabled() {
|
||||
@@ -2332,6 +2470,56 @@ func (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) openTransientBlankRuntime(scope, workspaceRoot string) error {
|
||||
scope = strings.TrimSpace(scope)
|
||||
if scope != "project" {
|
||||
scope = "global"
|
||||
}
|
||||
actualRoot := ""
|
||||
if scope == "project" {
|
||||
workspaceRoot = normalizeProjectRoot(workspaceRoot)
|
||||
if workspaceRoot == "" {
|
||||
return fmt.Errorf("workspaceRoot is required")
|
||||
}
|
||||
saveWorkspace(workspaceRoot)
|
||||
_ = addProject(workspaceRoot, "")
|
||||
actualRoot = workspaceRoot
|
||||
} else {
|
||||
actualRoot = globalWorkspaceRoot()
|
||||
if err := os.MkdirAll(actualRoot, 0o755); err != nil {
|
||||
return fmt.Errorf("create global workspace: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
model, toolApprovalMode := desktopNewSessionDefaults()
|
||||
sessionPath, err := createEmptySessionFile(desktopSessionDir(actualRoot), model)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tab := &WorkspaceTab{
|
||||
Scope: scope,
|
||||
WorkspaceRoot: actualRoot,
|
||||
TopicTitle: defaultTopicTitle,
|
||||
SessionPath: sessionPath,
|
||||
model: model,
|
||||
tokenMode: boot.TokenModeFull,
|
||||
mode: tabModeFromAxes(false, toolApprovalMode == control.ToolApprovalYolo),
|
||||
toolApprovalMode: toolApprovalMode,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
a.mu.Lock()
|
||||
tab.ID = a.newUniqueTabIDLocked()
|
||||
tab.sink = &tabEventSink{tabID: tab.ID, app: a}
|
||||
a.tabs[tab.ID] = tab
|
||||
a.tabOrder = append(a.tabOrder, tab.ID)
|
||||
a.activeTabID = tab.ID
|
||||
a.saveTabsLocked()
|
||||
a.mu.Unlock()
|
||||
|
||||
a.startTabControllerBuild(tab)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) beginDestroySessionJobs(dir, sessionPath string) []control.SessionDestroyHandle {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
@@ -2447,9 +2635,19 @@ func (a *App) PurgeTrashedSession(path string) error {
|
||||
}
|
||||
|
||||
// RenameSession sets a custom display name for a session (empty clears it back to
|
||||
// the preview). It only affects the history panel; the file on disk is unchanged.
|
||||
// the preview). The transcript file is unchanged; the canonical name lives in
|
||||
// the branch meta sidecar, with the legacy .titles.json map kept as a
|
||||
// compatibility write-through for older desktop data paths.
|
||||
func (a *App) RenameSession(path, title string) error {
|
||||
if err := setSessionTitle(a.activeSessionDir(), path, title); err != nil {
|
||||
dir := a.activeSessionDir()
|
||||
sessionPath, _, err := validateSessionPath(dir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := agent.RenameSession(sessionPath, title); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := setSessionTitle(dir, sessionPath, title); err != nil {
|
||||
return err
|
||||
}
|
||||
a.invalidatePromptHistoryCache()
|
||||
@@ -2609,11 +2807,13 @@ func (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string
|
||||
if sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {
|
||||
return nil
|
||||
}
|
||||
profile := loadTabSessionProfile(sessionPath)
|
||||
|
||||
ctrl := tab.Ctrl
|
||||
if ctrl == nil {
|
||||
a.mu.Lock()
|
||||
tab.SessionPath = sessionPath
|
||||
applyTabSessionProfile(tab, profile)
|
||||
tab.Ready = false
|
||||
tab.StartupErr = ""
|
||||
tab.ActivityStatus = ""
|
||||
@@ -2630,18 +2830,27 @@ func (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string
|
||||
return nil
|
||||
}
|
||||
|
||||
_ = a.snapshotTab(tab) // persist writable sessions before switching the view.
|
||||
if err := a.snapshotTabForAction(tab, "switching sessions"); err != nil {
|
||||
return err
|
||||
}
|
||||
if oldPath := a.reconciledSessionPathForTab(tab); oldPath != "" {
|
||||
if err := saveTabSessionMeta(tab, oldPath); err != nil {
|
||||
return fmt.Errorf("save current session metadata before switching sessions: %w", err)
|
||||
}
|
||||
}
|
||||
if tab.hasActiveRuntimeWork() {
|
||||
if !a.detachRuntimeForReplacement(tab) {
|
||||
return fmt.Errorf("current session runtime cannot be detached")
|
||||
}
|
||||
} else {
|
||||
ctrl.Close()
|
||||
tab.releaseSessionLease()
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
tab.Ctrl = nil
|
||||
tab.SessionPath = sessionPath
|
||||
applyTabSessionProfile(tab, profile)
|
||||
tab.Ready = false
|
||||
tab.StartupErr = ""
|
||||
tab.ActivityStatus = ""
|
||||
@@ -3249,8 +3458,10 @@ func (a *App) RemoveWorkspace(dir string) error {
|
||||
if !tabInWorkspace(tab, dir) {
|
||||
continue
|
||||
}
|
||||
if tab.Ctrl != nil && !tab.ReadOnly {
|
||||
_ = tab.Ctrl.Snapshot()
|
||||
if err := snapshotTabDirect(tab); err != nil {
|
||||
a.mu.Unlock()
|
||||
slog.Warn("desktop: snapshot before removing workspace failed", "tab", id, "workspace", dir, "err", err)
|
||||
return fmt.Errorf("save current session before removing workspace: %w", err)
|
||||
}
|
||||
a.markTabRemovedLocked(tab)
|
||||
closeTabs = append(closeTabs, tab)
|
||||
@@ -4824,6 +5035,7 @@ func (a *App) Commands() []CommandInfo {
|
||||
{Name: "remember", Description: i18n.M.CmdRemember, Kind: "builtin"},
|
||||
{Name: "mcp", Description: i18n.M.CmdMcp, Kind: "builtin"},
|
||||
{Name: "hooks", Description: i18n.M.CmdHooks, Kind: "builtin"},
|
||||
{Name: "plugins", Description: i18n.M.CmdPlugins, Kind: "builtin"},
|
||||
{Name: "theme", Description: i18n.M.CmdTheme, Kind: "builtin"},
|
||||
{Name: "skill", Description: i18n.M.CmdSkill, Kind: "builtin"},
|
||||
{Name: "reload-cmd", Description: i18n.M.CmdReloadCmd, Kind: "builtin"},
|
||||
@@ -4890,6 +5102,9 @@ func (a *App) SlashArgs(input string) SlashArgsResult {
|
||||
DisconnectedMCP: ctrl.DisconnectedMCPNames(),
|
||||
CurrentModel: model,
|
||||
}
|
||||
if names, err := pluginpkg.InstalledNames(config.ReasonixHomeDir()); err == nil {
|
||||
data.PluginNames = names
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, m := range a.Models() {
|
||||
data.ModelRefs = append(data.ModelRefs, m.Ref)
|
||||
@@ -4920,6 +5135,7 @@ type CapabilitiesView struct {
|
||||
Servers []ServerView `json:"servers"`
|
||||
Skills []SkillView `json:"skills"`
|
||||
SkillRoots []SkillRootView `json:"skillRoots"`
|
||||
Plugins []PluginView `json:"plugins"`
|
||||
}
|
||||
|
||||
// SkillsSettingsView is the skills management page's data, split from MCP
|
||||
@@ -5002,6 +5218,7 @@ func (a *App) Capabilities() CapabilitiesView {
|
||||
Servers: a.MCPServers(),
|
||||
Skills: skills.Skills,
|
||||
SkillRoots: skills.SkillRoots,
|
||||
Plugins: a.Plugins(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5433,6 +5650,27 @@ func (a *App) PickSkillFolder() (string, error) {
|
||||
return normalizeSkillPath(dir), nil
|
||||
}
|
||||
|
||||
// PickPluginFolder opens a directory picker for choosing a local plugin package
|
||||
// source. It returns the selected directory path; plugin install/plan performs
|
||||
// manifest validation and decides whether to copy or link the package.
|
||||
func (a *App) PickPluginFolder() (string, error) {
|
||||
if a.ctx == nil {
|
||||
return "", nil
|
||||
}
|
||||
cur := a.activeWorkspaceRoot()
|
||||
if strings.TrimSpace(cur) == "" {
|
||||
cur, _ = os.Getwd()
|
||||
}
|
||||
dir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{
|
||||
Title: "Choose plugin folder",
|
||||
DefaultDirectory: dialogDefaultDirectory(cur),
|
||||
})
|
||||
if err != nil || dir == "" {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Clean(dir), nil
|
||||
}
|
||||
|
||||
// AddSkillPath adds a custom skill root to the user config and rebuilds the
|
||||
// controller so the skills index and slash menu reflect it immediately.
|
||||
func (a *App) AddSkillPath(path string) error {
|
||||
@@ -6370,7 +6608,9 @@ func (a *App) SetModelForTab(tabID, name string) error {
|
||||
if prevPath == "" {
|
||||
prevPath = tab.Ctrl.SessionPath()
|
||||
}
|
||||
_ = a.snapshotTab(tab)
|
||||
if err := a.snapshotTabForAction(tab, "changing model"); err != nil {
|
||||
return err
|
||||
}
|
||||
carried = tab.Ctrl.History()
|
||||
tab.Ctrl.Close()
|
||||
}
|
||||
@@ -6389,11 +6629,27 @@ func (a *App) SetModelForTab(tabID, name string) error {
|
||||
TokenMode: currentTabTokenMode(tab),
|
||||
SharedHost: sharedHost,
|
||||
CleanupPendingReconciler: reconcileDesktopCleanupPending,
|
||||
SessionRecoveryMeta: a.tabSessionRecoveryMeta(tab),
|
||||
OnSessionRecovered: a.handleTabSessionRecovered(tab),
|
||||
})
|
||||
if err != nil {
|
||||
tab.releaseSessionLease()
|
||||
return err
|
||||
}
|
||||
a.bindControllerDisplayRecorder(newCtrl)
|
||||
newCtrl.EnableInteractiveApproval()
|
||||
applyTabModeToController(newCtrl, tab.mode)
|
||||
applyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)
|
||||
newCtrl.SetGoal(tab.goal)
|
||||
|
||||
path := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())
|
||||
if err := tab.ensureSessionLease(path); err != nil {
|
||||
newCtrl.Close()
|
||||
tab.releaseSessionLease()
|
||||
return err
|
||||
}
|
||||
resumeWithFreshSystemPrompt(newCtrl, carried, path)
|
||||
a.persistTabSessionPath(tab, path)
|
||||
a.mu.Lock()
|
||||
tab.Ctrl = newCtrl
|
||||
tab.model = name
|
||||
@@ -6401,14 +6657,6 @@ func (a *App) SetModelForTab(tabID, name string) error {
|
||||
tab.Label = newCtrl.Label()
|
||||
a.saveTabsLocked()
|
||||
a.mu.Unlock()
|
||||
newCtrl.EnableInteractiveApproval()
|
||||
applyTabModeToController(newCtrl, tab.mode)
|
||||
applyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)
|
||||
newCtrl.SetGoal(tab.goal)
|
||||
|
||||
path := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())
|
||||
resumeWithFreshSystemPrompt(newCtrl, carried, path)
|
||||
a.persistTabSessionPath(tab, path)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6474,7 +6722,9 @@ func (a *App) SetEffortForTab(tabID, level string) error {
|
||||
if prevPath == "" {
|
||||
prevPath = tab.Ctrl.SessionPath()
|
||||
}
|
||||
_ = a.snapshotTab(tab)
|
||||
if err := a.snapshotTabForAction(tab, "changing effort"); err != nil {
|
||||
return err
|
||||
}
|
||||
carried = tab.Ctrl.History()
|
||||
tab.Ctrl.Close()
|
||||
}
|
||||
@@ -6489,11 +6739,26 @@ func (a *App) SetEffortForTab(tabID, level string) error {
|
||||
TokenMode: currentTabTokenMode(tab),
|
||||
SharedHost: sharedHost,
|
||||
CleanupPendingReconciler: reconcileDesktopCleanupPending,
|
||||
SessionRecoveryMeta: a.tabSessionRecoveryMeta(tab),
|
||||
OnSessionRecovered: a.handleTabSessionRecovered(tab),
|
||||
})
|
||||
if err != nil {
|
||||
tab.releaseSessionLease()
|
||||
return err
|
||||
}
|
||||
a.bindControllerDisplayRecorder(newCtrl)
|
||||
newCtrl.EnableInteractiveApproval()
|
||||
applyTabModeToController(newCtrl, tab.mode)
|
||||
applyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)
|
||||
newCtrl.SetGoal(tab.goal)
|
||||
path := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())
|
||||
if err := tab.ensureSessionLease(path); err != nil {
|
||||
newCtrl.Close()
|
||||
tab.releaseSessionLease()
|
||||
return err
|
||||
}
|
||||
resumeWithFreshSystemPrompt(newCtrl, carried, path)
|
||||
a.persistTabSessionPath(tab, path)
|
||||
a.mu.Lock()
|
||||
tab.Ctrl = newCtrl
|
||||
tab.model = modelRef
|
||||
@@ -6503,13 +6768,6 @@ func (a *App) SetEffortForTab(tabID, level string) error {
|
||||
tab.Ready = true
|
||||
a.saveTabsLocked()
|
||||
a.mu.Unlock()
|
||||
newCtrl.EnableInteractiveApproval()
|
||||
applyTabModeToController(newCtrl, tab.mode)
|
||||
applyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)
|
||||
newCtrl.SetGoal(tab.goal)
|
||||
path := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())
|
||||
resumeWithFreshSystemPrompt(newCtrl, carried, path)
|
||||
a.persistTabSessionPath(tab, path)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6551,7 +6809,9 @@ func (a *App) SetTokenModeForTab(tabID, mode string) error {
|
||||
if prevPath == "" {
|
||||
prevPath = oldCtrl.SessionPath()
|
||||
}
|
||||
_ = a.snapshotTab(tab)
|
||||
if err := a.snapshotTabForAction(tab, "changing token mode"); err != nil {
|
||||
return err
|
||||
}
|
||||
carried = oldCtrl.History()
|
||||
}
|
||||
sharedHost := a.lookupSharedHost(tab.SharedHostKey)
|
||||
@@ -6565,14 +6825,27 @@ func (a *App) SetTokenModeForTab(tabID, mode string) error {
|
||||
TokenMode: mode,
|
||||
SharedHost: sharedHost,
|
||||
CleanupPendingReconciler: reconcileDesktopCleanupPending,
|
||||
SessionRecoveryMeta: a.tabSessionRecoveryMeta(tab),
|
||||
OnSessionRecovered: a.handleTabSessionRecovered(tab),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.bindControllerDisplayRecorder(newCtrl)
|
||||
newCtrl.EnableInteractiveApproval()
|
||||
applyTabModeToController(newCtrl, tab.mode)
|
||||
applyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)
|
||||
newCtrl.SetGoal(tab.goal)
|
||||
path := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())
|
||||
if err := tab.ensureSessionLease(path); err != nil {
|
||||
newCtrl.Close()
|
||||
return err
|
||||
}
|
||||
resumeWithFreshSystemPrompt(newCtrl, carried, path)
|
||||
if oldCtrl != nil {
|
||||
oldCtrl.Close()
|
||||
}
|
||||
a.persistTabSessionPath(tab, path)
|
||||
a.mu.Lock()
|
||||
tab.Ctrl = newCtrl
|
||||
tab.model = modelRef
|
||||
@@ -6582,13 +6855,6 @@ func (a *App) SetTokenModeForTab(tabID, mode string) error {
|
||||
tab.Ready = true
|
||||
a.saveTabsLocked()
|
||||
a.mu.Unlock()
|
||||
newCtrl.EnableInteractiveApproval()
|
||||
applyTabModeToController(newCtrl, tab.mode)
|
||||
applyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)
|
||||
newCtrl.SetGoal(tab.goal)
|
||||
path := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())
|
||||
resumeWithFreshSystemPrompt(newCtrl, carried, path)
|
||||
a.persistTabSessionPath(tab, path)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -49,7 +50,12 @@ func waitForFile(t *testing.T, path, want string) {
|
||||
|
||||
func waitForAutosaveIdle(t *testing.T, tab *WorkspaceTab) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
waitForAutosaveIdleWithin(t, tab, 2*time.Second)
|
||||
}
|
||||
|
||||
func waitForAutosaveIdleWithin(t *testing.T, tab *WorkspaceTab, timeout time.Duration) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
tab.saveMu.Lock()
|
||||
idle := !tab.saving && !tab.saveAgain
|
||||
@@ -132,6 +138,314 @@ func TestScheduleSnapshotCoalesces(t *testing.T) {
|
||||
waitForAutosaveIdle(t, tab)
|
||||
}
|
||||
|
||||
func TestAutosaveFailureRetriesAndRecoversOnNextTurnDone(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "blocked.jsonl")
|
||||
if err := os.Mkdir(path, 0o755); err != nil {
|
||||
t.Fatalf("mkdir blocked path: %v", err)
|
||||
}
|
||||
a, tab := appWithTab(t, path)
|
||||
_ = a
|
||||
|
||||
tab.sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
waitForAutosaveIdleWithin(t, tab, 5*time.Second)
|
||||
|
||||
tab.saveMu.Lock()
|
||||
failures := tab.saveFailures
|
||||
tab.saveMu.Unlock()
|
||||
if failures == 0 {
|
||||
t.Fatal("autosave failure should be recorded and retried")
|
||||
}
|
||||
if info, err := os.Stat(path); err != nil || !info.IsDir() {
|
||||
t.Fatalf("blocked session path should still be the directory, info=%v err=%v", info, err)
|
||||
}
|
||||
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatalf("remove blocked dir: %v", err)
|
||||
}
|
||||
tab.sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
waitForFile(t, path, "remember this turn")
|
||||
waitForAutosaveIdle(t, tab)
|
||||
|
||||
tab.saveMu.Lock()
|
||||
failures = tab.saveFailures
|
||||
tab.saveMu.Unlock()
|
||||
if failures != 0 {
|
||||
t.Fatalf("autosave failures after recovery = %d, want 0", failures)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopSnapshotConflictRecoveryUpdatesTabAndProjectTree(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
root := globalTabWorkspaceRoot()
|
||||
dir := desktopSessionDir(root)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
originalPath := filepath.Join(dir, "session.jsonl")
|
||||
originalTopic := "topic_original"
|
||||
if err := setTopicTitle("", originalTopic, "Original"); err != nil {
|
||||
t.Fatalf("set original topic title: %v", err)
|
||||
}
|
||||
current := agent.NewSession("sys")
|
||||
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
|
||||
if err := current.Save(originalPath); err != nil {
|
||||
t.Fatalf("Save current: %v", err)
|
||||
}
|
||||
if err := agent.SaveBranchMeta(originalPath, agent.BranchMeta{
|
||||
Scope: "global",
|
||||
TopicID: originalTopic,
|
||||
TopicTitle: "Original",
|
||||
Preview: "first",
|
||||
Turns: 2,
|
||||
SchemaVersion: agent.BranchMetaCountsVersion,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveBranchMeta original: %v", err)
|
||||
}
|
||||
|
||||
staleSess := agent.NewSession("sys")
|
||||
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
|
||||
staleExec := agent.New(stubProvider{}, tool.NewRegistry(), staleSess, agent.Options{}, event.Discard)
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{},
|
||||
detachedSessions: map[string]*WorkspaceTab{},
|
||||
activeTabID: "recovery_tab",
|
||||
}
|
||||
tab := &WorkspaceTab{
|
||||
ID: "recovery_tab",
|
||||
Scope: "global",
|
||||
WorkspaceRoot: root,
|
||||
TopicID: originalTopic,
|
||||
TopicTitle: "Original",
|
||||
SessionPath: originalPath,
|
||||
Ready: true,
|
||||
model: "test-model",
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
|
||||
tab.Ctrl = control.New(control.Options{
|
||||
Executor: staleExec,
|
||||
SessionDir: dir,
|
||||
SessionPath: originalPath,
|
||||
Label: "test",
|
||||
Sink: tab.sink,
|
||||
SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
|
||||
OnSessionRecovered: app.handleTabSessionRecovered(tab),
|
||||
})
|
||||
app.tabs[tab.ID] = tab
|
||||
|
||||
if err := tab.Ctrl.Snapshot(); err != nil {
|
||||
t.Fatalf("Snapshot: %v", err)
|
||||
}
|
||||
recoveryPath := tab.Ctrl.SessionPath()
|
||||
if recoveryPath == "" || recoveryPath == originalPath {
|
||||
t.Fatalf("recovery path = %q, want distinct path", recoveryPath)
|
||||
}
|
||||
if tab.SessionPath != recoveryPath {
|
||||
t.Fatalf("tab session path = %q, want recovery path %q", tab.SessionPath, recoveryPath)
|
||||
}
|
||||
if tab.TopicID == "" || tab.TopicID == originalTopic {
|
||||
t.Fatalf("tab topic ID = %q, want fresh recovery topic", tab.TopicID)
|
||||
}
|
||||
meta, ok, err := agent.LoadBranchMeta(recoveryPath)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta recovery ok=%v err=%v", ok, err)
|
||||
}
|
||||
if !meta.Recovered || meta.TopicID != tab.TopicID || meta.TopicTitle != tab.TopicTitle {
|
||||
t.Fatalf("recovery meta = %+v, tab topic=%q/%q", meta, tab.TopicID, tab.TopicTitle)
|
||||
}
|
||||
tabMeta := app.tabMeta(tab, true)
|
||||
if !tabMeta.Recovered || tabMeta.RecoveryDigest != meta.RecoveryDigest || tabMeta.RecoveryParentID != string(meta.ParentID) {
|
||||
t.Fatalf("tab recovery meta = %+v, want digest %q parent %q", tabMeta, meta.RecoveryDigest, meta.ParentID)
|
||||
}
|
||||
nodes := app.ListProjectTree()
|
||||
found := false
|
||||
var walk func([]ProjectNode)
|
||||
walk = func(list []ProjectNode) {
|
||||
for _, node := range list {
|
||||
if node.TopicID == tab.TopicID {
|
||||
found = true
|
||||
if !node.Recovered || node.RecoveryDigest != meta.RecoveryDigest || node.RecoveryParentID != string(meta.ParentID) {
|
||||
t.Fatalf("project tree recovery node = %+v, want digest %q parent %q", node, meta.RecoveryDigest, meta.ParentID)
|
||||
}
|
||||
}
|
||||
walk(node.Children)
|
||||
}
|
||||
}
|
||||
walk(nodes)
|
||||
if !found {
|
||||
t.Fatalf("project tree did not include recovery topic %q: %#v", tab.TopicID, nodes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopSnapshotConflictRecoveryRequiresRecoveryLease(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
root := globalTabWorkspaceRoot()
|
||||
dir := desktopSessionDir(root)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
originalPath := filepath.Join(dir, "session.jsonl")
|
||||
current := agent.NewSession("sys")
|
||||
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
|
||||
if err := current.Save(originalPath); err != nil {
|
||||
t.Fatalf("Save current: %v", err)
|
||||
}
|
||||
|
||||
staleSess := agent.NewSession("sys")
|
||||
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
|
||||
recovery, err := staleSess.SaveRecoveryBranch(agent.RecoveryBranchOptions{
|
||||
OriginalPath: originalPath,
|
||||
BranchMeta: agent.BranchMeta{
|
||||
Name: agent.RecoveryBranchDefaultName,
|
||||
Scope: "global",
|
||||
TopicID: "topic_recovery",
|
||||
TopicTitle: "Recovery",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveRecoveryBranch: %v", err)
|
||||
}
|
||||
lease, err := agent.TryAcquireSessionLease(recovery.Path)
|
||||
if err != nil {
|
||||
t.Fatalf("TryAcquireSessionLease recovery: %v", err)
|
||||
}
|
||||
defer lease.Release()
|
||||
|
||||
staleExec := agent.New(stubProvider{}, tool.NewRegistry(), staleSess, agent.Options{}, event.Discard)
|
||||
runtimeEvents := make(chan runtimeEventEnvelope, 4)
|
||||
app := &App{
|
||||
ctx: context.Background(),
|
||||
tabs: map[string]*WorkspaceTab{},
|
||||
detachedSessions: map[string]*WorkspaceTab{},
|
||||
activeTabID: "recovery_tab",
|
||||
}
|
||||
app.runtimeEvents.emit = func(ctx context.Context, name string, payload ...interface{}) {
|
||||
runtimeEvents <- runtimeEventEnvelope{
|
||||
ctx: ctx,
|
||||
name: name,
|
||||
payload: append([]interface{}(nil), payload...),
|
||||
}
|
||||
}
|
||||
tab := &WorkspaceTab{
|
||||
ID: "recovery_tab",
|
||||
Scope: "global",
|
||||
WorkspaceRoot: root,
|
||||
TopicID: "topic_original",
|
||||
TopicTitle: "Original",
|
||||
SessionPath: originalPath,
|
||||
Ready: true,
|
||||
model: "test-model",
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
|
||||
tab.Ctrl = control.New(control.Options{
|
||||
Executor: staleExec,
|
||||
SessionDir: dir,
|
||||
SessionPath: originalPath,
|
||||
Label: "test",
|
||||
Sink: tab.sink,
|
||||
SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
|
||||
OnSessionRecovered: app.handleTabSessionRecovered(tab),
|
||||
})
|
||||
app.tabs[tab.ID] = tab
|
||||
|
||||
err = tab.Ctrl.Snapshot()
|
||||
if !errors.Is(err, agent.ErrSessionLeaseHeld) {
|
||||
t.Fatalf("Snapshot err = %v, want ErrSessionLeaseHeld", err)
|
||||
}
|
||||
if got := tab.Ctrl.SessionPath(); got != originalPath {
|
||||
t.Fatalf("controller session path = %q, want original %q", got, originalPath)
|
||||
}
|
||||
if tab.SessionPath != originalPath {
|
||||
t.Fatalf("tab session path = %q, want original %q", tab.SessionPath, originalPath)
|
||||
}
|
||||
if tab.TopicID != "topic_original" {
|
||||
t.Fatalf("tab topic ID = %q, want original topic", tab.TopicID)
|
||||
}
|
||||
|
||||
deadline := time.After(time.Second)
|
||||
for {
|
||||
select {
|
||||
case emitted := <-runtimeEvents:
|
||||
if emitted.name != "session:recovery-failed" {
|
||||
continue
|
||||
}
|
||||
if len(emitted.payload) != 1 {
|
||||
t.Fatalf("session:recovery-failed payload count = %d, want 1", len(emitted.payload))
|
||||
}
|
||||
failed, ok := emitted.payload[0].(sessionRecoveryFailedEvent)
|
||||
if !ok {
|
||||
t.Fatalf("session:recovery-failed payload type = %T, want sessionRecoveryFailedEvent", emitted.payload[0])
|
||||
}
|
||||
if failed.Reason != "lease_held" {
|
||||
t.Fatalf("session:recovery-failed reason = %q, want lease_held", failed.Reason)
|
||||
}
|
||||
return
|
||||
case <-deadline:
|
||||
t.Fatal("session:recovery-failed event was not emitted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetActiveTabBlocksWhenCurrentSessionCannotPersist(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "blocked.jsonl")
|
||||
if err := os.Mkdir(path, 0o755); err != nil {
|
||||
t.Fatalf("mkdir blocked path: %v", err)
|
||||
}
|
||||
a, _ := appWithTab(t, path)
|
||||
a.tabs["target_tab"] = &WorkspaceTab{
|
||||
ID: "target_tab",
|
||||
Scope: "global",
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
a.tabOrder = []string{"test_tab", "target_tab"}
|
||||
|
||||
err := a.SetActiveTab("target_tab")
|
||||
if err == nil || !strings.Contains(err.Error(), "save current session before switching tabs") {
|
||||
t.Fatalf("SetActiveTab error = %v, want persistence failure", err)
|
||||
}
|
||||
if a.activeTabID != "test_tab" {
|
||||
t.Fatalf("active tab = %q, want original tab after failed save", a.activeTabID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRebindSessionBlocksWhenCurrentSessionCannotPersist(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "blocked.jsonl")
|
||||
if err := os.Mkdir(path, 0o755); err != nil {
|
||||
t.Fatalf("mkdir blocked path: %v", err)
|
||||
}
|
||||
a, tab := appWithTab(t, path)
|
||||
target := filepath.Join(t.TempDir(), "target.jsonl")
|
||||
sess := agent.NewSession("system")
|
||||
sess.Add(provider.Message{Role: provider.RoleUser, Content: "target prompt"})
|
||||
if err := sess.Save(target); err != nil {
|
||||
t.Fatalf("save target: %v", err)
|
||||
}
|
||||
loaded, err := agent.LoadSession(target)
|
||||
if err != nil {
|
||||
t.Fatalf("load target: %v", err)
|
||||
}
|
||||
|
||||
err = a.rebindTabToLoadedSessionPath(tab, target, loaded)
|
||||
if err == nil || !strings.Contains(err.Error(), "save current session before switching sessions") {
|
||||
t.Fatalf("rebind error = %v, want persistence failure", err)
|
||||
}
|
||||
if tab.Ctrl == nil || tab.Ctrl.SessionPath() != path {
|
||||
t.Fatalf("tab controller/path changed after failed save: ctrl=%v path=%q", tab.Ctrl, tab.currentSessionPath())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCloseTabNoResurrectionFromAutosave is the regression test for #4384.
|
||||
// It proves that after CloseTab returns, the per-turn autosave goroutine can no
|
||||
// longer write the session file — even when it is in flight at the moment the
|
||||
@@ -187,6 +501,34 @@ func TestCloseTabNoResurrectionFromAutosave(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseTabBlocksWhenSessionCannotPersist(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "blocked.jsonl")
|
||||
if err := os.Mkdir(path, 0o755); err != nil {
|
||||
t.Fatalf("mkdir blocked path: %v", err)
|
||||
}
|
||||
a, tab := appWithTab(t, path)
|
||||
survivor := &WorkspaceTab{
|
||||
ID: "survivor_tab",
|
||||
Scope: "global",
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
survivor.sink = &tabEventSink{tabID: survivor.ID, app: a}
|
||||
a.tabs[survivor.ID] = survivor
|
||||
a.tabOrder = []string{tab.ID, survivor.ID}
|
||||
|
||||
err := a.CloseTab(tab.ID)
|
||||
if err == nil || !strings.Contains(err.Error(), "save current session before closing tab") {
|
||||
t.Fatalf("CloseTab error = %v, want persistence failure", err)
|
||||
}
|
||||
if _, ok := a.tabs[tab.ID]; !ok {
|
||||
t.Fatal("tab was removed even though its session could not be saved")
|
||||
}
|
||||
if tab.Ctrl == nil || tab.Ctrl.SessionPath() != path {
|
||||
t.Fatalf("tab controller/path changed after failed close: ctrl=%v path=%q", tab.Ctrl, tab.currentSessionPath())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCloseTabSurvivorKeepsAutosave ensures the survivor tab is untouched: the
|
||||
// closing/drain logic is per-tab and must not leak to other tabs.
|
||||
func TestCloseTabSurvivorKeepsAutosave(t *testing.T) {
|
||||
|
||||
@@ -3,6 +3,8 @@ package main
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -189,6 +191,221 @@ func TestEnsureBlankTabCreatesOneBlankPerProject(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBlankTabStartsProjectRuntimeWithCurrentWorkspacePrompt(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectA := robustTempDir(t)
|
||||
projectB := robustTempDir(t)
|
||||
if err := addProject(projectA, "Project A"); err != nil {
|
||||
t.Fatalf("add project A: %v", err)
|
||||
}
|
||||
if err := addProject(projectB, "Project B"); err != nil {
|
||||
t.Fatalf("add project B: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
first, err := app.EnsureBlankTab("project", projectA)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab(project A): %v", err)
|
||||
}
|
||||
tabA := waitForTabReady(t, app, first.ID)
|
||||
if got := normalizeProjectRoot(tabA.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectA) {
|
||||
t.Fatalf("project A controller workspace root = %q, want %q", got, normalizeProjectRoot(projectA))
|
||||
}
|
||||
|
||||
second, err := app.EnsureBlankTab("project", projectB)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab(project B): %v", err)
|
||||
}
|
||||
if second.ID == first.ID {
|
||||
t.Fatalf("EnsureBlankTab reused project A tab %q for project B", second.ID)
|
||||
}
|
||||
tabB := waitForTabReady(t, app, second.ID)
|
||||
|
||||
if got := normalizeProjectRoot(tabB.WorkspaceRoot); got != normalizeProjectRoot(projectB) {
|
||||
t.Fatalf("project B tab workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
|
||||
}
|
||||
if got := normalizeProjectRoot(tabB.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
|
||||
t.Fatalf("project B controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
|
||||
}
|
||||
if !sameDesktopPath(tabB.Ctrl.SessionDir(), desktopSessionDir(projectB)) {
|
||||
t.Fatalf("project B controller session dir = %q, want %q", tabB.Ctrl.SessionDir(), desktopSessionDir(projectB))
|
||||
}
|
||||
if !sameDesktopPath(filepath.Dir(tabB.Ctrl.SessionPath()), desktopSessionDir(projectB)) {
|
||||
t.Fatalf("project B controller session path = %q, want under %q", tabB.Ctrl.SessionPath(), desktopSessionDir(projectB))
|
||||
}
|
||||
sys := systemPromptFrom(tabB.Ctrl.History())
|
||||
if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
|
||||
t.Fatalf("project B system prompt missing current workspace %q:\n%s", projectB, sys)
|
||||
}
|
||||
if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
|
||||
t.Fatalf("project B system prompt retained project A workspace %q:\n%s", projectA, sys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlankTabSessionPathRejectsOtherProjectWorkspace(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectA := robustTempDir(t)
|
||||
projectB := robustTempDir(t)
|
||||
pathA, err := createEmptySessionFile(desktopSessionDir(projectA), "test-model")
|
||||
if err != nil {
|
||||
t.Fatalf("create project A empty session: %v", err)
|
||||
}
|
||||
tab := &WorkspaceTab{
|
||||
ID: "blank-project-b",
|
||||
Scope: "project",
|
||||
WorkspaceRoot: projectB,
|
||||
SessionPath: pathA,
|
||||
}
|
||||
|
||||
if blankTabSessionPathHasNoContent(tab) {
|
||||
t.Fatalf("blank tab treated session %q from project A as reusable for project B %q", pathA, projectB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForkKeepsProjectWorkspacePrompt(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectA := robustTempDir(t)
|
||||
projectB := robustTempDir(t)
|
||||
if err := addProject(projectA, "Project A"); err != nil {
|
||||
t.Fatalf("add project A: %v", err)
|
||||
}
|
||||
if err := addProject(projectB, "Project B"); err != nil {
|
||||
t.Fatalf("add project B: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
first, err := app.EnsureBlankTab("project", projectA)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab(project A): %v", err)
|
||||
}
|
||||
waitForTabReady(t, app, first.ID)
|
||||
|
||||
second, err := app.EnsureBlankTab("project", projectB)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab(project B): %v", err)
|
||||
}
|
||||
tabB := waitForTabReady(t, app, second.ID)
|
||||
ctrl := installStubControllerWithCurrentPrompt(t, app, tabB)
|
||||
turn := submitStubTurnAndWaitForCheckpoint(t, ctrl, "project B turn")
|
||||
|
||||
forked, err := app.Fork(turn)
|
||||
if err != nil {
|
||||
t.Fatalf("Fork: %v", err)
|
||||
}
|
||||
if forked.ID == "" || forked.ID == second.ID {
|
||||
t.Fatalf("forked tab ID = %q, want a fresh tab distinct from %q", forked.ID, second.ID)
|
||||
}
|
||||
forkTab := waitForTabReady(t, app, forked.ID)
|
||||
if got := normalizeProjectRoot(forkTab.WorkspaceRoot); got != normalizeProjectRoot(projectB) {
|
||||
t.Fatalf("fork tab workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
|
||||
}
|
||||
if got := normalizeProjectRoot(forkTab.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
|
||||
t.Fatalf("fork controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
|
||||
}
|
||||
sys := systemPromptFrom(forkTab.Ctrl.History())
|
||||
if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
|
||||
t.Fatalf("fork system prompt missing project B workspace %q:\n%s", projectB, sys)
|
||||
}
|
||||
if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
|
||||
t.Fatalf("fork system prompt retained project A workspace %q:\n%s", projectA, sys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewindKeepsProjectWorkspacePrompt(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectA := robustTempDir(t)
|
||||
projectB := robustTempDir(t)
|
||||
if err := addProject(projectA, "Project A"); err != nil {
|
||||
t.Fatalf("add project A: %v", err)
|
||||
}
|
||||
if err := addProject(projectB, "Project B"); err != nil {
|
||||
t.Fatalf("add project B: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
first, err := app.EnsureBlankTab("project", projectA)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab(project A): %v", err)
|
||||
}
|
||||
waitForTabReady(t, app, first.ID)
|
||||
|
||||
second, err := app.EnsureBlankTab("project", projectB)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab(project B): %v", err)
|
||||
}
|
||||
tabB := waitForTabReady(t, app, second.ID)
|
||||
ctrl := installStubControllerWithCurrentPrompt(t, app, tabB)
|
||||
turn := submitStubTurnAndWaitForCheckpoint(t, ctrl, "project B turn")
|
||||
|
||||
if err := app.Rewind(turn, "conversation"); err != nil {
|
||||
t.Fatalf("Rewind: %v", err)
|
||||
}
|
||||
if got := normalizeProjectRoot(tabB.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
|
||||
t.Fatalf("rewound controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
|
||||
}
|
||||
sys := systemPromptFrom(tabB.Ctrl.History())
|
||||
if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
|
||||
t.Fatalf("rewound system prompt missing project B workspace %q:\n%s", projectB, sys)
|
||||
}
|
||||
if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
|
||||
t.Fatalf("rewound system prompt retained project A workspace %q:\n%s", projectA, sys)
|
||||
}
|
||||
}
|
||||
|
||||
func installStubControllerWithCurrentPrompt(t *testing.T, app *App, tab *WorkspaceTab) *control.Controller {
|
||||
t.Helper()
|
||||
if tab == nil || tab.Ctrl == nil {
|
||||
t.Fatal("tab controller is required")
|
||||
}
|
||||
sys := systemPromptFrom(tab.Ctrl.History())
|
||||
if strings.TrimSpace(sys) == "" {
|
||||
t.Fatal("tab controller did not expose a system prompt")
|
||||
}
|
||||
sessionDir := tab.Ctrl.SessionDir()
|
||||
sessionPath := tab.Ctrl.SessionPath()
|
||||
workspaceRoot := tab.Ctrl.WorkspaceRoot()
|
||||
label := tab.Ctrl.Label()
|
||||
tab.Ctrl.Close()
|
||||
|
||||
sess := agent.NewSession(sys)
|
||||
ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
|
||||
ctrl := control.New(control.Options{
|
||||
Runner: ag,
|
||||
Executor: ag,
|
||||
SessionDir: sessionDir,
|
||||
SessionPath: sessionPath,
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Label: label,
|
||||
SystemPrompt: sys,
|
||||
Sink: event.Discard,
|
||||
})
|
||||
tab.Ctrl = ctrl
|
||||
app.bindControllerDisplayRecorder(ctrl)
|
||||
return ctrl
|
||||
}
|
||||
|
||||
func submitStubTurnAndWaitForCheckpoint(t *testing.T, ctrl control.SessionAPI, input string) int {
|
||||
t.Helper()
|
||||
ctrl.SubmitUserTurn(input, input)
|
||||
waitNotRunning(t, ctrl)
|
||||
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for {
|
||||
checkpoints := ctrl.Checkpoints()
|
||||
if len(checkpoints) > 0 {
|
||||
return checkpoints[len(checkpoints)-1].Turn
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("controller did not record a checkpoint")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBlankTabResetsReusableAutoTopicTitle(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
|
||||
+614
-5
@@ -27,7 +27,9 @@ import (
|
||||
"reasonix/internal/jobs"
|
||||
"reasonix/internal/memory"
|
||||
"reasonix/internal/plugin"
|
||||
"reasonix/internal/pluginpkg"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/sandbox"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
@@ -955,21 +957,36 @@ api_key_env = "DEEPSEEK_API_KEY"
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
if got := app.Settings().Agent.MaxSubagentDepth; got != agent.DefaultMaxSubagentDepth {
|
||||
t.Fatalf("default max subagent depth = %d, want %d", got, agent.DefaultMaxSubagentDepth)
|
||||
}
|
||||
if err := app.SetSubagentModel("deepseek/deepseek-v4-pro"); err != nil {
|
||||
t.Fatalf("SetSubagentModel: %v", err)
|
||||
}
|
||||
if err := app.SetSubagentEffort("max"); err != nil {
|
||||
t.Fatalf("SetSubagentEffort: %v", err)
|
||||
}
|
||||
if err := app.SetMaxSubagentDepth(1); err != nil {
|
||||
t.Fatalf("SetMaxSubagentDepth(1): %v", err)
|
||||
}
|
||||
if err := app.SetMaxSubagentDepth(2); err != nil {
|
||||
t.Fatalf("SetMaxSubagentDepth(2): %v", err)
|
||||
}
|
||||
|
||||
got := app.Settings()
|
||||
if got.SubagentModel != "deepseek/deepseek-v4-pro" || got.SubagentEffort != "max" {
|
||||
t.Fatalf("subagent settings = model:%q effort:%q", got.SubagentModel, got.SubagentEffort)
|
||||
}
|
||||
if got.Agent.MaxSubagentDepth != 2 {
|
||||
t.Fatalf("max subagent depth = %d, want 2", got.Agent.MaxSubagentDepth)
|
||||
}
|
||||
cfg := config.LoadForEdit(config.UserConfigPath())
|
||||
if cfg.Agent.SubagentModel != "deepseek/deepseek-v4-pro" || cfg.Agent.SubagentEffort != "max" {
|
||||
t.Fatalf("saved config = model:%q effort:%q", cfg.Agent.SubagentModel, cfg.Agent.SubagentEffort)
|
||||
}
|
||||
if cfg.Agent.MaxSubagentDepth != 2 {
|
||||
t.Fatalf("saved max_subagent_depth = %d, want 2", cfg.Agent.MaxSubagentDepth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsSurfacesOfficialProviderTemplatesSeparately(t *testing.T) {
|
||||
@@ -1214,6 +1231,385 @@ api_key_env = "DEEPSEEK_API_KEY"
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsSurfacesCuratedProviderPresets(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
view := NewApp().Settings()
|
||||
if len(view.ProviderPresets) < 18 {
|
||||
t.Fatalf("Settings().ProviderPresets length = %d, want curated custom presets", len(view.ProviderPresets))
|
||||
}
|
||||
got := map[string]ProviderPresetView{}
|
||||
for _, preset := range view.ProviderPresets {
|
||||
got[preset.ID] = preset
|
||||
}
|
||||
for _, curated := range config.CuratedProviderPresets() {
|
||||
id := curated.ID
|
||||
preset, ok := got[id]
|
||||
if !ok {
|
||||
t.Fatalf("Settings().ProviderPresets missing %q: %+v", id, view.ProviderPresets)
|
||||
}
|
||||
if preset.KeyEnv == "" || len(preset.ProviderNames) == 0 || len(preset.Models) == 0 {
|
||||
t.Fatalf("preset %q view has missing fields: %+v", id, preset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func providerPresetViewByID(t *testing.T, view SettingsView, id string) ProviderPresetView {
|
||||
t.Helper()
|
||||
for _, preset := range view.ProviderPresets {
|
||||
if preset.ID == id {
|
||||
return preset
|
||||
}
|
||||
}
|
||||
t.Fatalf("Settings().ProviderPresets missing %q: %+v", id, view.ProviderPresets)
|
||||
return ProviderPresetView{}
|
||||
}
|
||||
|
||||
func TestSettingsMarksPresetAddedWhenSameNameProviderExistsWithoutAccess(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
|
||||
t.Fatalf("mkdir config dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(config.UserConfigPath(), []byte(`
|
||||
[desktop]
|
||||
provider_access = []
|
||||
|
||||
[[providers]]
|
||||
name = "mimo-api"
|
||||
kind = "openai"
|
||||
base_url = "https://custom.example/v1"
|
||||
models = ["custom-model"]
|
||||
default = "custom-model"
|
||||
api_key_env = "MIMO_API_KEY"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
view := NewApp().Settings()
|
||||
presetView := providerPresetViewByID(t, view, "mimo-api")
|
||||
if !presetView.Added || presetView.Status != providerPresetStatusNameConflict || !reflect.DeepEqual(presetView.StatusProviderNames, []string{"mimo-api"}) {
|
||||
t.Fatalf("mimo-api preset view = %+v, want name-conflict because a different same-name provider exists", presetView)
|
||||
}
|
||||
|
||||
var providerView *ProviderView
|
||||
for i := range view.Providers {
|
||||
if view.Providers[i].Name == "mimo-api" {
|
||||
providerView = &view.Providers[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if providerView == nil {
|
||||
t.Fatal("mimo-api provider view missing")
|
||||
}
|
||||
if providerView.Added {
|
||||
t.Fatalf("mimo-api provider Added = true, want false until provider_access explicitly enables it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsMarksLegacyEquivalentPresetAsInstalled(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
preset, ok := config.CuratedProviderPreset("mimo-api")
|
||||
if !ok || len(preset.Entries) == 0 {
|
||||
t.Fatal("missing mimo-api preset")
|
||||
}
|
||||
legacy := preset.Entries[0]
|
||||
legacy.PresetID = ""
|
||||
legacy.PresetVersion = 0
|
||||
cfg := config.Default()
|
||||
if err := cfg.UpsertProvider(legacy); err != nil {
|
||||
t.Fatalf("upsert legacy provider: %v", err)
|
||||
}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
view := NewApp().Settings()
|
||||
presetView := providerPresetViewByID(t, view, "mimo-api")
|
||||
if !presetView.Added || presetView.Status != providerPresetStatusInstalled || !reflect.DeepEqual(presetView.StatusProviderNames, []string{"mimo-api"}) {
|
||||
t.Fatalf("mimo-api preset view = %+v, want installed for legacy equivalent config", presetView)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsMarksPresetWithChangedCoreConfigAsModified(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
preset, ok := config.CuratedProviderPreset("mimo-api")
|
||||
if !ok || len(preset.Entries) == 0 {
|
||||
t.Fatal("missing mimo-api preset")
|
||||
}
|
||||
modified := preset.Entries[0]
|
||||
modified.BaseURL = "https://custom.example/v1"
|
||||
cfg := config.Default()
|
||||
if err := cfg.UpsertProvider(modified); err != nil {
|
||||
t.Fatalf("upsert modified provider: %v", err)
|
||||
}
|
||||
cfg.Desktop.ProviderAccess = []string{"mimo-api"}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
view := NewApp().Settings()
|
||||
presetView := providerPresetViewByID(t, view, "mimo-api")
|
||||
if !presetView.Added || presetView.Status != providerPresetStatusInstalledModified || !reflect.DeepEqual(presetView.StatusProviderNames, []string{"mimo-api"}) {
|
||||
t.Fatalf("mimo-api preset view = %+v, want installed-modified for edited preset provider", presetView)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsMarksSimilarProviderPresetWithoutBlockingAdd(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
preset, ok := config.CuratedProviderPreset("mimo-api")
|
||||
if !ok || len(preset.Entries) == 0 {
|
||||
t.Fatal("missing mimo-api preset")
|
||||
}
|
||||
similar := preset.Entries[0]
|
||||
similar.Name = "my-mimo"
|
||||
similar.PresetID = ""
|
||||
similar.PresetVersion = 0
|
||||
cfg := config.Default()
|
||||
if err := cfg.UpsertProvider(similar); err != nil {
|
||||
t.Fatalf("upsert similar provider: %v", err)
|
||||
}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
view := NewApp().Settings()
|
||||
presetView := providerPresetViewByID(t, view, "mimo-api")
|
||||
if presetView.Added || presetView.Status != providerPresetStatusSimilarExisting || !reflect.DeepEqual(presetView.StatusProviderNames, []string{"my-mimo"}) {
|
||||
t.Fatalf("mimo-api preset view = %+v, want non-blocking similar-existing status", presetView)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddProviderPresetAccessSavesEditableProviderAndKey(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
t.Setenv("MIMO_API_KEY", "")
|
||||
os.Unsetenv("MIMO_API_KEY")
|
||||
|
||||
if warning, err := NewApp().AddProviderPresetAccess("mimo-api", "sk-mimo"); err != nil {
|
||||
t.Fatalf("AddProviderPresetAccess: %v", err)
|
||||
} else if warning != "" {
|
||||
t.Fatalf("AddProviderPresetAccess warning = %q, want none", warning)
|
||||
}
|
||||
|
||||
cfg := config.LoadForEdit(config.UserConfigPath())
|
||||
p, ok := cfg.Provider("mimo-api")
|
||||
if !ok {
|
||||
t.Fatal("mimo-api provider not saved")
|
||||
}
|
||||
if p.Kind != "openai" || p.BaseURL != "https://api.xiaomimimo.com/v1" || p.Default != "mimo-v2.5-pro" {
|
||||
t.Fatalf("mimo-api provider after preset add = %+v", p)
|
||||
}
|
||||
if p.PresetID != "mimo-api" || p.PresetVersion != config.ProviderPresetVersion {
|
||||
t.Fatalf("mimo-api preset metadata = %q/%d, want mimo-api/%d", p.PresetID, p.PresetVersion, config.ProviderPresetVersion)
|
||||
}
|
||||
if !p.NoProxy {
|
||||
t.Fatal("mimo-api preset should save no_proxy = true")
|
||||
}
|
||||
if !p.HasVisionModel("mimo-v2.5") || p.HasVisionModel("mimo-v2.5-pro") {
|
||||
t.Fatalf("mimo vision_models = %+v, want only vision-capable MiMo models", p.VisionModels)
|
||||
}
|
||||
if price := p.PriceForModel("mimo-v2.5-pro"); price == nil || price.Currency != "¥" {
|
||||
t.Fatalf("mimo-v2.5-pro price = %+v, want RMB pricing", price)
|
||||
}
|
||||
if !providerAccessSet(cfg.Desktop.ProviderAccess)["mimo-api"] {
|
||||
t.Fatalf("provider_access missing mimo-api: %+v", cfg.Desktop.ProviderAccess)
|
||||
}
|
||||
data, err := os.ReadFile(config.UserCredentialsPath())
|
||||
if err != nil {
|
||||
t.Fatalf("read saved credentials: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "MIMO_API_KEY=sk-mimo") {
|
||||
t.Fatalf("saved credentials missing MiMo key:\n%s", data)
|
||||
}
|
||||
|
||||
view := NewApp().Settings()
|
||||
var presetView *ProviderPresetView
|
||||
var providerView *ProviderView
|
||||
for i := range view.ProviderPresets {
|
||||
if view.ProviderPresets[i].ID == "mimo-api" {
|
||||
presetView = &view.ProviderPresets[i]
|
||||
}
|
||||
}
|
||||
for i := range view.Providers {
|
||||
if view.Providers[i].Name == "mimo-api" {
|
||||
providerView = &view.Providers[i]
|
||||
}
|
||||
}
|
||||
if presetView == nil || !presetView.Added || presetView.Status != providerPresetStatusInstalled || !presetView.KeySet {
|
||||
t.Fatalf("mimo-api preset view = %+v, want installed/key-set", presetView)
|
||||
}
|
||||
if providerView == nil || providerView.BuiltIn || !providerView.Added || !providerView.KeySet {
|
||||
t.Fatalf("mimo provider view = %+v, want editable added custom provider with key", providerView)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddProviderPresetAccessDoesNotOverwriteExistingProvider(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
t.Setenv("MIMO_API_KEY", "")
|
||||
os.Unsetenv("MIMO_API_KEY")
|
||||
setDesktopTestCredential(t, "MIMO_API_KEY", "sk-original")
|
||||
|
||||
cfg := config.Default()
|
||||
custom := config.ProviderEntry{
|
||||
Name: "mimo-api",
|
||||
Kind: "openai",
|
||||
BaseURL: "https://custom.example/v1",
|
||||
Models: []string{"custom-model"},
|
||||
Default: "custom-model",
|
||||
APIKeyEnv: "MIMO_API_KEY",
|
||||
Headers: map[string]string{"X-Custom": "keep-me"},
|
||||
}
|
||||
if err := cfg.UpsertProvider(custom); err != nil {
|
||||
t.Fatalf("upsert custom provider: %v", err)
|
||||
}
|
||||
cfg.Desktop.ProviderAccess = []string{"mimo-api"}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
if warning, err := NewApp().AddProviderPresetAccess("mimo-api", "sk-new"); err == nil {
|
||||
t.Fatal("AddProviderPresetAccess unexpectedly overwrote an existing provider")
|
||||
} else if !strings.Contains(err.Error(), "provider name(s) already exist") {
|
||||
t.Fatalf("AddProviderPresetAccess error = %v, want name-exists guard", err)
|
||||
} else if warning != "" {
|
||||
t.Fatalf("AddProviderPresetAccess warning = %q, want none on rejected add", warning)
|
||||
}
|
||||
|
||||
cfg = config.LoadForEdit(config.UserConfigPath())
|
||||
got, ok := cfg.Provider("mimo-api")
|
||||
if !ok {
|
||||
t.Fatal("mimo-api provider missing after rejected add")
|
||||
}
|
||||
if got.BaseURL != custom.BaseURL || got.DefaultModel() != custom.DefaultModel() || !reflect.DeepEqual(got.ModelList(), custom.ModelList()) || !reflect.DeepEqual(got.Headers, custom.Headers) {
|
||||
t.Fatalf("mimo-api provider was overwritten: %+v, want custom %+v", got, custom)
|
||||
}
|
||||
data, err := os.ReadFile(config.UserCredentialsPath())
|
||||
if err != nil {
|
||||
t.Fatalf("read saved credentials: %v", err)
|
||||
}
|
||||
if strings.Contains(string(data), "sk-new") || !strings.Contains(string(data), "MIMO_API_KEY=sk-original") {
|
||||
t.Fatalf("credentials changed after rejected add:\n%s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetProviderPresetAccessOverwritesSameNameProvider(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
t.Setenv("MIMO_API_KEY", "")
|
||||
os.Unsetenv("MIMO_API_KEY")
|
||||
setDesktopTestCredential(t, "MIMO_API_KEY", "sk-original")
|
||||
|
||||
cfg := config.Default()
|
||||
custom := config.ProviderEntry{
|
||||
Name: "mimo-api",
|
||||
Kind: "openai",
|
||||
BaseURL: "https://custom.example/v1",
|
||||
Models: []string{"custom-model"},
|
||||
Default: "custom-model",
|
||||
APIKeyEnv: "MIMO_API_KEY",
|
||||
Headers: map[string]string{"X-Custom": "remove-me"},
|
||||
}
|
||||
if err := cfg.UpsertProvider(custom); err != nil {
|
||||
t.Fatalf("upsert custom provider: %v", err)
|
||||
}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
if err := NewApp().ResetProviderPresetAccess("mimo-api"); err != nil {
|
||||
t.Fatalf("ResetProviderPresetAccess: %v", err)
|
||||
}
|
||||
|
||||
cfg = config.LoadForEdit(config.UserConfigPath())
|
||||
got, ok := cfg.Provider("mimo-api")
|
||||
if !ok {
|
||||
t.Fatal("mimo-api provider missing after reset")
|
||||
}
|
||||
if got.BaseURL != "https://api.xiaomimimo.com/v1" || got.DefaultModel() != "mimo-v2.5-pro" || got.PresetID != "mimo-api" || got.PresetVersion != config.ProviderPresetVersion {
|
||||
t.Fatalf("mimo-api provider after reset = %+v, want preset template", got)
|
||||
}
|
||||
if len(got.Headers) != 0 {
|
||||
t.Fatalf("mimo-api headers after reset = %+v, want preset headers", got.Headers)
|
||||
}
|
||||
if !providerAccessSet(cfg.Desktop.ProviderAccess)["mimo-api"] {
|
||||
t.Fatalf("provider_access missing mimo-api after reset: %+v", cfg.Desktop.ProviderAccess)
|
||||
}
|
||||
data, err := os.ReadFile(config.UserCredentialsPath())
|
||||
if err != nil {
|
||||
t.Fatalf("read saved credentials: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "MIMO_API_KEY=sk-original") {
|
||||
t.Fatalf("credentials changed after reset:\n%s", data)
|
||||
}
|
||||
|
||||
presetView := providerPresetViewByID(t, NewApp().Settings(), "mimo-api")
|
||||
if !presetView.Added || presetView.Status != providerPresetStatusInstalled {
|
||||
t.Fatalf("mimo-api preset view = %+v, want installed after reset", presetView)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetProviderPresetAccessRejectsMissingSameNameProvider(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
if err := NewApp().ResetProviderPresetAccess("mimo-api"); err == nil {
|
||||
t.Fatal("ResetProviderPresetAccess unexpectedly reset a missing provider")
|
||||
} else if !strings.Contains(err.Error(), "no same-name provider exists") {
|
||||
t.Fatalf("ResetProviderPresetAccess error = %v, want missing same-name provider guard", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddEveryProviderPresetAccessInstallsTemplate(t *testing.T) {
|
||||
for _, preset := range config.CuratedProviderPresets() {
|
||||
preset := preset
|
||||
t.Run(preset.ID, func(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
if warning, err := NewApp().AddProviderPresetAccess(preset.ID, "sk-test"); err != nil {
|
||||
t.Fatalf("AddProviderPresetAccess(%q): %v", preset.ID, err)
|
||||
} else if warning != "" {
|
||||
t.Fatalf("AddProviderPresetAccess(%q) warning = %q, want none", preset.ID, warning)
|
||||
}
|
||||
|
||||
cfg := config.LoadForEdit(config.UserConfigPath())
|
||||
access := providerAccessSet(cfg.Desktop.ProviderAccess)
|
||||
for _, entry := range preset.Entries {
|
||||
got, ok := cfg.Provider(entry.Name)
|
||||
if !ok {
|
||||
t.Fatalf("provider %q from preset %q was not saved", entry.Name, preset.ID)
|
||||
}
|
||||
if !access[entry.Name] {
|
||||
t.Fatalf("provider_access for preset %q missing %q: %+v", preset.ID, entry.Name, cfg.Desktop.ProviderAccess)
|
||||
}
|
||||
if got.Kind != entry.Kind || got.BaseURL != entry.BaseURL || got.DefaultModel() != entry.DefaultModel() || got.APIKeyEnv != entry.APIKeyEnv || got.AuthHeader != entry.AuthHeader || got.NoProxy != entry.NoProxy {
|
||||
t.Fatalf("provider %q core fields = %+v, want template %+v", entry.Name, got, entry)
|
||||
}
|
||||
if got.PresetID != preset.ID || got.PresetVersion != config.ProviderPresetVersion {
|
||||
t.Fatalf("provider %q preset metadata = %q/%d, want %q/%d", entry.Name, got.PresetID, got.PresetVersion, preset.ID, config.ProviderPresetVersion)
|
||||
}
|
||||
if got.ContextWindow != entry.ContextWindow || got.Thinking != entry.Thinking || got.DefaultEffort != entry.DefaultEffort || got.ReasoningProtocol != entry.ReasoningProtocol {
|
||||
t.Fatalf("provider %q capability fields = %+v, want template %+v", entry.Name, got, entry)
|
||||
}
|
||||
if !reflect.DeepEqual(got.ModelList(), entry.ModelList()) || !reflect.DeepEqual(got.VisionModels, entry.VisionModels) || !reflect.DeepEqual(got.SupportedEfforts, entry.SupportedEfforts) {
|
||||
t.Fatalf("provider %q models/capabilities = %+v, want template %+v", entry.Name, got, entry)
|
||||
}
|
||||
if !reflect.DeepEqual(got.Headers, entry.Headers) || !reflect.DeepEqual(got.ExtraBody, entry.ExtraBody) {
|
||||
t.Fatalf("provider %q request extras = %+v, want template %+v", entry.Name, got, entry)
|
||||
}
|
||||
}
|
||||
|
||||
view := NewApp().Settings()
|
||||
var presetView *ProviderPresetView
|
||||
for i := range view.ProviderPresets {
|
||||
if view.ProviderPresets[i].ID == preset.ID {
|
||||
presetView = &view.ProviderPresets[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if presetView == nil || !presetView.Added || presetView.Status != providerPresetStatusInstalled || !presetView.KeySet || !presetView.Configured {
|
||||
t.Fatalf("preset view for %q = %+v, want installed/key-set/configured", preset.ID, presetView)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddOfficialProviderAccessRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
t.Setenv("DEEPSEEK_API_KEY", "")
|
||||
@@ -1523,6 +1919,13 @@ func TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {
|
||||
setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test")
|
||||
|
||||
cfg := config.Default()
|
||||
preset, ok := config.CuratedProviderPreset("mimo-api")
|
||||
if !ok || len(preset.Entries) == 0 {
|
||||
t.Fatal("mimo-api preset missing")
|
||||
}
|
||||
if err := cfg.UpsertProvider(preset.Entries[0]); err != nil {
|
||||
t.Fatalf("upsert mimo-api preset: %v", err)
|
||||
}
|
||||
cfg.DefaultModel = "mimo-api/mimo-v2.5-pro"
|
||||
cfg.Desktop.ProviderAccess = []string{"mimo-api"}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
@@ -1534,14 +1937,13 @@ func TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"mimo-api/mimo-v2.5-pro",
|
||||
"mimo-api/mimo-v2.5",
|
||||
"mimo-api/mimo-v2-omni",
|
||||
} {
|
||||
if !refs[want] {
|
||||
t.Fatalf("Models() refs = %+v, missing %s", models, want)
|
||||
}
|
||||
}
|
||||
if len(models) != 3 {
|
||||
t.Fatalf("Models() len = %d, want 3: %+v", len(models), models)
|
||||
if len(models) != 2 {
|
||||
t.Fatalf("Models() len = %d, want 2: %+v", len(models), models)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1871,6 +2273,34 @@ func TestEnsureTabControllerWorkspaceRebuildsStaleWorkspace(t *testing.T) {
|
||||
assertTabRebuiltToPinnedWorkspace(t, f)
|
||||
}
|
||||
|
||||
func TestEnsureTabControllerWorkspaceWarnsWhenPinnedSessionSwitchesWorkspace(t *testing.T) {
|
||||
f := newStaleWorkspaceBindingFixture(t, "warn_workspace_switch")
|
||||
events := make(chan event.Event, 8)
|
||||
f.tab.sink.SetBotSink(event.FuncSink(func(e event.Event) {
|
||||
events <- e
|
||||
}))
|
||||
|
||||
if err := f.app.ensureTabControllerWorkspace(f.tab); err != nil {
|
||||
t.Fatalf("ensureTabControllerWorkspace: %v", err)
|
||||
}
|
||||
assertTabRebuiltToPinnedWorkspace(t, f)
|
||||
|
||||
deadline := time.After(2 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case e := <-events:
|
||||
if e.Kind == event.Notice &&
|
||||
e.Level == event.LevelWarn &&
|
||||
strings.Contains(e.Text, f.projectA) &&
|
||||
strings.Contains(e.Text, "switched tab") {
|
||||
return
|
||||
}
|
||||
case <-deadline:
|
||||
t.Fatal("did not receive workspace switch warning notice")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSteerForTabReconcilesStaleWorkspaceBeforeIdleFallback(t *testing.T) {
|
||||
f := newStaleWorkspaceBindingFixture(t, "steer_idle_fallback")
|
||||
|
||||
@@ -3201,8 +3631,8 @@ func TestDeleteLastTopicSessionFallbackDoesNotReuseDeletedTopic(t *testing.T) {
|
||||
if tab.TopicID == topicID {
|
||||
t.Fatalf("fallback tab %q reused deleted topic %q", id, topicID)
|
||||
}
|
||||
if strings.TrimSpace(tab.TopicID) == "" {
|
||||
t.Fatalf("fallback tab %q has empty topic ID", id)
|
||||
if strings.TrimSpace(tab.TopicID) != "" {
|
||||
t.Fatalf("fallback tab %q topic ID = %q, want transient unindexed blank", id, tab.TopicID)
|
||||
}
|
||||
}
|
||||
trashPath := filepath.Join(dir, sessionTrashDir, "delete-last.jsonl", "delete-last.jsonl")
|
||||
@@ -3676,6 +4106,17 @@ func TestDesktopSessionAPIsUseControllerSessionDir(t *testing.T) {
|
||||
if err := app.RenameSession(pathA, "A title"); err != nil {
|
||||
t.Fatalf("RenameSession in active session dir: %v", err)
|
||||
}
|
||||
meta, ok, err := agent.LoadBranchMeta(pathA)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta after RenameSession ok=%v err=%v", ok, err)
|
||||
}
|
||||
if meta.CustomTitle != "A title" {
|
||||
t.Fatalf("custom title should be written to branch meta, got %q", meta.CustomTitle)
|
||||
}
|
||||
sessions = app.ListSessions()
|
||||
if len(sessions) != 1 || sessions[0].Title != "A title" {
|
||||
t.Fatalf("ListSessions should return custom title from branch meta, got %+v", sessions)
|
||||
}
|
||||
if titles := loadSessionTitles(dirA); titles["a.jsonl"] != "A title" {
|
||||
t.Fatalf("title should be written beside the active session, got %+v", titles)
|
||||
}
|
||||
@@ -4209,6 +4650,51 @@ args = ["-y", "@playwright/mcp"]
|
||||
t.Fatalf("playwright MCP missing from Capabilities: %+v", view.Servers)
|
||||
}
|
||||
|
||||
func TestCapabilitiesIncludesInstalledPlugins(t *testing.T) {
|
||||
home := isolateDesktopUserDirs(t)
|
||||
reasonixHome := filepath.Join(home, ".reasonix")
|
||||
root := filepath.Join(reasonixHome, "plugins", "superpowers")
|
||||
if err := os.MkdirAll(filepath.Join(root, "skills"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, "skills", "plan"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "skills", "plan", "SKILL.md"), []byte("---\ndescription: Plan work\n---\nbody"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, ".codex-plugin"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, ".codex-plugin", "plugin.json"), []byte(`{
|
||||
"name": "superpowers",
|
||||
"version": "6.1.0",
|
||||
"description": "Planning workflows",
|
||||
"skills": "./skills/"
|
||||
}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pluginpkg.Upsert(reasonixHome, pluginpkg.InstalledPlugin{
|
||||
Name: "superpowers",
|
||||
Root: "plugins/superpowers",
|
||||
Version: "6.1.0",
|
||||
Description: "Planning workflows",
|
||||
ManifestKind: "codex",
|
||||
Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
plugins := app.Capabilities().Plugins
|
||||
if len(plugins) != 1 || plugins[0].Name != "superpowers" || plugins[0].Skills != 1 {
|
||||
t.Fatalf("Capabilities().Plugins = %+v", plugins)
|
||||
}
|
||||
if len(plugins[0].SkillDetails) != 1 || plugins[0].SkillDetails[0].Invocation != "/plan" {
|
||||
t.Fatalf("Capabilities().Plugins skill details = %+v", plugins[0].SkillDetails)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopSharedHostBackgroundMCPAutoConnectsOnBoot(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := robustTempDir(t)
|
||||
@@ -5304,6 +5790,129 @@ func TestRunShellForTabRoutesToRequestedTab(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunShellForTabStaysBoundDuringRapidProjectTabSwitching(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectA := t.TempDir()
|
||||
projectB := t.TempDir()
|
||||
globalRoot := t.TempDir()
|
||||
shellEvents := make(chan event.Event, 64)
|
||||
projectEvents := make(chan event.Event, 64)
|
||||
globalEvents := make(chan event.Event, 64)
|
||||
shellCtrl := control.New(control.Options{
|
||||
Sink: event.FuncSink(func(e event.Event) { shellEvents <- e }),
|
||||
WorkspaceRoot: projectA,
|
||||
})
|
||||
projectCtrl := control.New(control.Options{
|
||||
Sink: event.FuncSink(func(e event.Event) { projectEvents <- e }),
|
||||
WorkspaceRoot: projectB,
|
||||
})
|
||||
globalCtrl := control.New(control.Options{
|
||||
Sink: event.FuncSink(func(e event.Event) { globalEvents <- e }),
|
||||
WorkspaceRoot: globalRoot,
|
||||
})
|
||||
defer shellCtrl.Close()
|
||||
defer projectCtrl.Close()
|
||||
defer globalCtrl.Close()
|
||||
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{
|
||||
"shell": {ID: "shell", Scope: "project", WorkspaceRoot: projectA, Ctrl: shellCtrl, Ready: true},
|
||||
"project-b": {ID: "project-b", Scope: "project", WorkspaceRoot: projectB, Ctrl: projectCtrl, Ready: true},
|
||||
"global": {ID: "global", Scope: "global", WorkspaceRoot: globalRoot, Ctrl: globalCtrl, Ready: true},
|
||||
},
|
||||
tabOrder: []string{"shell", "project-b", "global"},
|
||||
activeTabID: "shell",
|
||||
}
|
||||
|
||||
marker := "shell-route-marker.txt"
|
||||
if err := app.RunShellForTab("shell", longRunningMarkerCommand(marker)); err != nil {
|
||||
t.Fatalf("RunShellForTab: %v", err)
|
||||
}
|
||||
waitForShellDispatch(t, shellEvents, marker)
|
||||
waitForFile(t, filepath.Join(projectA, marker), "shell")
|
||||
|
||||
for i := 0; i < 8; i++ {
|
||||
if err := app.SetActiveTab("project-b"); err != nil {
|
||||
t.Fatalf("SetActiveTab(project-b): %v", err)
|
||||
}
|
||||
if err := app.SetActiveTab("global"); err != nil {
|
||||
t.Fatalf("SetActiveTab(global): %v", err)
|
||||
}
|
||||
if err := app.SetActiveTab("shell"); err != nil {
|
||||
t.Fatalf("SetActiveTab(shell): %v", err)
|
||||
}
|
||||
}
|
||||
if err := app.SetActiveTab("project-b"); err != nil {
|
||||
t.Fatalf("SetActiveTab(project-b final): %v", err)
|
||||
}
|
||||
app.CancelTab("shell")
|
||||
|
||||
cancelled := false
|
||||
deadline := time.After(15 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case e := <-shellEvents:
|
||||
if e.Kind == event.ToolResult && e.Tool.Name == "bash" {
|
||||
cancelled = e.Tool.Err != ""
|
||||
}
|
||||
if e.Kind == event.TurnDone {
|
||||
if !cancelled {
|
||||
t.Fatal("shell tab finished without a cancelled shell result")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(projectB, marker)); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("shell marker appeared in project-b workspace: %v", err)
|
||||
}
|
||||
if got := activeTabIDForTest(app); got != "project-b" {
|
||||
t.Fatalf("active tab = %q, want project-b after background shell cancel", got)
|
||||
}
|
||||
assertNoEvents(t, projectEvents, "project-b")
|
||||
assertNoEvents(t, globalEvents, "global")
|
||||
return
|
||||
}
|
||||
case <-deadline:
|
||||
t.Fatal("timed out waiting for shell tab cancellation")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func longRunningMarkerCommand(marker string) string {
|
||||
if sandbox.ResolveShell("", "", nil).Kind == sandbox.ShellPowerShell {
|
||||
return fmt.Sprintf("Set-Content -LiteralPath %s -Value shell; Start-Sleep -Seconds 30", marker)
|
||||
}
|
||||
return fmt.Sprintf("printf shell > %s; sleep 30", marker)
|
||||
}
|
||||
|
||||
func waitForShellDispatch(t *testing.T, ch <-chan event.Event, marker string) {
|
||||
t.Helper()
|
||||
deadline := time.After(5 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case e := <-ch:
|
||||
if e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, marker) {
|
||||
return
|
||||
}
|
||||
case <-deadline:
|
||||
t.Fatal("timed out waiting for shell dispatch")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func activeTabIDForTest(app *App) string {
|
||||
app.mu.RLock()
|
||||
defer app.mu.RUnlock()
|
||||
return app.activeTabID
|
||||
}
|
||||
|
||||
func assertNoEvents(t *testing.T, ch <-chan event.Event, name string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case e := <-ch:
|
||||
t.Fatalf("%s received event while shell ran in another tab: %+v", name, e)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
type blockingRunner struct {
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"reasonix/internal/bot"
|
||||
"reasonix/internal/bot/feishu"
|
||||
"reasonix/internal/bot/weixin"
|
||||
"reasonix/internal/botruntime"
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
@@ -48,6 +49,7 @@ type BotConnectionView struct {
|
||||
Model string `json:"model"`
|
||||
ToolApprovalMode string `json:"toolApprovalMode"`
|
||||
WorkspaceRoot string `json:"workspaceRoot"`
|
||||
Access BotAccessView `json:"access"`
|
||||
Credential BotConnectionCredentialView `json:"credential"`
|
||||
SessionMappings []BotConnectionSessionMappingView `json:"sessionMappings"`
|
||||
LastError string `json:"lastError"`
|
||||
@@ -164,6 +166,7 @@ func (a *App) PollBotConnectionInstall(installID string) (BotInstallPollResult,
|
||||
Label: "微信",
|
||||
Enabled: true,
|
||||
Status: "connected",
|
||||
Access: botInstallAccess(result.UserID),
|
||||
Credential: config.BotConnectionCredential{AccountID: result.AccountID, TokenEnv: "WEIXIN_BOT_TOKEN"},
|
||||
}, func(c *config.Config) {
|
||||
c.Bot.Enabled = true
|
||||
@@ -518,6 +521,7 @@ func (a *App) pollFeishuConnectionInstall(installID string, session *botInstallS
|
||||
Label: label,
|
||||
Enabled: true,
|
||||
Status: "connected",
|
||||
Access: botInstallAccess(userID),
|
||||
Credential: config.BotConnectionCredential{AppID: appID, AppSecretEnv: secretEnv},
|
||||
}, func(c *config.Config) {
|
||||
c.Bot.Enabled = true
|
||||
@@ -545,6 +549,9 @@ func (a *App) upsertBotConnection(conn config.BotConnectionConfig, updateLegacy
|
||||
if conn.Status == "" {
|
||||
conn.Status = "connected"
|
||||
}
|
||||
if normalizeBotConnectionToolApprovalMode(conn.ToolApprovalMode) == "" {
|
||||
conn.ToolApprovalMode = "ask"
|
||||
}
|
||||
if conn.ID == "" {
|
||||
conn.ID = connectionID(conn.Provider, conn.Domain)
|
||||
}
|
||||
@@ -556,6 +563,9 @@ func (a *App) upsertBotConnection(conn config.BotConnectionConfig, updateLegacy
|
||||
for i, existing := range c.Bot.Connections {
|
||||
if existing.ID == conn.ID {
|
||||
conn.CreatedAt = firstNonEmptyBot(existing.CreatedAt, conn.CreatedAt)
|
||||
if !botruntime.BotAccessActive(conn.Access) && botruntime.BotAccessActive(existing.Access) {
|
||||
conn.Access = existing.Access
|
||||
}
|
||||
c.Bot.Connections[i] = conn
|
||||
replaced = true
|
||||
break
|
||||
@@ -697,6 +707,7 @@ func botConnectionView(conn config.BotConnectionConfig) BotConnectionView {
|
||||
return BotConnectionView{
|
||||
ID: conn.ID, Provider: conn.Provider, Domain: conn.Domain, Label: conn.Label, Enabled: conn.Enabled, Status: conn.Status,
|
||||
Model: conn.Model, ToolApprovalMode: normalizeBotConnectionToolApprovalMode(conn.ToolApprovalMode), WorkspaceRoot: conn.WorkspaceRoot,
|
||||
Access: botAccessViewFromConfig(conn.Access),
|
||||
Credential: BotConnectionCredentialView{
|
||||
AppID: conn.Credential.AppID, AppSecretEnv: conn.Credential.AppSecretEnv, AccountID: conn.Credential.AccountID, TokenEnv: conn.Credential.TokenEnv,
|
||||
SecretSet: botCredentialSecretSet(conn),
|
||||
@@ -763,8 +774,9 @@ func botConnectionConfig(view BotConnectionView) config.BotConnectionConfig {
|
||||
Enabled: view.Enabled,
|
||||
Status: strings.TrimSpace(view.Status),
|
||||
Model: strings.TrimSpace(view.Model),
|
||||
ToolApprovalMode: normalizeBotConnectionToolApprovalMode(view.ToolApprovalMode),
|
||||
ToolApprovalMode: firstNonEmptyBot(normalizeBotConnectionToolApprovalMode(view.ToolApprovalMode), "ask"),
|
||||
WorkspaceRoot: strings.TrimSpace(view.WorkspaceRoot),
|
||||
Access: botAccessConfigFromView(view.Access),
|
||||
Credential: config.BotConnectionCredential{
|
||||
AppID: strings.TrimSpace(view.Credential.AppID),
|
||||
AppSecretEnv: strings.TrimSpace(view.Credential.AppSecretEnv),
|
||||
@@ -873,6 +885,15 @@ func connectionID(provider, domain string) string {
|
||||
return strings.Trim(strings.ToLower(provider+"-"+domain), "-")
|
||||
}
|
||||
|
||||
func botInstallAccess(userID string) config.BotAccessConfig {
|
||||
userID = strings.TrimSpace(userID)
|
||||
access := config.BotAccessConfig{Enabled: true, PairingEnabled: true}
|
||||
if userID != "" {
|
||||
access.Users = []string{userID}
|
||||
}
|
||||
return access
|
||||
}
|
||||
|
||||
func randomInstallID() string {
|
||||
var b [12]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/bot"
|
||||
"reasonix/internal/event"
|
||||
)
|
||||
|
||||
const botForwardSendTimeout = 30 * time.Second
|
||||
const botForwardQueueSize = 64
|
||||
|
||||
// ── Forward target ──────────────────────────────────────────────────────────
|
||||
|
||||
// botForwardTarget identifies one remote chat to send forwarded events to.
|
||||
type botForwardTarget struct {
|
||||
ConnID string
|
||||
Domain string
|
||||
ChatID string
|
||||
ChatType bot.ChatType
|
||||
}
|
||||
|
||||
// ── Event forwarder ─────────────────────────────────────────────────────────
|
||||
|
||||
// botEventForwarder implements event.Sink and forwards relevant events to
|
||||
// connected bot channels through the desktopBotRuntime. It is attached to a
|
||||
// tabEventSink when a heartbeat task should push AI output to IM channels.
|
||||
//
|
||||
// It accumulates Text events and sends them as complete messages on TurnDone
|
||||
// (and occasionally during generation when the buffer grows large enough), so
|
||||
// the remote side sees progressive streaming output rather than one big blob.
|
||||
type botEventForwarder struct {
|
||||
runtime *desktopBotRuntime
|
||||
targets []botForwardTarget
|
||||
|
||||
mu sync.Mutex
|
||||
buf strings.Builder
|
||||
queueMu sync.Mutex
|
||||
queue chan string
|
||||
closed bool
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
// newBotEventForwarder creates a forwarder that sends to all given targets.
|
||||
// runtime may be nil — Emit calls are then no-ops.
|
||||
func newBotEventForwarder(runtime *desktopBotRuntime, targets []botForwardTarget) *botEventForwarder {
|
||||
f := &botEventForwarder{
|
||||
runtime: runtime,
|
||||
targets: targets,
|
||||
queue: make(chan string, botForwardQueueSize),
|
||||
}
|
||||
go f.run()
|
||||
return f
|
||||
}
|
||||
|
||||
// Emit implements event.Sink. It forwards text and lifecycle events to the
|
||||
// connected bot channels; reasoning, tool dispatch, and other internal events
|
||||
// are dropped to avoid noisy IM output.
|
||||
func (f *botEventForwarder) Emit(e event.Event) {
|
||||
if f.runtime == nil || len(f.targets) == 0 {
|
||||
return
|
||||
}
|
||||
switch e.Kind {
|
||||
case event.TurnStarted:
|
||||
f.mu.Lock()
|
||||
f.buf.Reset()
|
||||
f.mu.Unlock()
|
||||
|
||||
case event.Text:
|
||||
f.mu.Lock()
|
||||
f.buf.WriteString(e.Text)
|
||||
size := f.buf.Len()
|
||||
f.mu.Unlock()
|
||||
// Flush opportunistically when the buffer crosses a threshold, so long
|
||||
// streams (e.g. "tell me three jokes") produce multiple messages.
|
||||
if size >= 400 {
|
||||
f.flush()
|
||||
}
|
||||
|
||||
case event.TurnDone:
|
||||
f.flush()
|
||||
f.Close()
|
||||
|
||||
case event.ApprovalRequest:
|
||||
// The heartbeat turn belongs to the desktop tab controller, not the bot
|
||||
// gateway session, so remote /approve replies cannot satisfy this ID.
|
||||
text := "⚠️ 需要在 Reasonix 桌面端批准操作: " + e.Approval.Tool + " — " + e.Approval.Subject
|
||||
text += "\n请回到桌面窗口处理。"
|
||||
f.sendToAll(text)
|
||||
|
||||
case event.AskRequest:
|
||||
var qb strings.Builder
|
||||
qb.WriteString("❓ 需要在 Reasonix 桌面端回答问题:\n")
|
||||
for i, q := range e.Ask.Questions {
|
||||
if i > 0 {
|
||||
qb.WriteString("\n")
|
||||
}
|
||||
qb.WriteString(q.Prompt)
|
||||
}
|
||||
qb.WriteString("\n请回到桌面窗口处理。")
|
||||
f.sendToAll(qb.String())
|
||||
|
||||
case event.Notice:
|
||||
if e.Level == event.LevelWarn {
|
||||
f.sendToAll("⚠️ " + e.Text)
|
||||
}
|
||||
|
||||
case event.CompactionStarted:
|
||||
f.sendToAll("🔄 正在压缩上下文...")
|
||||
}
|
||||
}
|
||||
|
||||
// flush sends the accumulated buffer as one message per target channel.
|
||||
func (f *botEventForwarder) flush() {
|
||||
f.mu.Lock()
|
||||
text := strings.TrimSpace(f.buf.String())
|
||||
if text == "" {
|
||||
f.mu.Unlock()
|
||||
return
|
||||
}
|
||||
f.buf.Reset()
|
||||
f.mu.Unlock()
|
||||
|
||||
f.sendToAll(text)
|
||||
}
|
||||
|
||||
// sendToAll dispatches text to every target channel. Errors are logged and
|
||||
// non-fatal; a failed target does not block other targets.
|
||||
func (f *botEventForwarder) sendToAll(text string) {
|
||||
text = strings.TrimSpace(text)
|
||||
if f.runtime == nil || len(f.targets) == 0 || text == "" {
|
||||
return
|
||||
}
|
||||
f.queueMu.Lock()
|
||||
defer f.queueMu.Unlock()
|
||||
if f.closed {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case f.queue <- text:
|
||||
default:
|
||||
log.Printf("[bot-forward] send queue full; dropping message for %d target(s)", len(f.targets))
|
||||
}
|
||||
}
|
||||
|
||||
func (f *botEventForwarder) run() {
|
||||
for text := range f.queue {
|
||||
f.sendToAllNow(text)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *botEventForwarder) sendToAllNow(text string) {
|
||||
for _, tgt := range f.targets {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), botForwardSendTimeout)
|
||||
_, err := f.runtime.SendToAdapter(ctx, tgt.ConnID, tgt.Domain, bot.OutboundMessage{
|
||||
ChatID: tgt.ChatID,
|
||||
ChatType: tgt.ChatType,
|
||||
Text: text,
|
||||
})
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Printf("[bot-forward] send to %s/%s failed: %v", tgt.ConnID, tgt.ChatType, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *botEventForwarder) Close() {
|
||||
f.closeOnce.Do(func() {
|
||||
f.flush()
|
||||
f.queueMu.Lock()
|
||||
f.closed = true
|
||||
close(f.queue)
|
||||
f.queueMu.Unlock()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/bot"
|
||||
"reasonix/internal/event"
|
||||
)
|
||||
|
||||
type desktopForwardTestAdapter struct {
|
||||
platform bot.Platform
|
||||
name string
|
||||
messages chan bot.InboundMessage
|
||||
entered chan struct{}
|
||||
release chan struct{}
|
||||
sent chan bot.OutboundMessage
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func newDesktopForwardTestAdapter() *desktopForwardTestAdapter {
|
||||
return &desktopForwardTestAdapter{
|
||||
platform: bot.PlatformFeishu,
|
||||
name: "forward-test",
|
||||
messages: make(chan bot.InboundMessage),
|
||||
entered: make(chan struct{}),
|
||||
release: make(chan struct{}),
|
||||
sent: make(chan bot.OutboundMessage, 8),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *desktopForwardTestAdapter) Platform() bot.Platform { return a.platform }
|
||||
func (a *desktopForwardTestAdapter) Name() string { return a.name }
|
||||
func (a *desktopForwardTestAdapter) Start(context.Context) error {
|
||||
return nil
|
||||
}
|
||||
func (a *desktopForwardTestAdapter) Stop() error { return nil }
|
||||
func (a *desktopForwardTestAdapter) SendTyping(context.Context, string) error {
|
||||
return nil
|
||||
}
|
||||
func (a *desktopForwardTestAdapter) Messages() <-chan bot.InboundMessage {
|
||||
return a.messages
|
||||
}
|
||||
func (a *desktopForwardTestAdapter) Send(ctx context.Context, msg bot.OutboundMessage) (bot.SendResult, error) {
|
||||
a.once.Do(func() { close(a.entered) })
|
||||
select {
|
||||
case <-a.release:
|
||||
case <-ctx.Done():
|
||||
return bot.SendResult{}, ctx.Err()
|
||||
}
|
||||
a.sent <- msg
|
||||
return bot.SendResult{MessageID: "sent"}, nil
|
||||
}
|
||||
|
||||
func newDesktopForwardTestRuntime(adapter bot.Adapter) *desktopBotRuntime {
|
||||
gw := bot.NewGatewayWithAdapterBindings(bot.GatewayConfig{}, []bot.AdapterBinding{{
|
||||
ID: "feishu-lark",
|
||||
Domain: "lark",
|
||||
Platform: bot.PlatformFeishu,
|
||||
Adapter: adapter,
|
||||
}}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
return &desktopBotRuntime{gw: gw}
|
||||
}
|
||||
|
||||
func TestBotEventForwarderDoesNotBlockEventEmissionOnSlowSend(t *testing.T) {
|
||||
adapter := newDesktopForwardTestAdapter()
|
||||
forwarder := newBotEventForwarder(newDesktopForwardTestRuntime(adapter), []botForwardTarget{{
|
||||
ConnID: "feishu-lark",
|
||||
Domain: "lark",
|
||||
ChatID: "oc-group-1",
|
||||
ChatType: bot.ChatGroup,
|
||||
}})
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
forwarder.Emit(event.Event{Kind: event.Text, Text: strings.Repeat("x", 400)})
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("Emit blocked behind slow bot send")
|
||||
}
|
||||
select {
|
||||
case <-adapter.entered:
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("adapter send did not start")
|
||||
}
|
||||
|
||||
forwarder.Close()
|
||||
close(adapter.release)
|
||||
select {
|
||||
case <-adapter.sent:
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("queued bot message was not sent after adapter release")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotEventForwarderApprovalNoticeDoesNotExposeReplyID(t *testing.T) {
|
||||
adapter := newDesktopForwardTestAdapter()
|
||||
close(adapter.release)
|
||||
forwarder := newBotEventForwarder(newDesktopForwardTestRuntime(adapter), []botForwardTarget{{
|
||||
ConnID: "feishu-lark",
|
||||
Domain: "lark",
|
||||
ChatID: "oc-group-1",
|
||||
ChatType: bot.ChatGroup,
|
||||
}})
|
||||
|
||||
forwarder.Emit(event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{
|
||||
ID: "approval-1",
|
||||
Tool: "shell",
|
||||
Subject: "run command",
|
||||
}})
|
||||
forwarder.Close()
|
||||
|
||||
select {
|
||||
case msg := <-adapter.sent:
|
||||
if strings.Contains(msg.Text, "approval-1") || strings.Contains(msg.Text, "/approve") {
|
||||
t.Fatalf("approval notice exposed unusable reply routing: %q", msg.Text)
|
||||
}
|
||||
if !strings.Contains(msg.Text, "桌面") {
|
||||
t.Fatalf("approval notice = %q, want desktop guidance", msg.Text)
|
||||
}
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("approval notice was not sent")
|
||||
}
|
||||
}
|
||||
+118
-4
@@ -33,6 +33,30 @@ func newDesktopBotRuntime() *desktopBotRuntime {
|
||||
return &desktopBotRuntime{status: BotRuntimeStatusView{Status: "stopped", Message: "bot runtime is not started"}}
|
||||
}
|
||||
|
||||
func desktopBotChannelsWithLegacyQQ(qq config.QQBotConfig, channels map[bot.Platform]bot.ChannelConfig, connectionChannels map[string]bot.ChannelConfig) (map[bot.Platform]bot.ChannelConfig, map[string]bot.ChannelConfig) {
|
||||
channel := bot.ChannelConfig{
|
||||
Model: strings.TrimSpace(qq.Model),
|
||||
ToolApprovalMode: normalizeBotConnectionToolApprovalMode(qq.ToolApprovalMode),
|
||||
WorkspaceRoot: strings.TrimSpace(qq.WorkspaceRoot),
|
||||
}
|
||||
if channel.Model == "" && channel.ToolApprovalMode == "" && channel.WorkspaceRoot == "" {
|
||||
return channels, connectionChannels
|
||||
}
|
||||
if channels == nil {
|
||||
channels = make(map[bot.Platform]bot.ChannelConfig)
|
||||
}
|
||||
if _, ok := channels[bot.PlatformQQ]; !ok {
|
||||
channels[bot.PlatformQQ] = channel
|
||||
}
|
||||
if connectionChannels == nil {
|
||||
connectionChannels = make(map[string]bot.ChannelConfig)
|
||||
}
|
||||
if _, ok := connectionChannels[string(bot.PlatformQQ)]; !ok {
|
||||
connectionChannels[string(bot.PlatformQQ)] = channel
|
||||
}
|
||||
return channels, connectionChannels
|
||||
}
|
||||
|
||||
func (a *App) refreshBotRuntimeAsync() {
|
||||
if a.ctx == nil {
|
||||
return
|
||||
@@ -92,13 +116,33 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
modelName := botruntime.ModelName(cfg, "")
|
||||
channels := botruntime.ChannelConfigs(cfg.Bot.Connections, true, true)
|
||||
connectionChannels := botruntime.ConnectionChannelConfigs(cfg.Bot.Connections, true, true)
|
||||
channels, connectionChannels = desktopBotChannelsWithLegacyQQ(cfg.Bot.QQ, channels, connectionChannels)
|
||||
gwCfg := bot.GatewayConfig{
|
||||
Model: modelName,
|
||||
ToolApprovalMode: cfg.Bot.ToolApprovalMode,
|
||||
MaxSteps: cfg.Bot.MaxSteps,
|
||||
QueueMode: cfg.Bot.QueueMode,
|
||||
QueueCap: cfg.Bot.QueueCap,
|
||||
QueueDrop: cfg.Bot.QueueDrop,
|
||||
PairingEnabled: cfg.Bot.Pairing.Enabled,
|
||||
PairingTTL: time.Duration(cfg.Bot.Pairing.RequestTTLMinutes) * time.Minute,
|
||||
PairingMaxPending: cfg.Bot.Pairing.MaxPendingPerPlatform,
|
||||
IgnoreSelfMessages: cfg.Bot.IgnoreSelfMessages,
|
||||
SelfUserIDs: map[bot.Platform][]string{
|
||||
bot.PlatformQQ: cfg.Bot.SelfUserIDs.QQ,
|
||||
bot.PlatformFeishu: cfg.Bot.SelfUserIDs.Feishu,
|
||||
bot.PlatformWeixin: cfg.Bot.SelfUserIDs.Weixin,
|
||||
},
|
||||
ControlEnabled: cfg.Bot.Control.Enabled,
|
||||
ControlAddr: cfg.Bot.Control.Addr,
|
||||
ControlToken: os.Getenv(strings.TrimSpace(cfg.Bot.Control.TokenEnv)),
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Channels: botruntime.ChannelConfigs(cfg.Bot.Connections, true, true),
|
||||
ConnectionChannels: botruntime.ConnectionChannelConfigs(cfg.Bot.Connections, true, true),
|
||||
Channels: channels,
|
||||
ConnectionChannels: connectionChannels,
|
||||
Routes: botruntime.RouteConfigs(cfg.Bot.Routes, true, true),
|
||||
ConnectionAccess: botruntime.ConnectionAccessConfigs(cfg),
|
||||
Enabled: plan.Enabled,
|
||||
Allowlist: bot.AllowlistConfig{
|
||||
Enabled: cfg.Bot.Allowlist.Enabled,
|
||||
@@ -108,6 +152,16 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
|
||||
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuUsers,
|
||||
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinUsers,
|
||||
},
|
||||
Approvers: map[bot.Platform][]string{
|
||||
bot.PlatformQQ: cfg.Bot.Allowlist.QQApprovers,
|
||||
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuApprovers,
|
||||
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinApprovers,
|
||||
},
|
||||
Admins: map[bot.Platform][]string{
|
||||
bot.PlatformQQ: cfg.Bot.Allowlist.QQAdmins,
|
||||
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuAdmins,
|
||||
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinAdmins,
|
||||
},
|
||||
Groups: map[bot.Platform][]string{
|
||||
bot.PlatformQQ: cfg.Bot.Allowlist.QQGroups,
|
||||
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuGroups,
|
||||
@@ -206,8 +260,8 @@ func desktopBotRuntimePlan(cfg *config.Config) botRuntimePlan {
|
||||
if !cfg.Bot.Enabled {
|
||||
return botRuntimePlan{Status: "stopped", Message: "bot is disabled"}
|
||||
}
|
||||
if !cfg.Bot.Allowlist.AllowAll && (!cfg.Bot.Allowlist.Enabled || botruntime.AllowlistUserCount(cfg.Bot.Allowlist) == 0) {
|
||||
return botRuntimePlan{Status: "blocked", Message: "bot requires an allowlist or allow_all=true"}
|
||||
if !botruntime.BotConfigHasAccessControl(cfg.Bot) {
|
||||
return botRuntimePlan{Status: "blocked", Message: "bot requires an allowlist, pairing, per-bot access, or allow_all=true"}
|
||||
}
|
||||
enabled, unknown := botruntime.EnabledPlatforms(cfg, nil)
|
||||
if len(unknown) > 0 {
|
||||
@@ -258,3 +312,63 @@ func (r *desktopBotRuntime) updateConnectionToolApprovalMode(connID, mode string
|
||||
r.gw.UpdateConnectionToolApprovalMode(connID, mode)
|
||||
return true
|
||||
}
|
||||
|
||||
// SendToAdapter sends a message through the running gateway's adapter
|
||||
// identified by connID. Returns an error if the gateway is not running
|
||||
// or no matching adapter is found.
|
||||
func (r *desktopBotRuntime) SendToAdapter(ctx context.Context, connID, domain string, msg bot.OutboundMessage) (bot.SendResult, error) {
|
||||
r.mu.Lock()
|
||||
gw := r.gw
|
||||
r.mu.Unlock()
|
||||
if gw == nil {
|
||||
return bot.SendResult{}, nil // gateway not running — silent no-op
|
||||
}
|
||||
return gw.SendToAdapter(ctx, connID, domain, msg)
|
||||
}
|
||||
|
||||
// Running returns true if the bot gateway is currently active.
|
||||
func (r *desktopBotRuntime) Running() bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.gw != nil
|
||||
}
|
||||
|
||||
// ForwardTargets returns the list of bot forward targets derived from the
|
||||
// current config's bot connections and their session mappings. Each mapping
|
||||
// produces one target (connID + chatID + chatType) for event forwarding.
|
||||
func (r *desktopBotRuntime) ForwardTargets(cfg *config.Config) []botForwardTarget {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
var targets []botForwardTarget
|
||||
seen := make(map[botForwardTarget]bool)
|
||||
for _, conn := range cfg.Bot.Connections {
|
||||
if !conn.Enabled {
|
||||
continue
|
||||
}
|
||||
connID := botruntime.ConnectionRuntimeID(conn)
|
||||
domain := strings.TrimSpace(conn.Domain)
|
||||
for _, sm := range conn.SessionMappings {
|
||||
remoteID := strings.TrimSpace(sm.RemoteID)
|
||||
if remoteID == "" {
|
||||
continue
|
||||
}
|
||||
chatType := bot.ChatDM
|
||||
if sm.ChatType != "" {
|
||||
chatType = bot.ChatType(sm.ChatType)
|
||||
}
|
||||
target := botForwardTarget{
|
||||
ConnID: connID,
|
||||
Domain: domain,
|
||||
ChatID: remoteID,
|
||||
ChatType: chatType,
|
||||
}
|
||||
if seen[target] {
|
||||
continue
|
||||
}
|
||||
seen[target] = true
|
||||
targets = append(targets, target)
|
||||
}
|
||||
}
|
||||
return targets
|
||||
}
|
||||
@@ -39,6 +39,7 @@ func TestDesktopBotRuntimePlanBlocksWithoutAllowlist(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Enabled = true
|
||||
cfg.Bot.Allowlist.Enabled = true
|
||||
cfg.Bot.Pairing.Enabled = false
|
||||
cfg.Bot.Connections = []config.BotConnectionConfig{
|
||||
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true},
|
||||
}
|
||||
@@ -49,6 +50,44 @@ func TestDesktopBotRuntimePlanBlocksWithoutAllowlist(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopBotRuntimePlanStartsWithPairing(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Enabled = true
|
||||
cfg.Bot.Allowlist.Enabled = true
|
||||
cfg.Bot.Pairing.Enabled = true
|
||||
cfg.Bot.Connections = []config.BotConnectionConfig{
|
||||
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true},
|
||||
}
|
||||
|
||||
plan := desktopBotRuntimePlan(cfg)
|
||||
if !plan.Start {
|
||||
t.Fatalf("plan = %+v, want start with pairing enabled", plan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopBotChannelsWithLegacyQQConfig(t *testing.T) {
|
||||
channels, connectionChannels := desktopBotChannelsWithLegacyQQ(config.QQBotConfig{
|
||||
Model: "qq-model",
|
||||
ToolApprovalMode: "auto",
|
||||
WorkspaceRoot: "/tmp/qq-project",
|
||||
}, nil, nil)
|
||||
|
||||
channel, ok := channels[bot.PlatformQQ]
|
||||
if !ok {
|
||||
t.Fatalf("platform QQ channel missing: %+v", channels)
|
||||
}
|
||||
if channel.Model != "qq-model" || channel.ToolApprovalMode != "auto" || channel.WorkspaceRoot != "/tmp/qq-project" {
|
||||
t.Fatalf("platform channel = %+v, want QQ-specific runtime fields", channel)
|
||||
}
|
||||
connectionChannel, ok := connectionChannels["qq"]
|
||||
if !ok {
|
||||
t.Fatalf("connection QQ channel missing: %+v", connectionChannels)
|
||||
}
|
||||
if connectionChannel.Model != "qq-model" || connectionChannel.ToolApprovalMode != "auto" || connectionChannel.WorkspaceRoot != "/tmp/qq-project" {
|
||||
t.Fatalf("connection channel = %+v, want QQ-specific runtime fields", connectionChannel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopBotRuntimePlanStopsWhenBotDisabled(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Enabled = false
|
||||
@@ -63,6 +102,37 @@ func TestDesktopBotRuntimePlanStopsWhenBotDisabled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopBotRuntimeForwardTargetsDeduplicatesMappedChats(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Connections = []config.BotConnectionConfig{{
|
||||
ID: "feishu-lark",
|
||||
Provider: "feishu",
|
||||
Domain: "lark",
|
||||
Enabled: true,
|
||||
SessionMappings: []config.BotConnectionSessionMapping{
|
||||
{RemoteID: "oc-group-1", ChatType: string(bot.ChatGroup), UserID: "ou-user-1"},
|
||||
{RemoteID: "oc-group-1", ChatType: string(bot.ChatGroup), UserID: "ou-user-2"},
|
||||
{RemoteID: "oc-dm-1", ChatType: string(bot.ChatDM), UserID: "ou-user-1"},
|
||||
},
|
||||
}}
|
||||
|
||||
targets := newDesktopBotRuntime().ForwardTargets(cfg)
|
||||
if len(targets) != 2 {
|
||||
t.Fatalf("targets = %+v, want one group target plus one dm target", targets)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, target := range targets {
|
||||
key := target.ConnID + "|" + target.Domain + "|" + target.ChatID + "|" + string(target.ChatType)
|
||||
if seen[key] {
|
||||
t.Fatalf("duplicate target %q in %+v", key, targets)
|
||||
}
|
||||
seen[key] = true
|
||||
}
|
||||
if !seen["feishu-lark|lark|oc-group-1|group"] || !seen["feishu-lark|lark|oc-dm-1|dm"] {
|
||||
t.Fatalf("targets = %+v, want group and dm targets", targets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopBotRuntimeConfigUsesUserBotSettings(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
|
||||
@@ -42,9 +42,9 @@ func TestBoundArrayPayloadsAreNonNilBeforeStartup(t *testing.T) {
|
||||
if got := app.HooksSettings("global"); got.Hooks == nil || got.Events == nil {
|
||||
t.Fatalf("HooksSettings(global) arrays = hooks:%v events:%v, want non-nil", got.Hooks, got.Events)
|
||||
}
|
||||
if got := app.Settings(); got.Providers == nil || got.OfficialProviders == nil || got.ProviderKinds == nil ||
|
||||
if got := app.Settings(); got.Providers == nil || got.OfficialProviders == nil || got.ProviderPresets == nil || got.ProviderKinds == nil ||
|
||||
got.Permissions.Allow == nil || got.Permissions.Ask == nil || got.Permissions.Deny == nil ||
|
||||
got.Sandbox.AllowWrite == nil ||
|
||||
got.Sandbox.AllowWrite == nil || got.Sandbox.EffectiveWriteRoots == nil ||
|
||||
got.Bot.Allowlist.QQUsers == nil || got.Bot.Allowlist.FeishuUsers == nil || got.Bot.Allowlist.WeixinUsers == nil ||
|
||||
got.Bot.Allowlist.QQGroups == nil || got.Bot.Allowlist.FeishuGroups == nil || got.Bot.Allowlist.WeixinGroups == nil {
|
||||
t.Fatalf("Settings() contains nil array fields: %+v", got)
|
||||
|
||||
@@ -25,6 +25,28 @@ func seedCheckpoint(t *testing.T, ckptDir string, c checkpoint.Checkpoint) {
|
||||
}
|
||||
}
|
||||
|
||||
func assertCheckpointFilesEncodeAsArray(t *testing.T, metas []CheckpointMeta) {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(metas)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload []struct {
|
||||
Files json.RawMessage `json:"files"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, item := range payload {
|
||||
if string(item.Files) == "null" {
|
||||
t.Fatalf("checkpoint %d files encoded as null; frontend expects []", i)
|
||||
}
|
||||
if len(item.Files) == 0 || item.Files[0] != '[' {
|
||||
t.Fatalf("checkpoint %d files encoded as %s, want JSON array", i, item.Files)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckpointsCanCodePropagatesToEarlierTurns covers #3438: RestoreCode(turn)
|
||||
// reverts files touched in that turn or any later one, so a turn with no file
|
||||
// changes of its own can still rewind code when a later turn changed files. The
|
||||
@@ -77,4 +99,56 @@ func TestCheckpointsCanCodePropagatesToEarlierTurns(t *testing.T) {
|
||||
if len(metas[0].Files) != 1 || metas[0].Files[0] != "a.txt" {
|
||||
t.Fatalf("turn 0 cumulative files = %#v, want [a.txt]", metas[0].Files)
|
||||
}
|
||||
if metas[0].FileCount != 1 || metas[0].FilesTruncated {
|
||||
t.Fatalf("turn 0 file summary = count %d truncated %v, want count 1 truncated false", metas[0].FileCount, metas[0].FilesTruncated)
|
||||
}
|
||||
if len(metas[2].Files) != 0 {
|
||||
t.Fatalf("turn 2 cumulative files = %#v, want empty", metas[2].Files)
|
||||
}
|
||||
assertCheckpointFilesEncodeAsArray(t, metas)
|
||||
}
|
||||
|
||||
func TestCheckpointsForTabLimitsCumulativeFilePreview(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sessionPath := filepath.Join(dir, "s.jsonl")
|
||||
ckptDir := sessionPath[:len(sessionPath)-len(".jsonl")] + ".ckpt"
|
||||
if err := os.MkdirAll(ckptDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := "old"
|
||||
files := make([]checkpoint.FileSnap, 0, checkpointFilePreviewLimit+5)
|
||||
for i := 0; i < checkpointFilePreviewLimit+5; i++ {
|
||||
files = append(files, checkpoint.FileSnap{Path: "file-" + strconv.Itoa(1000+i) + ".txt", Content: &content})
|
||||
}
|
||||
now := time.Now()
|
||||
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 0, Time: now, Prompt: "before edits", MsgIndex: 0})
|
||||
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 1, Time: now, Prompt: "edit many files", MsgIndex: 2, Files: files})
|
||||
|
||||
ag := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
|
||||
ctrl := control.New(control.Options{Executor: ag, SessionDir: dir, Label: "test"})
|
||||
ctrl.SetSessionPath(sessionPath)
|
||||
|
||||
app := &App{}
|
||||
app.setTestCtrl(ctrl, "test")
|
||||
|
||||
metas := app.CheckpointsForTab("test")
|
||||
if len(metas) != 2 {
|
||||
t.Fatalf("checkpoints = %d, want 2", len(metas))
|
||||
}
|
||||
if metas[0].FileCount != checkpointFilePreviewLimit+5 {
|
||||
t.Fatalf("turn 0 cumulative file count = %d, want %d", metas[0].FileCount, checkpointFilePreviewLimit+5)
|
||||
}
|
||||
if len(metas[0].Files) != checkpointFilePreviewLimit {
|
||||
t.Fatalf("turn 0 preview files = %d, want %d", len(metas[0].Files), checkpointFilePreviewLimit)
|
||||
}
|
||||
if !metas[0].FilesTruncated {
|
||||
t.Fatal("turn 0 should mark file preview as truncated")
|
||||
}
|
||||
if metas[0].TurnFileCount != 0 {
|
||||
t.Fatalf("turn 0 file count = %d, want 0 for this turn", metas[0].TurnFileCount)
|
||||
}
|
||||
if metas[1].TurnFileCount != checkpointFilePreviewLimit+5 {
|
||||
t.Fatalf("turn 1 file count = %d, want %d", metas[1].TurnFileCount, checkpointFilePreviewLimit+5)
|
||||
}
|
||||
assertCheckpointFilesEncodeAsArray(t, metas)
|
||||
}
|
||||
@@ -166,12 +166,12 @@ func signFiles(files []string) error {
|
||||
// release tag used in download URLs (e.g. "desktop-v1.1.0").
|
||||
func genManifest(dir, version, tag string) error {
|
||||
repo := os.Getenv("GITHUB_REPOSITORY")
|
||||
if repo == "" {
|
||||
repo = "esengine/reasonix"
|
||||
if repo == "" || repo == "esengine/reasonix" {
|
||||
repo = "esengine/DeepSeek-Reasonix"
|
||||
}
|
||||
m := update.Manifest{
|
||||
Version: version,
|
||||
DownloadPage: fmt.Sprintf("https://github.com/%s/releases/latest", repo),
|
||||
DownloadPage: "https://reasonix.io/#start",
|
||||
Platforms: map[string]update.Asset{},
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
|
||||
@@ -85,6 +85,9 @@ func TestGenManifest(t *testing.T) {
|
||||
if m.Version != "v1.2.0" {
|
||||
t.Fatalf("version = %q, want v1.2.0", m.Version)
|
||||
}
|
||||
if m.DownloadPage != "https://reasonix.io/#start" {
|
||||
t.Fatalf("download_page = %q, want official install page", m.DownloadPage)
|
||||
}
|
||||
if len(m.Platforms) != 5 {
|
||||
t.Fatalf("want 5 platforms, got %d: %v", len(m.Platforms), m.Platforms)
|
||||
}
|
||||
@@ -92,7 +95,7 @@ func TestGenManifest(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("windows-amd64 missing")
|
||||
}
|
||||
wantURL := "https://github.com/esengine/reasonix/releases/download/desktop-v1.2.0/Reasonix-windows-amd64-installer.exe"
|
||||
wantURL := "https://github.com/esengine/DeepSeek-Reasonix/releases/download/desktop-v1.2.0/Reasonix-windows-amd64-installer.exe"
|
||||
if win.URL != wantURL {
|
||||
t.Fatalf("windows url = %q, want %q", win.URL, wantURL)
|
||||
}
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
"check:css": "node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css",
|
||||
"test:todo-visibility": "node scripts/test-todo-visibility.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx && tsx src/__tests__/memory-compiler-display.test.ts && tsx src/__tests__/memory-citation-visibility.test.ts",
|
||||
"test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx && tsx src/__tests__/memory-compiler-display.test.ts && tsx src/__tests__/memory-citation-visibility.test.ts",
|
||||
"test:typecheck": "tsc --noEmit -p tsconfig.test.json",
|
||||
"test:all": "tsc --noEmit -p tsconfig.test.json && node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx"
|
||||
"test:all": "tsc --noEmit -p tsconfig.test.json && node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gsap/react": "^2.1.2",
|
||||
|
||||
@@ -16,7 +16,16 @@ const transpiled = ts.transpileModule(source, {
|
||||
}).outputText;
|
||||
|
||||
const moduleUrl = `data:text/javascript;base64,${Buffer.from(transpiled).toString("base64")}`;
|
||||
const { shouldShowTodoPanel, todoDismissalKey } = await import(moduleUrl);
|
||||
const {
|
||||
dismissedTodoKeyForScope,
|
||||
scopedTodoBatchKey,
|
||||
scopedTodoDismissalKey,
|
||||
shouldOpenTodoPanelByDefault,
|
||||
shouldShowTodoPanel,
|
||||
todoBatchKey,
|
||||
todoDismissalKey,
|
||||
todoPanelScope,
|
||||
} = await import(moduleUrl);
|
||||
|
||||
const completedTodos = [
|
||||
{ content: "Inspect the report", status: "completed" },
|
||||
@@ -42,6 +51,66 @@ assert.equal(
|
||||
false,
|
||||
"a user dismissal still hides that exact todo list",
|
||||
);
|
||||
const completedKey = todoDismissalKey(completedTodos);
|
||||
const dismissedBySession = new Set([scopedTodoDismissalKey("session:a", completedKey)]);
|
||||
assert.equal(
|
||||
shouldShowTodoPanel(completedKey, dismissedTodoKeyForScope("session:a", dismissedBySession, completedKey), completedTodos),
|
||||
false,
|
||||
"a completed todo dismissal hides the list in the session where it was closed",
|
||||
);
|
||||
assert.equal(
|
||||
shouldShowTodoPanel(completedKey, dismissedTodoKeyForScope("session:b", dismissedBySession, completedKey), completedTodos),
|
||||
true,
|
||||
"a completed todo dismissal in one session does not hide another session's list",
|
||||
);
|
||||
dismissedBySession.add(scopedTodoDismissalKey("session:b", completedKey));
|
||||
assert.equal(
|
||||
shouldShowTodoPanel(completedKey, dismissedTodoKeyForScope("session:a", dismissedBySession, completedKey), completedTodos),
|
||||
false,
|
||||
"closing another session's todo does not forget the first session dismissal",
|
||||
);
|
||||
assert.equal(
|
||||
todoPanelScope({
|
||||
activeTabId: "tab-a",
|
||||
activeTab: {
|
||||
id: "tab-a",
|
||||
scope: "project",
|
||||
workspaceRoot: "/repo",
|
||||
topicId: "topic-a",
|
||||
sessionPath: "/sessions/a.jsonl",
|
||||
},
|
||||
}),
|
||||
"session:/sessions/a.jsonl",
|
||||
"a restored history session uses its session path as the todo panel scope",
|
||||
);
|
||||
assert.equal(
|
||||
todoPanelScope({
|
||||
activeTabId: "tab-b",
|
||||
activeTab: {
|
||||
id: "tab-a",
|
||||
scope: "project",
|
||||
workspaceRoot: "/repo",
|
||||
topicId: "topic-a",
|
||||
sessionPath: "/sessions/a.jsonl",
|
||||
},
|
||||
eventChannel: "desktop-events",
|
||||
}),
|
||||
"tab:tab-b",
|
||||
"a stale active-tab fallback must not scope dismissal to the previous session",
|
||||
);
|
||||
assert.equal(
|
||||
todoPanelScope({
|
||||
activeTabId: "tab-c",
|
||||
activeTab: {
|
||||
id: "tab-c",
|
||||
scope: "project",
|
||||
workspaceRoot: "/repo",
|
||||
topicId: "topic-a",
|
||||
},
|
||||
}),
|
||||
"tab:tab-c",
|
||||
"an unsaved topic session uses its tab id so sibling sessions do not share todo state",
|
||||
);
|
||||
assert.equal(shouldShowTodoPanel(null, null, completedTodos), false, "no canonical todo item means no panel");
|
||||
assert.equal(shouldShowTodoPanel("todo-empty", null, []), false, "empty todo lists do not render a panel");
|
||||
|
||||
@@ -61,6 +130,36 @@ assert.notEqual(
|
||||
todoDismissalKey([{ ...activeTodos[0], status: "completed" }, { ...activeTodos[1], status: "in_progress" }]),
|
||||
"real progress produces a fresh dismissal key",
|
||||
);
|
||||
assert.equal(
|
||||
todoBatchKey(activeTodos),
|
||||
todoBatchKey([{ ...activeTodos[0], status: "completed" }, { ...activeTodos[1], status: "in_progress" }]),
|
||||
"status progress stays in the same todo batch",
|
||||
);
|
||||
assert.equal(
|
||||
scopedTodoBatchKey("session:a", todoBatchKey(activeTodos)),
|
||||
scopedTodoBatchKey("session:a", todoBatchKey([{ ...activeTodos[0], status: "completed" }, { ...activeTodos[1], status: "in_progress" }])),
|
||||
"status progress keeps the same scoped open-state key",
|
||||
);
|
||||
assert.notEqual(
|
||||
scopedTodoBatchKey("session:a", todoBatchKey(activeTodos)),
|
||||
scopedTodoBatchKey("session:b", todoBatchKey(activeTodos)),
|
||||
"the same task list in a different session gets isolated open state",
|
||||
);
|
||||
assert.notEqual(
|
||||
todoBatchKey(activeTodos),
|
||||
todoBatchKey([{ content: "Run a different task", status: "in_progress" }]),
|
||||
"new task content creates a new todo batch",
|
||||
);
|
||||
assert.equal(
|
||||
shouldOpenTodoPanelByDefault(activeTodos),
|
||||
true,
|
||||
"new incomplete todo batches open by default",
|
||||
);
|
||||
assert.equal(
|
||||
shouldOpenTodoPanelByDefault(completedTodos),
|
||||
false,
|
||||
"completed todo batches collapse by default",
|
||||
);
|
||||
|
||||
const iterations = 200_000;
|
||||
const started = performance.now();
|
||||
|
||||
+230
-10
@@ -1,5 +1,5 @@
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { CSSProperties, KeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
|
||||
import type { CSSProperties, KeyboardEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from "react";
|
||||
import { ShellExpandProvider, useShellExpand } from "./lib/shellExpand";
|
||||
import gsap from "gsap";
|
||||
import { useGSAP } from "@gsap/react";
|
||||
@@ -10,8 +10,11 @@ import {
|
||||
Activity,
|
||||
CircleHelp,
|
||||
Command,
|
||||
Copy as RestoreIcon,
|
||||
Download,
|
||||
Minus,
|
||||
Search,
|
||||
Square,
|
||||
SquarePen,
|
||||
PanelLeft,
|
||||
PanelRight,
|
||||
@@ -29,12 +32,13 @@ import {
|
||||
Brain,
|
||||
Cpu,
|
||||
Palette,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useToast } from "./lib/toast";
|
||||
import { asArray } from "./lib/array";
|
||||
import { clearLegacyLangPref, normalizeLangPref, readLegacyLangPref, useI18n, useT, type Translator } from "./lib/i18n";
|
||||
import { useController, type Item, type LiveStream } from "./lib/useController";
|
||||
import { app, onEvent, onProjectTreeChanged } from "./lib/bridge";
|
||||
import { app, onEvent, onProjectTreeChanged, onSessionRecovered, onSessionRecoveryFailed } from "./lib/bridge";
|
||||
import { generativeMusic, isGenerativeMusicEnabled } from "./lib/generative-music";
|
||||
import { playSuccessChime } from "./lib/sound";
|
||||
import { Transcript } from "./components/Transcript";
|
||||
@@ -59,7 +63,15 @@ import { HeartbeatPanel } from "./custom/features/heartbeat/HeartbeatPanel";
|
||||
import "./custom/features/heartbeat/heartbeat.css";
|
||||
import { CopyButton } from "./components/CopyButton";
|
||||
import { parseTodos } from "./lib/tools";
|
||||
import { shouldShowTodoPanel, todoDismissalKey } from "./lib/todoVisibility";
|
||||
import {
|
||||
dismissedTodoKeyForScope,
|
||||
scopedTodoBatchKey,
|
||||
scopedTodoDismissalKey,
|
||||
shouldShowTodoPanel,
|
||||
todoBatchKey,
|
||||
todoDismissalKey,
|
||||
todoPanelScope,
|
||||
} from "./lib/todoVisibility";
|
||||
import {
|
||||
type BotConnectionView,
|
||||
type BotRuntimeStatusView,
|
||||
@@ -182,9 +194,70 @@ function normalizeDesktopLayoutStyle(style: string | undefined): DesktopLayoutSt
|
||||
return "classic";
|
||||
}
|
||||
const SHOW_CONTEXT_DOCK = true;
|
||||
const DISMISSED_TODO_STORAGE_KEY = "todoPanel:dismissedKeys";
|
||||
const MAX_DISMISSED_TODO_KEYS = 160;
|
||||
type HistoryScopeFilter = { scope: "global" | "project"; workspaceRoot: string };
|
||||
type WorkspaceInsertTarget = "composer" | "planRevision";
|
||||
type DesktopPlatform = "darwin" | "windows" | "linux";
|
||||
|
||||
function WindowsWindowControls() {
|
||||
const [maximised, setMaximised] = useState(false);
|
||||
|
||||
const syncMaximised = useCallback(() => {
|
||||
void app.IsMainWindowMaximised()
|
||||
.then(setMaximised)
|
||||
.catch(() => setMaximised(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
syncMaximised();
|
||||
window.addEventListener("resize", syncMaximised);
|
||||
window.addEventListener("focus", syncMaximised);
|
||||
return () => {
|
||||
window.removeEventListener("resize", syncMaximised);
|
||||
window.removeEventListener("focus", syncMaximised);
|
||||
};
|
||||
}, [syncMaximised]);
|
||||
|
||||
const toggleMaximise = useCallback(() => {
|
||||
void app.ToggleMaximiseMainWindow()
|
||||
.then(() => window.setTimeout(syncMaximised, 80))
|
||||
.catch(() => undefined);
|
||||
}, [syncMaximised]);
|
||||
|
||||
return (
|
||||
<div className="windows-window-controls" aria-label="Window controls">
|
||||
<button
|
||||
className="windows-window-control windows-window-control--minimize"
|
||||
type="button"
|
||||
aria-label="Minimize window"
|
||||
title="Minimize"
|
||||
onClick={() => void app.MinimiseMainWindow()}
|
||||
>
|
||||
<Minus size={13} strokeWidth={1.9} />
|
||||
</button>
|
||||
<button
|
||||
className="windows-window-control windows-window-control--maximize"
|
||||
type="button"
|
||||
aria-label="Maximize or restore window"
|
||||
aria-pressed={maximised}
|
||||
title={maximised ? "Restore" : "Maximize"}
|
||||
onClick={toggleMaximise}
|
||||
>
|
||||
{maximised ? <RestoreIcon size={12} strokeWidth={1.75} /> : <Square size={11} strokeWidth={1.8} />}
|
||||
</button>
|
||||
<button
|
||||
className="windows-window-control windows-window-control--close"
|
||||
type="button"
|
||||
aria-label="Close window"
|
||||
title="Close"
|
||||
onClick={() => void app.CloseMainWindow()}
|
||||
>
|
||||
<X size={13} strokeWidth={1.9} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
type HistoryViewState =
|
||||
| { kind: "history"; source: "scope"; filter: HistoryScopeFilter; sessions: SessionMeta[] }
|
||||
| { kind: "history"; source: "all"; sessions: SessionMeta[] }
|
||||
@@ -231,6 +304,29 @@ type SidebarImConnectionDetailProps = {
|
||||
onManageAllowlist: () => void;
|
||||
};
|
||||
|
||||
function loadDismissedTodoKeys(): Set<string> {
|
||||
try {
|
||||
const saved = window.localStorage.getItem(DISMISSED_TODO_STORAGE_KEY);
|
||||
if (!saved) return new Set();
|
||||
const parsed = JSON.parse(saved) as unknown;
|
||||
if (!Array.isArray(parsed)) return new Set();
|
||||
return new Set(parsed.filter((value): value is string => typeof value === "string" && value.length > 0));
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function saveDismissedTodoKeys(keys: ReadonlySet<string>): void {
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
DISMISSED_TODO_STORAGE_KEY,
|
||||
JSON.stringify(Array.from(keys).slice(-MAX_DISMISSED_TODO_KEYS)),
|
||||
);
|
||||
} catch {
|
||||
/* ignore quota errors */
|
||||
}
|
||||
}
|
||||
|
||||
function isSidebarImConnection(connection: BotConnectionView): boolean {
|
||||
return connection.provider === "feishu" || connection.provider === "weixin";
|
||||
}
|
||||
@@ -652,6 +748,21 @@ function browserPlatformOverride(): DesktopPlatform | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const GUIDANCE_QUEUE_MOCK_ITEMS = [
|
||||
"先确认发送后输入框为什么残留刚发的消息,再决定修哪里。",
|
||||
"保持真实 steer 协议不变,只调整前端乐观队列和按钮状态。",
|
||||
"最后补后端 submit 悬挂时的回归测试,确保输入框会立刻释放。",
|
||||
] as const;
|
||||
|
||||
function browserMockScenarioParam(): string {
|
||||
if (typeof window === "undefined" || window.runtime) return "";
|
||||
return new URLSearchParams(window.location.search).get("mock")?.trim().toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
function isGuidanceMockScenario(value: string): boolean {
|
||||
return value === "guidance" || value === "guide" || value === "steer";
|
||||
}
|
||||
|
||||
function detectBrowserPlatform(): DesktopPlatform {
|
||||
const override = browserPlatformOverride();
|
||||
if (override) return override;
|
||||
@@ -919,6 +1030,9 @@ export default function App() {
|
||||
const rightDockMode = useLayoutStore((s) => s.rightDockMode);
|
||||
const setRightDockMode = useLayoutStore((s) => s.setRightDockMode);
|
||||
const [dockRefreshKey, setDockRefreshKey] = useState(0);
|
||||
const [fileRefRefreshKey, setFileRefRefreshKey] = useState(0);
|
||||
const refreshComposerFileRefs = useCallback(() => setFileRefRefreshKey((value) => value + 1), []);
|
||||
const composerFileRefRefreshKey = `${dockRefreshKey}:${fileRefRefreshKey}`;
|
||||
const [projectRevision, setProjectRevision] = useState(0);
|
||||
const [activeTopicTurns, setActiveTopicTurns] = useState<number | undefined>(undefined);
|
||||
const [composerInsertRequest, setComposerInsertRequest] = useState<ComposerInsertRequest | null>(null);
|
||||
@@ -1395,7 +1509,11 @@ export default function App() {
|
||||
// a stale local dismissal cannot hide work that still blocks final readiness;
|
||||
// completed lists collapse automatically and can then be dismissed. The
|
||||
// dismissal key is still based on stable todo content/state so history reloads
|
||||
// do not resurrect the same finished list under a different event id.
|
||||
// do not resurrect the same finished list under a different event id. The
|
||||
// batch key ignores status changes so progress within the same task list does
|
||||
// not look like a brand-new task batch. Dismissal and open state are scoped to
|
||||
// the active session/topic/tab so different projects and sessions do not hide
|
||||
// or reopen each other's todo panels.
|
||||
const todoEntry = useMemo(() => {
|
||||
for (let i = state.items.length - 1; i >= 0; i--) {
|
||||
const it = state.items[i];
|
||||
@@ -1407,9 +1525,30 @@ export default function App() {
|
||||
}, [state.items]);
|
||||
const todoItem = todoEntry?.item ?? null;
|
||||
const todos = useMemo(() => (todoItem ? parseTodos(todoItem.args) : []), [todoItem]);
|
||||
const [dismissedTodo, setDismissedTodo] = useState<string | null>(null);
|
||||
const [dismissedTodoKeys, setDismissedTodoKeys] = useState<Set<string>>(loadDismissedTodoKeys);
|
||||
const todoKey = useMemo(() => todoDismissalKey(todos), [todos]);
|
||||
const todoBatch = useMemo(() => todoBatchKey(todos), [todos]);
|
||||
const todoScope = useMemo(
|
||||
() => todoPanelScope({ activeTab, activeTabId, eventChannel: state.meta?.eventChannel }),
|
||||
[activeTab, activeTabId, state.meta?.eventChannel],
|
||||
);
|
||||
const dismissedTodo = useMemo(
|
||||
() => dismissedTodoKeyForScope(todoScope, dismissedTodoKeys, todoKey),
|
||||
[dismissedTodoKeys, todoKey, todoScope],
|
||||
);
|
||||
const scopedTodoKey = useMemo(() => scopedTodoDismissalKey(todoScope, todoKey), [todoKey, todoScope]);
|
||||
const scopedTodoBatch = useMemo(() => scopedTodoBatchKey(todoScope, todoBatch), [todoBatch, todoScope]);
|
||||
const showTodos = shouldShowTodoPanel(todoKey, dismissedTodo, todos);
|
||||
const dismissTodos = useCallback(() => {
|
||||
if (!scopedTodoKey) return;
|
||||
setDismissedTodoKeys((current) => {
|
||||
if (current.has(scopedTodoKey)) return current;
|
||||
const next = new Set(current);
|
||||
next.add(scopedTodoKey);
|
||||
saveDismissedTodoKeys(next);
|
||||
return next;
|
||||
});
|
||||
}, [scopedTodoKey]);
|
||||
|
||||
const sessionTitle = topicTitle(activeTab);
|
||||
const sessionHasContent = state.items.length > 0 || Boolean(state.live?.text || state.live?.reasoning);
|
||||
@@ -2126,6 +2265,16 @@ export default function App() {
|
||||
return transcriptItems.slice(0, rewindState.boundaryIdx).filter((it) => it.kind !== "compaction");
|
||||
}, [transcriptItems, rewindState]);
|
||||
|
||||
const latestGuidanceConsumed = useMemo(() => {
|
||||
for (let i = state.items.length - 1; i >= 0; i--) {
|
||||
const item = state.items[i];
|
||||
if (item.kind === "notice" && item.text.startsWith("↪ ")) {
|
||||
return { key: item.id, text: item.text.slice(2) };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [state.items]);
|
||||
|
||||
// send wrapper: commits any pending optimistic rewind before sending.
|
||||
const commitThenSend = useCallback(async (displayText: string, submitText?: string) => {
|
||||
if (activeTab?.readOnly) throw new Error("channel session is read-only");
|
||||
@@ -2442,6 +2591,37 @@ export default function App() {
|
||||
enqueueNavigation({ kind: "blank", scope, workspaceRoot: scope === "project" ? workspaceRoot : "" }),
|
||||
[enqueueNavigation]);
|
||||
|
||||
useEffect(() => {
|
||||
return onSessionRecovered((event) => {
|
||||
const scope = event.scope === "project" ? "project" : "global";
|
||||
const workspaceRoot = scope === "project" ? event.workspaceRoot || "" : "";
|
||||
setProjectRevision((value) => value + 1);
|
||||
void refreshTabMetas();
|
||||
showToast(t("recovery.toast", { title: event.topicTitle || t("recovery.branch") }), "warn", event.topicId
|
||||
? {
|
||||
actionLabel: t("recovery.open"),
|
||||
durationMs: 9000,
|
||||
onAction: () => {
|
||||
void enqueueNavigation({
|
||||
kind: "topic",
|
||||
scope,
|
||||
workspaceRoot,
|
||||
topicId: event.topicId || "",
|
||||
sessionPath: event.recoveryPath || "",
|
||||
});
|
||||
},
|
||||
}
|
||||
: { durationMs: 9000 });
|
||||
});
|
||||
}, [enqueueNavigation, refreshTabMetas, showToast, t]);
|
||||
|
||||
useEffect(() => {
|
||||
return onSessionRecoveryFailed((event) => {
|
||||
const key = event.reason === "lease_held" ? "recovery.failedLease" : "recovery.failedUnavailable";
|
||||
showToast(t(key), "error", { durationMs: 9000 });
|
||||
});
|
||||
}, [showToast, t]);
|
||||
|
||||
const handleNewTab = useCallback(async () => {
|
||||
closeTransientOverlays();
|
||||
setSidebarImDetailConnectionId("");
|
||||
@@ -2623,7 +2803,7 @@ export default function App() {
|
||||
setProjectRevision((value) => value + 1);
|
||||
const tabs = await refreshTabMetas();
|
||||
if (activeTabId && !tabs.some((tab) => tab.id === activeTabId)) {
|
||||
await syncActiveTab(true);
|
||||
await syncActiveTab(false);
|
||||
}
|
||||
}, [activeTabId, refreshTabMetas, syncActiveTab]);
|
||||
|
||||
@@ -2680,6 +2860,8 @@ export default function App() {
|
||||
: t("sidebar.collapse");
|
||||
const sidebarNavTooltipDisabled = !sidebarCollapsed;
|
||||
const browserPreviewChrome = typeof window !== "undefined" && !window.runtime;
|
||||
const browserMockScenario = browserPreviewChrome ? browserMockScenarioParam() : "";
|
||||
const guidanceQueueMockItems = isGuidanceMockScenario(browserMockScenario) ? GUIDANCE_QUEUE_MOCK_ITEMS : undefined;
|
||||
const workspacePanelResetWidth = rightDockDetailActive
|
||||
? RIGHT_DOCK_PREVIEW_DEFAULT_WIDTH
|
||||
: defaultRightDockTreeWidth();
|
||||
@@ -2700,7 +2882,23 @@ export default function App() {
|
||||
: [topicbarWorkspacePath || topicbarWorkspaceLabel, topicbarImSourceLabel].filter(Boolean).join(" · ");
|
||||
const topicbarCanRename = !sidebarImDetailConnection && Boolean(activeTab?.topicId);
|
||||
const topicbarTitleEditSize = Math.min(56, Math.max(4, topicTitleDraft.length || topicbarTitle.length || 1));
|
||||
const recoveryBannerTitle = activeTab?.recovered
|
||||
? [
|
||||
activeTab.recoveryReason ? t("recovery.reason", { reason: activeTab.recoveryReason }) : "",
|
||||
activeTab.recoveryDigest ? t("recovery.digest", { digest: activeTab.recoveryDigest.slice(0, 12) }) : "",
|
||||
activeTab.recoveryParentId ? t("recovery.parent", { parent: activeTab.recoveryParentId }) : "",
|
||||
].filter(Boolean).join(" · ")
|
||||
: "";
|
||||
const sidebarWorkbench = desktopLayoutStyle === "workbench";
|
||||
const windowsFramelessChrome = desktopPlatform === "windows";
|
||||
const handleWindowsTitlebarDoubleClick = useCallback((event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
if (!windowsFramelessChrome) return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (!target?.closest(".app-chrome, .topicbar, .workbench-dock__tools")) return;
|
||||
if (target.closest("button, input, textarea, select, a, [role='button'], [role='tab'], .windows-window-controls")) return;
|
||||
event.preventDefault();
|
||||
void app.ToggleMaximiseMainWindow();
|
||||
}, [windowsFramelessChrome]);
|
||||
// Creation keeps the classic sidebar/chat structure while gating chrome tweaks
|
||||
// behind its own style flag so classic/workbench remain unchanged.
|
||||
const appChromeHidden = sidebarWorkbench || sidebarCreation;
|
||||
@@ -2715,11 +2913,13 @@ export default function App() {
|
||||
<ShellExpandProvider>
|
||||
<ShellHotkeys />
|
||||
<TextSizeHotkeys />
|
||||
<div
|
||||
ref={appRef}
|
||||
className={[
|
||||
<div
|
||||
ref={appRef}
|
||||
onDoubleClickCapture={handleWindowsTitlebarDoubleClick}
|
||||
className={[
|
||||
"app",
|
||||
`app--${desktopPlatform}`,
|
||||
windowsFramelessChrome ? "app--windows-frameless" : "",
|
||||
browserPreviewChrome ? "app--browser-preview" : "",
|
||||
sidebarWorkbench ? "app--workbench" : "",
|
||||
sidebarCreation ? "app--creation" : "",
|
||||
@@ -3240,6 +3440,13 @@ export default function App() {
|
||||
<div className="banner banner--error">{t("topbar.startupError", { msg: state.meta.startupErr })}</div>
|
||||
)}
|
||||
|
||||
{activeTab?.recovered && !sidebarImDetailConnection && (
|
||||
<div className="banner banner--recovery" title={recoveryBannerTitle || undefined}>
|
||||
<span className="banner__badge">{t("recovery.branch")}</span>
|
||||
<span>{t("recovery.banner")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<UpdateBanner enabled={startupUpdateChecksEnabled === true} />
|
||||
|
||||
<main className="main">
|
||||
@@ -3280,7 +3487,14 @@ export default function App() {
|
||||
|
||||
{!sidebarImDetailConnection && (
|
||||
<footer className="footer" ref={footerRef}>
|
||||
{showTodos && <TodoPanel todos={todos} onDismiss={() => setDismissedTodo(todoKey)} />}
|
||||
{showTodos && (
|
||||
<TodoPanel
|
||||
key={scopedTodoBatch}
|
||||
stateKey={scopedTodoBatch}
|
||||
todos={todos}
|
||||
onDismiss={dismissTodos}
|
||||
/>
|
||||
)}
|
||||
{rewindState && (
|
||||
<UndoRewindBanner
|
||||
meta={{
|
||||
@@ -3372,6 +3586,10 @@ export default function App() {
|
||||
retry={state.retry}
|
||||
transientDismissSignal={transientOverlayDismissSignal}
|
||||
sessionKey={composerSessionKey}
|
||||
fileRefRefreshKey={composerFileRefRefreshKey}
|
||||
guidanceConsumedKey={latestGuidanceConsumed?.key}
|
||||
guidanceConsumedText={latestGuidanceConsumed?.text}
|
||||
guidanceQueuePreviewItems={guidanceQueueMockItems}
|
||||
/>
|
||||
<StatusBar
|
||||
context={state.context}
|
||||
@@ -3487,6 +3705,7 @@ export default function App() {
|
||||
onPreviewModeChange={handleWorkspacePreviewModeChange}
|
||||
onAddToChat={addWorkspaceTextToComposer}
|
||||
onRequestPanelWidth={ensureWorkspacePanelWidth}
|
||||
onFileTreeRefresh={refreshComposerFileRefs}
|
||||
refreshKey={dockRefreshKey}
|
||||
initialViewMode={rightDockMode === "changed" ? "changed" : "files"}
|
||||
showViewTabs={false}
|
||||
@@ -3566,6 +3785,7 @@ export default function App() {
|
||||
<HeartbeatPanel open={heartbeatOpen} onClose={() => setHeartbeatOpen(false)} onOpenTopic={(scope, workspaceRoot, topicId) => {
|
||||
void handleOpenTopic(scope, workspaceRoot, topicId);
|
||||
}} />
|
||||
{windowsFramelessChrome && <WindowsWindowControls />}
|
||||
</div>
|
||||
</ShellExpandProvider>
|
||||
);
|
||||
|
||||
@@ -69,6 +69,12 @@ ok(
|
||||
"AppChrome markup includes classic tab strip containers",
|
||||
);
|
||||
|
||||
ok(
|
||||
/const titlebarDragRail = darwinChrome \|\| platform === "windows";/.test(appChromeSource) &&
|
||||
/\{titlebarDragRail && <span className="app-chrome__drag-rail"/.test(appChromeSource),
|
||||
"AppChrome exposes the classic drag rail on macOS and Windows",
|
||||
);
|
||||
|
||||
ok(
|
||||
/const WORKSPACE_PANEL_DEFAULT_OPEN = false;/.test(layoutStoreSource) &&
|
||||
/workspacePanelOpen:\s*WORKSPACE_PANEL_DEFAULT_OPEN/.test(layoutStoreSource),
|
||||
@@ -252,6 +258,37 @@ for (const selector of [
|
||||
);
|
||||
}
|
||||
|
||||
for (const selector of [
|
||||
".app--windows-frameless .app-chrome--native-tabs",
|
||||
":root[data-theme-style] .app--windows-frameless .app-chrome--native-tabs",
|
||||
]) {
|
||||
const paddingRight = finalDeclaration(selector, "padding-right") ?? "";
|
||||
ok(
|
||||
finalDeclaration(selector, "--windows-frameless-titlebar-tools-offset") === "var(--windows-window-controls-safe)" &&
|
||||
paddingRight.includes("--windows-frameless-titlebar-tools-offset") &&
|
||||
paddingRight.includes("--chrome-panel-control-size") &&
|
||||
!paddingRight.includes("--chrome-right-toggle-offset"),
|
||||
`${selector} keeps titlebar tools fixed beside the Windows controls`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const selector of [
|
||||
".app--windows-frameless .app-chrome--native-tabs .app-chrome__panel-toggle--right",
|
||||
":root[data-theme-style] .app--windows-frameless .app-chrome--native-tabs .app-chrome__panel-toggle--right",
|
||||
]) {
|
||||
ok(
|
||||
finalDeclaration(selector, "right") === "calc(var(--windows-frameless-titlebar-tools-offset) + 8px)",
|
||||
`${selector} stays fixed outside the Windows window controls`,
|
||||
);
|
||||
}
|
||||
|
||||
ok(
|
||||
finalDeclaration(".app--windows-frameless:not(.app--workbench):not(.app--creation) .app-chrome--native-tabs .app-chrome__drag-rail", "--wails-draggable") === "drag" &&
|
||||
finalDeclaration(".app--windows-frameless:not(.app--workbench):not(.app--creation) .app-chrome--native-tabs .app-chrome__drag-rail", "right")?.includes("--windows-window-controls-safe") &&
|
||||
finalDeclaration(".app--windows .app-chrome--native-tabs .tabbar", "--wails-draggable") === "no-drag",
|
||||
"Windows classic chrome keeps a draggable rail while tabs remain clickable",
|
||||
);
|
||||
|
||||
for (const selector of [
|
||||
".layout--workbench-chrome-hidden",
|
||||
":root[data-theme-style] .layout--workbench-chrome-hidden",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import gsap from "gsap";
|
||||
import { ApprovalModal } from "../components/ApprovalModal";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
@@ -12,6 +13,17 @@ import type { WireApproval } from "../lib/types";
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
type GsapToOptions = { onComplete?: () => void };
|
||||
const gsapForTests = (typeof gsap.to === "function" ? gsap : (gsap as unknown as { default?: typeof gsap }).default) as unknown as {
|
||||
to?: (target: unknown, vars: GsapToOptions) => unknown;
|
||||
};
|
||||
if (typeof gsapForTests.to === "function") {
|
||||
gsapForTests.to = (_target: unknown, vars: GsapToOptions) => {
|
||||
vars.onComplete?.();
|
||||
return {};
|
||||
};
|
||||
}
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
@@ -196,5 +208,77 @@ console.log("\napproval modal file references");
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
mockApp({
|
||||
ListDir: async () => [],
|
||||
SearchFileRefs: async () => [],
|
||||
});
|
||||
const { root } = await renderApproval({
|
||||
approval: {
|
||||
id: "tool-approval",
|
||||
tool: "bash",
|
||||
subject: "npm run build\n\nRun the build command to verify frontend artifacts.",
|
||||
},
|
||||
});
|
||||
|
||||
const subject = document.querySelector(".approval-subject");
|
||||
ok(subject != null, "tool approval shows its full subject by default");
|
||||
eq(
|
||||
subject?.textContent,
|
||||
"npm run build\n\nRun the build command to verify frontend artifacts.",
|
||||
"default-open tool approval keeps the complete subject visible",
|
||||
);
|
||||
ok(document.body.textContent?.includes("Hide") === true, "tool approval details start expanded");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
mockApp({
|
||||
ListDir: async () => [],
|
||||
SearchFileRefs: async () => [],
|
||||
});
|
||||
const answers: Array<[boolean, boolean, boolean]> = [];
|
||||
const { root } = await renderApproval({
|
||||
approval: {
|
||||
id: "memory-approval",
|
||||
tool: "remember",
|
||||
subject: "Save/update memory \"prefers-vitest\": Preferred test framework",
|
||||
},
|
||||
onAnswer: (allow, session, persist) => answers.push([allow, session, persist]),
|
||||
});
|
||||
|
||||
const text = document.body.textContent ?? "";
|
||||
ok(text.includes("Allow once"), "fresh-human approval shows allow once");
|
||||
ok(text.includes("Deny"), "fresh-human approval shows deny");
|
||||
ok(!text.includes("Allow for session"), "fresh-human approval hides session grant");
|
||||
ok(!text.includes("Always allow"), "fresh-human approval hides persistent grant");
|
||||
eq(
|
||||
Array.from(document.querySelectorAll(".prompt-shelf__actions button")).map((button) => button.textContent).join("|"),
|
||||
"1Allow once|4Deny",
|
||||
"fresh-human approval keeps allow/deny shortcut keys",
|
||||
);
|
||||
|
||||
const allowButton = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.includes("Allow once")) as HTMLButtonElement | undefined;
|
||||
if (!allowButton) throw new Error("allow once button did not render");
|
||||
|
||||
await act(async () => {
|
||||
allowButton.click();
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
eq(JSON.stringify(answers), JSON.stringify([[true, false, false]]), "fresh-human approval allows only once");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -43,7 +43,7 @@ ok(
|
||||
"App loads secondary drawers on demand",
|
||||
);
|
||||
ok(
|
||||
!/import\s+\{[^}]*\b(?:MCPServersSettingsPage|SkillsSettingsPage)\b[^}]*\}\s+from\s+["']\.\/CapabilitiesPanel["']/.test(settingsSource) &&
|
||||
!/import\s+\{[^}]*\b(?:MCPServersSettingsPage|SkillsSettingsPage|PluginsSettingsPage)\b[^}]*\}\s+from\s+["']\.\/CapabilitiesPanel["']/.test(settingsSource) &&
|
||||
!/import\s+\{[^}]*\bMemorySettingsPage\b[^}]*\}\s+from\s+["']\.\/MemoryPanel["']/.test(settingsSource),
|
||||
"SettingsPanel keeps secondary settings pages out of the first settings chunk",
|
||||
);
|
||||
|
||||
@@ -2,11 +2,11 @@ import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MCPServersSettingsPage } from "../components/CapabilitiesPanel";
|
||||
import { MCPServersSettingsPage, PluginsSettingsPage } from "../components/CapabilitiesPanel";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { mcpServerLifecycleActions, mcpServerRetryableFromAvailableList } from "../lib/mcpServerLifecycle";
|
||||
import type { Meta, ServerView, TabMeta } from "../lib/types";
|
||||
import type { Meta, PluginInstallOptions, PluginView, ServerView, TabMeta } from "../lib/types";
|
||||
|
||||
function ok(value: unknown, message: string) {
|
||||
if (!value) throw new Error(message);
|
||||
@@ -80,6 +80,7 @@ function installDom() {
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.HTMLButtonElement = dom.window.HTMLButtonElement;
|
||||
globalThis.HTMLInputElement = dom.window.HTMLInputElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
@@ -106,6 +107,16 @@ function findButton(label: string): HTMLButtonElement | undefined {
|
||||
return Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === label) as HTMLButtonElement | undefined;
|
||||
}
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const win = input.ownerDocument.defaultView;
|
||||
const setter = Object.getOwnPropertyDescriptor((win?.HTMLInputElement ?? HTMLInputElement).prototype, "value")?.set;
|
||||
setter?.call(input, value);
|
||||
const eventCtor = win?.Event ?? Event;
|
||||
const inputEventCtor = win?.InputEvent ?? eventCtor;
|
||||
input.dispatchEvent(new inputEventCtor("input", { bubbles: true, data: value, inputType: "insertText" } as InputEventInit));
|
||||
input.dispatchEvent(new eventCtor("change", { bubbles: true }));
|
||||
}
|
||||
|
||||
console.log("capabilities panel MCP actions");
|
||||
|
||||
{
|
||||
@@ -233,3 +244,221 @@ console.log("capabilities panel MCP actions");
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
console.log("capabilities panel plugin actions");
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
const meta: Meta = { label: "test", ready: true, eventChannel: "plugin-channel", cwd: "/tmp/reasonix-test", workspaceRoot: "/tmp/reasonix-test" };
|
||||
const tabs: TabMeta[] = [{
|
||||
id: "tab-plugin",
|
||||
scope: "project",
|
||||
workspaceRoot: "/tmp/reasonix-test",
|
||||
workspaceName: "reasonix-test",
|
||||
topicId: "topic-plugin",
|
||||
topicTitle: "Plugins",
|
||||
label: "Plugins",
|
||||
ready: true,
|
||||
running: false,
|
||||
mode: "normal",
|
||||
toolApprovalMode: "auto",
|
||||
active: true,
|
||||
cwd: "/tmp/reasonix-test",
|
||||
}];
|
||||
let planCalls = 0;
|
||||
let installCalls = 0;
|
||||
let toggleCalls = 0;
|
||||
let updateCalls = 0;
|
||||
let doctorCalls = 0;
|
||||
let removeCalls = 0;
|
||||
let pickFolderCalls = 0;
|
||||
const plannedSources: string[] = [];
|
||||
const installedSources: string[] = [];
|
||||
let plugins: PluginView[] = [{
|
||||
name: "superpowers",
|
||||
version: "0.1.0",
|
||||
description: "Shared agent skills and hooks.",
|
||||
source: "git:github.com/obra/superpowers",
|
||||
root: "~/.reasonix/plugins/superpowers",
|
||||
manifestKind: "reasonix",
|
||||
enabled: true,
|
||||
skills: 2,
|
||||
hooks: 1,
|
||||
mcpServers: 0,
|
||||
}];
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
Meta: async () => meta,
|
||||
ListTabs: async () => tabs,
|
||||
Plugins: async () => plugins.map((plugin) => ({ ...plugin, warnings: [...(plugin.warnings ?? [])] })),
|
||||
PlanPluginInstall: async (source: string, options: PluginInstallOptions) => {
|
||||
planCalls += 1;
|
||||
plannedSources.push(source);
|
||||
ok(options.dryRun === true, "plugin preview asks for dry-run planning");
|
||||
return JSON.stringify({
|
||||
ok: true,
|
||||
status: "planned",
|
||||
name: "superpowers",
|
||||
actions: [{ kind: "plugin", action: "install_plugin_package", name: "superpowers", source, status: "planned" }],
|
||||
});
|
||||
},
|
||||
InstallPlugin: async (source: string, _options: PluginInstallOptions) => {
|
||||
installCalls += 1;
|
||||
installedSources.push(source);
|
||||
const next: PluginView = {
|
||||
name: "superpowers",
|
||||
version: "0.1.1",
|
||||
description: "Shared agent skills and hooks.",
|
||||
source,
|
||||
root: "~/.reasonix/plugins/superpowers",
|
||||
manifestKind: "reasonix",
|
||||
enabled: true,
|
||||
skills: 3,
|
||||
hooks: 1,
|
||||
mcpServers: 1,
|
||||
skillDetails: [{ name: "plan", description: "Plan work before implementation.", invocation: "/plan", runAs: "inline" }],
|
||||
hookDetails: [{ event: "SessionStart", contextFile: "CLAUDE.md", description: "Load startup context." }],
|
||||
mcpServerDetails: [{ name: "context", transport: "stdio", command: "node server.js" }],
|
||||
};
|
||||
plugins = plugins.filter((plugin) => plugin.name !== next.name).concat(next);
|
||||
return JSON.stringify({ ok: true, status: "done", actions: [{ action: "install_plugin_package", name: next.name, status: "done" }] });
|
||||
},
|
||||
SetPluginEnabled: async (name: string, enabled: boolean) => {
|
||||
toggleCalls += 1;
|
||||
plugins = plugins.map((plugin) => plugin.name === name ? { ...plugin, enabled } : plugin);
|
||||
},
|
||||
UpdatePlugin: async (name: string) => {
|
||||
updateCalls += 1;
|
||||
plugins = plugins.map((plugin) => plugin.name === name ? { ...plugin, version: "0.1.2" } : plugin);
|
||||
return JSON.stringify({ ok: true, status: "done", name });
|
||||
},
|
||||
PluginDoctor: async (name: string) => {
|
||||
doctorCalls += 1;
|
||||
return { ...(plugins.find((plugin) => plugin.name === name) ?? plugins[0]), warnings: ["manifest exports no MCP auth metadata"] };
|
||||
},
|
||||
RemovePlugin: async (name: string) => {
|
||||
removeCalls += 1;
|
||||
plugins = plugins.filter((plugin) => plugin.name !== name);
|
||||
},
|
||||
PickPluginFolder: async () => {
|
||||
pickFolderCalls += 1;
|
||||
return "/tmp/superpowers-plugin";
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(LocaleProvider, null, React.createElement(PluginsSettingsPage)));
|
||||
await flush();
|
||||
});
|
||||
await waitFor("superpowers plugin row", () => Boolean(document.querySelector(".cap-row__name")?.textContent?.includes("superpowers")));
|
||||
ok(Boolean(document.querySelector(".cap-plugin-form-grid .cap-plugin-fields--local")), "local plugin install mode uses the shared form grid");
|
||||
const localOptionTexts = Array.from(document.querySelectorAll(".cap-plugin-installer__options > .cap-plugin-option-block"))
|
||||
.map((option) => option.textContent ?? "");
|
||||
ok(localOptionTexts[0]?.includes("Overwrite same-name plugin"), "local install mode shows overwrite before link mode");
|
||||
ok(localOptionTexts[1]?.includes("Developer mode: link source folder"), "local install mode shows link mode after overwrite");
|
||||
|
||||
const chooseFolder = findButton("Choose plugin folder");
|
||||
if (!chooseFolder) throw new Error("missing plugin folder picker button");
|
||||
await act(async () => {
|
||||
chooseFolder.click();
|
||||
await flush();
|
||||
});
|
||||
await waitFor("picked plugin folder source", () => document.body.textContent?.includes("/tmp/superpowers-plugin") ?? false);
|
||||
ok(pickFolderCalls === 1, "clicking Choose folder invokes the plugin folder picker once");
|
||||
|
||||
const gitMode = findButton("Git repository");
|
||||
if (!gitMode) throw new Error("missing Git repository install mode");
|
||||
await act(async () => {
|
||||
gitMode.click();
|
||||
await flush();
|
||||
});
|
||||
ok(Boolean(document.querySelector(".cap-plugin-form-grid .cap-plugin-fields--git")), "Git plugin install mode uses the shared form grid");
|
||||
const sourceInput = document.querySelector<HTMLInputElement>('input[aria-label="Git repository URL"]');
|
||||
if (!sourceInput) throw new Error("missing plugin git source input");
|
||||
await act(async () => {
|
||||
setInputValue(sourceInput, "git:github.com/obra/superpowers");
|
||||
await flush();
|
||||
});
|
||||
await waitFor("plugin preview enabled", () => findButton("Preview")?.disabled === false);
|
||||
|
||||
const preview = findButton("Preview");
|
||||
if (!preview) throw new Error("missing plugin preview button");
|
||||
await act(async () => {
|
||||
preview.click();
|
||||
await flush();
|
||||
});
|
||||
await waitFor("plugin install plan", () => document.body.textContent?.includes("install_plugin_package") ?? false);
|
||||
ok(planCalls === 1, "clicking Preview invokes plugin install planning once");
|
||||
ok(plannedSources[0] === "git:github.com/obra/superpowers", "plugin preview receives the entered Git source");
|
||||
|
||||
const install = findButton("Install plugin");
|
||||
if (!install) throw new Error("missing plugin install button");
|
||||
await act(async () => {
|
||||
install.click();
|
||||
await flush();
|
||||
});
|
||||
await waitFor("plugin install result", () => installCalls === 1 && plugins[0]?.version === "0.1.1");
|
||||
ok(installedSources[0] === "git:github.com/obra/superpowers", "plugin install receives the entered Git source");
|
||||
|
||||
const disclosure = document.querySelector<HTMLButtonElement>(".cap-plugin-entry .cap-disclosure");
|
||||
if (!disclosure) throw new Error("missing plugin disclosure");
|
||||
await act(async () => {
|
||||
disclosure.click();
|
||||
await flush();
|
||||
});
|
||||
await waitFor("plugin update action", () => Boolean(findButton("Update")));
|
||||
ok(document.body.textContent?.includes("How to use") ?? false, "expanded plugin details explain how to use the plugin");
|
||||
ok(document.body.textContent?.includes("/plan") ?? false, "expanded plugin details list exported skill invocations");
|
||||
ok(document.body.textContent?.includes("SessionStart") ?? false, "expanded plugin details list exported hooks");
|
||||
ok(document.body.textContent?.includes("context") ?? false, "expanded plugin details list exported MCP servers");
|
||||
|
||||
const update = findButton("Update");
|
||||
if (!update) throw new Error("missing plugin update button");
|
||||
await act(async () => {
|
||||
update.click();
|
||||
await flush();
|
||||
});
|
||||
await waitFor("plugin update call", () => updateCalls === 1 && plugins[0]?.version === "0.1.2");
|
||||
|
||||
const doctor = findButton("Doctor");
|
||||
if (!doctor) throw new Error("missing plugin doctor button");
|
||||
await act(async () => {
|
||||
doctor.click();
|
||||
await flush();
|
||||
});
|
||||
await waitFor("plugin diagnostic warning", () => document.body.textContent?.includes("manifest exports no MCP auth metadata") ?? false);
|
||||
ok(doctorCalls === 1, "clicking Doctor invokes plugin diagnostics once");
|
||||
|
||||
const toggle = document.querySelector<HTMLInputElement>(".cap-plugin-entry .cap-switch input");
|
||||
if (!toggle) throw new Error("missing plugin enable toggle");
|
||||
await act(async () => {
|
||||
toggle.click();
|
||||
await flush();
|
||||
});
|
||||
await waitFor("plugin disabled", () => toggleCalls === 1 && plugins[0]?.enabled === false);
|
||||
|
||||
const remove = findButton("Remove plugin");
|
||||
if (!remove) throw new Error("missing plugin remove button");
|
||||
await act(async () => {
|
||||
remove.click();
|
||||
await flush();
|
||||
});
|
||||
const confirmRemove = findButton("Confirm remove");
|
||||
if (!confirmRemove) throw new Error("missing plugin confirm remove button");
|
||||
await act(async () => {
|
||||
confirmRemove.click();
|
||||
await flush();
|
||||
});
|
||||
await waitFor("plugin removed", () => removeCalls === 1 && plugins.length === 0);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { Composer, composerPickFileEntry } from "../components/Composer";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { ToastProvider } from "../lib/toast";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import type { CollaborationMode, ToolApprovalMode, TokenMode } from "../lib/types";
|
||||
import type { CollaborationMode, DirEntry, ToolApprovalMode, TokenMode } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -200,6 +200,16 @@ async function waitFor(label: string, predicate: () => boolean) {
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
type RenderedComposer = Awaited<ReturnType<typeof renderComposer>>;
|
||||
|
||||
function fileEntry(name: string): DirEntry {
|
||||
return { name, isDir: false };
|
||||
}
|
||||
|
||||
async function replaceComposerDraft(rerender: RenderedComposer["rerender"], id: number, text: string) {
|
||||
await rerender({ insertRequest: { id, text, mode: "replace" } });
|
||||
}
|
||||
|
||||
console.log("\ncomposer goal toggle");
|
||||
|
||||
{
|
||||
@@ -568,5 +578,327 @@ console.log("\ncomposer goal toggle");
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const { root, rerender } = await renderComposer({
|
||||
running: true,
|
||||
guidanceQueuePreviewItems: ["confirm the send lifecycle", "keep steer protocol unchanged", "add a hanging submit regression"],
|
||||
});
|
||||
|
||||
let guidanceItems = Array.from(document.querySelectorAll(".composer-guidance-item"));
|
||||
eq(guidanceItems.length, 2, "running guidance preview shows a compact queue preview");
|
||||
ok(guidanceItems[0]?.textContent?.includes("confirm the send lifecycle") === true, "guidance preview shows the first seeded item");
|
||||
ok(guidanceItems[1]?.textContent?.includes("keep steer protocol unchanged") === true, "guidance preview shows the second seeded item");
|
||||
eq(document.querySelectorAll(".composer-guidance-item__guide").length, 2, "guidance preview exposes a guide action for each visible item");
|
||||
let guidanceMore = document.querySelector(".composer-guidance-more") as HTMLButtonElement | null;
|
||||
ok(guidanceMore?.textContent?.includes("1 more queued") === true, "guidance preview summarizes overflow items");
|
||||
eq(guidanceMore?.getAttribute("aria-expanded"), "false", "guidance overflow starts collapsed");
|
||||
|
||||
if (!guidanceMore) throw new Error("guidance overflow button did not render");
|
||||
await act(async () => {
|
||||
guidanceMore.click();
|
||||
await flushTimers();
|
||||
});
|
||||
guidanceItems = Array.from(document.querySelectorAll(".composer-guidance-item"));
|
||||
eq(guidanceItems.length, 3, "guidance overflow expands the remaining queued items");
|
||||
ok(guidanceItems[2]?.textContent?.includes("add a hanging submit regression") === true, "expanded guidance preview shows the hidden item");
|
||||
guidanceMore = document.querySelector(".composer-guidance-more") as HTMLButtonElement | null;
|
||||
ok(guidanceMore?.textContent?.includes("Collapse") === true, "expanded guidance overflow can be collapsed");
|
||||
eq(guidanceMore?.getAttribute("aria-expanded"), "true", "guidance overflow reports expanded state");
|
||||
|
||||
if (!guidanceMore) throw new Error("guidance collapse button did not render");
|
||||
await act(async () => {
|
||||
guidanceMore.click();
|
||||
await flushTimers();
|
||||
});
|
||||
guidanceItems = Array.from(document.querySelectorAll(".composer-guidance-item"));
|
||||
eq(guidanceItems.length, 2, "guidance overflow collapses back to the compact preview");
|
||||
|
||||
await rerender({ guidanceQueuePreviewItems: ["only the latest preview seed"] });
|
||||
guidanceItems = Array.from(document.querySelectorAll(".composer-guidance-item"));
|
||||
eq(guidanceItems.length, 1, "guidance preview refreshes when the seed changes");
|
||||
ok(guidanceItems[0]?.textContent?.includes("only the latest preview seed") === true, "guidance preview renders the refreshed seed");
|
||||
|
||||
await rerender({ running: false });
|
||||
ok(document.querySelector(".composer-guidance-item") === null, "guidance preview clears when the mock turn stops");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const { root, calls, rerender } = await renderComposer({
|
||||
running: true,
|
||||
onSend: (displayText, submitText) => {
|
||||
calls.send.push(displayText);
|
||||
calls.submit.push(submitText);
|
||||
},
|
||||
});
|
||||
|
||||
const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
|
||||
if (!textarea) throw new Error("composer textarea did not render");
|
||||
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
|
||||
if (!sendButton) throw new Error("running composer send button did not render");
|
||||
|
||||
eq(textarea.placeholder, "Add guidance to the queue...", "running composer explains queued guidance input");
|
||||
ok(sendButton.classList.contains("composer__btn--steer"), "running composer marks send button as steer");
|
||||
ok(sendButton.disabled === true, "running steer button stays disabled without input");
|
||||
|
||||
await rerender({ insertRequest: { id: 4, text: "keep the files small", mode: "replace" } });
|
||||
ok(sendButton.disabled === false, "running steer button enables after text input");
|
||||
|
||||
await act(async () => {
|
||||
sendButton.click();
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
eq(calls.send.join(","), "", "running composer queues guidance without sending it immediately");
|
||||
eq(textarea.value, "", "queued running guidance clears the composer draft");
|
||||
const guidanceItem = document.querySelector(".composer-guidance-item") as HTMLElement | null;
|
||||
if (!guidanceItem) throw new Error("running guidance chip did not render");
|
||||
ok(guidanceItem.textContent?.includes("keep the files small") === true, "running guidance chip shows queued text");
|
||||
ok(document.querySelector(".composer-guidance-head")?.textContent?.includes("Queued guidance 1") === true, "running guidance shelf shows queued count");
|
||||
|
||||
const guideButton = guidanceItem.querySelector(".composer-guidance-item__guide") as HTMLButtonElement | null;
|
||||
if (!guideButton) throw new Error("running guidance guide button did not render");
|
||||
await act(async () => {
|
||||
guideButton.click();
|
||||
await flushTimers();
|
||||
});
|
||||
eq(calls.send.join(","), "keep the files small", "queued guidance sends through onSend when guided");
|
||||
ok(document.querySelector(".composer-guidance-item") === null, "queued guidance clears after being guided");
|
||||
|
||||
await rerender({ insertRequest: { id: 5, text: "prefer the smaller diff", mode: "replace" } });
|
||||
await act(async () => {
|
||||
sendButton.click();
|
||||
await flushTimers();
|
||||
});
|
||||
const dismissibleGuidanceItem = document.querySelector(".composer-guidance-item") as HTMLElement | null;
|
||||
if (!dismissibleGuidanceItem) throw new Error("dismissible guidance chip did not render");
|
||||
const dismissButton = dismissibleGuidanceItem.querySelector(".composer-guidance-item__action") as HTMLButtonElement | null;
|
||||
if (!dismissButton) throw new Error("running guidance dismiss button did not render");
|
||||
await act(async () => {
|
||||
dismissButton.click();
|
||||
await flushTimers();
|
||||
});
|
||||
ok(document.querySelector(".composer-guidance-item") === null, "running guidance chip can be dismissed");
|
||||
|
||||
await rerender({ insertRequest: { id: 6, text: "prefer the smaller diff", mode: "replace" } });
|
||||
await act(async () => {
|
||||
sendButton.click();
|
||||
await flushTimers();
|
||||
});
|
||||
ok(document.querySelector(".composer-guidance-item") !== null, "running guidance chip renders again after another queued item");
|
||||
|
||||
await rerender({ guidanceConsumedKey: "s1", guidanceConsumedText: "prefer the smaller diff" });
|
||||
ok(document.querySelector(".composer-guidance-item") === null, "running guidance chip clears when steer is consumed");
|
||||
|
||||
await rerender({ insertRequest: { id: 7, text: "then stop showing the chip", mode: "replace" } });
|
||||
await act(async () => {
|
||||
sendButton.click();
|
||||
await flushTimers();
|
||||
});
|
||||
ok(document.querySelector(".composer-guidance-item") !== null, "running guidance chip renders before turn stop");
|
||||
|
||||
await rerender({ running: false });
|
||||
ok(document.querySelector(".composer-guidance-item") === null, "running guidance chip clears when the turn stops");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const { root, calls, rerender } = await renderComposer({
|
||||
running: true,
|
||||
submitDisabled: true,
|
||||
onSend: (displayText) => {
|
||||
calls.send.push(displayText);
|
||||
},
|
||||
});
|
||||
|
||||
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
|
||||
if (!sendButton) throw new Error("running composer send button did not render");
|
||||
|
||||
await rerender({ insertRequest: { id: 7, text: "steer while activating", mode: "replace" } });
|
||||
ok(sendButton.disabled === false, "running guidance queue ignores controller submitDisabled");
|
||||
|
||||
await act(async () => {
|
||||
sendButton.click();
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
eq(calls.send.join(","), "", "running guidance queues while controllerReady is false");
|
||||
const guideButton = document.querySelector(".composer-guidance-item__guide") as HTMLButtonElement | null;
|
||||
if (!guideButton) throw new Error("running guidance guide button did not render");
|
||||
await act(async () => {
|
||||
guideButton.click();
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
eq(calls.send.join(","), "steer while activating", "queued guidance can be guided while controllerReady is false");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
let listDirCalls = 0;
|
||||
mockApp({
|
||||
ListDir: async () => {
|
||||
listDirCalls += 1;
|
||||
return listDirCalls === 1 ? [fileEntry("cached-dir.txt")] : [fileEntry("fresh-dir.txt")];
|
||||
},
|
||||
SearchFileRefs: async () => [],
|
||||
});
|
||||
const { root, rerender } = await renderComposer();
|
||||
|
||||
await replaceComposerDraft(rerender, 101, "@");
|
||||
await waitFor("initial @ directory load", () => listDirCalls === 1);
|
||||
|
||||
await replaceComposerDraft(rerender, 102, "");
|
||||
await replaceComposerDraft(rerender, 103, "@");
|
||||
await waitFor("@ directory revalidation call", () => listDirCalls === 2);
|
||||
|
||||
eq(listDirCalls, 2, "@ directory cache hit still revalidates ListDir");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
let listDirCalls = 0;
|
||||
mockApp({
|
||||
ListDir: async () => {
|
||||
listDirCalls += 1;
|
||||
return listDirCalls === 1 ? [fileEntry("manual-refresh-stale.txt")] : [fileEntry("manual-refresh-fresh.txt")];
|
||||
},
|
||||
SearchFileRefs: async () => [],
|
||||
});
|
||||
const { root, rerender } = await renderComposer({ fileRefRefreshKey: "0" });
|
||||
|
||||
await replaceComposerDraft(rerender, 201, "@");
|
||||
await waitFor("initial @ directory load before refresh key", () => listDirCalls === 1);
|
||||
|
||||
await rerender({ fileRefRefreshKey: "1" });
|
||||
await waitFor("@ directory reload after refresh key", () => listDirCalls === 2);
|
||||
|
||||
eq(listDirCalls, 2, "fileRefRefreshKey refreshes @ directory cache while the menu is open");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const realDateNow = Date.now;
|
||||
let now = 1000;
|
||||
let searchCalls = 0;
|
||||
Date.now = () => now;
|
||||
mockApp({
|
||||
ListDir: async () => [],
|
||||
SearchFileRefs: async () => {
|
||||
searchCalls += 1;
|
||||
return searchCalls === 1 ? [fileEntry("alpha-old.ts")] : [fileEntry("alpha-new.ts")];
|
||||
},
|
||||
});
|
||||
const { root, rerender } = await renderComposer();
|
||||
|
||||
try {
|
||||
await replaceComposerDraft(rerender, 301, "@alpha");
|
||||
await waitFor("initial @ search request", () => searchCalls === 1);
|
||||
eq(searchCalls, 1, "@ search fetches the first query");
|
||||
|
||||
await replaceComposerDraft(rerender, 302, "");
|
||||
now = 2000;
|
||||
await replaceComposerDraft(rerender, 303, "@alpha");
|
||||
await act(async () => {
|
||||
await flushTimers();
|
||||
});
|
||||
eq(searchCalls, 1, "@ search cache is reused inside the TTL");
|
||||
|
||||
await replaceComposerDraft(rerender, 304, "");
|
||||
now = 7001;
|
||||
await replaceComposerDraft(rerender, 305, "@alpha");
|
||||
await waitFor("expired @ search cache refresh", () => searchCalls === 2);
|
||||
eq(searchCalls, 2, "@ search cache revalidates after the TTL");
|
||||
} finally {
|
||||
Date.now = realDateNow;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
let staleListDirResolve: ((entries: DirEntry[]) => void) | undefined;
|
||||
let thirdListDirResolve: ((entries: DirEntry[]) => void) | undefined;
|
||||
let listDirCalls = 0;
|
||||
mockApp({
|
||||
ListDir: async () => {
|
||||
listDirCalls += 1;
|
||||
if (listDirCalls === 1) {
|
||||
return new Promise<DirEntry[]>((resolve) => {
|
||||
staleListDirResolve = resolve;
|
||||
});
|
||||
}
|
||||
if (listDirCalls === 2) return [fileEntry("cache-live.txt")];
|
||||
return new Promise<DirEntry[]>((resolve) => {
|
||||
thirdListDirResolve = resolve;
|
||||
});
|
||||
},
|
||||
SearchFileRefs: async () => [],
|
||||
});
|
||||
const { root, rerender } = await renderComposer({ fileRefRefreshKey: "0" });
|
||||
|
||||
const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
|
||||
if (!textarea) throw new Error("composer textarea did not render");
|
||||
|
||||
await replaceComposerDraft(rerender, 401, "@cache");
|
||||
await waitFor("initial stale @ directory request", () => listDirCalls === 1);
|
||||
|
||||
await rerender({ fileRefRefreshKey: "1" });
|
||||
await waitFor("fresh @ directory request after refresh key", () => listDirCalls === 2);
|
||||
await act(async () => {
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
staleListDirResolve?.([fileEntry("cache-stale.txt")]);
|
||||
await act(async () => {
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
await replaceComposerDraft(rerender, 402, "");
|
||||
await replaceComposerDraft(rerender, 403, "@cache");
|
||||
await waitFor("second fresh @ directory request", () => listDirCalls === 3);
|
||||
await act(async () => {
|
||||
textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(textarea.value, "@cache-live.txt ", "stale @ directory request cannot repopulate cache after refresh");
|
||||
thirdListDirResolve?.([fileEntry("cache-later.txt")]);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -5,6 +5,7 @@ import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Composer } from "../components/Composer";
|
||||
import { UserMessage } from "../components/Message";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { ToastProvider } from "../lib/toast";
|
||||
import type { CollaborationMode, TokenMode, ToolApprovalMode } from "../lib/types";
|
||||
@@ -137,7 +138,9 @@ async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<ToastProvider>
|
||||
<Composer {...currentProps} />
|
||||
<div className="chat-pane">
|
||||
<Composer {...currentProps} />
|
||||
</div>
|
||||
</ToastProvider>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
@@ -183,6 +186,29 @@ function imagePasteEvent(file: File): Event {
|
||||
return event;
|
||||
}
|
||||
|
||||
function imageViewerOpen(): boolean {
|
||||
return Boolean(document.querySelector(".image-viewer-backdrop .image-viewer__image"));
|
||||
}
|
||||
|
||||
function renderUserMessage(text: string, props: Partial<Parameters<typeof UserMessage>[0]> = {}) {
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
const paint = async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<div className="chat-pane">
|
||||
<UserMessage text={text} {...props} />
|
||||
</div>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushTimers();
|
||||
});
|
||||
};
|
||||
return { root, paint };
|
||||
}
|
||||
|
||||
console.log("\ncomposer image capability");
|
||||
|
||||
{
|
||||
@@ -255,5 +281,107 @@ console.log("\ncomposer image capability");
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
installBridgeApp({
|
||||
SavePastedImage: async () => ".reasonix/attachments/mock.png",
|
||||
AttachmentDataURL: async () => "data:image/png;base64,iVBORw0KGgo=",
|
||||
});
|
||||
const { root } = await renderComposer({ imageInputEnabled: true });
|
||||
const file = new File(["img"], "photo.png", { type: "image/png", lastModified: 1 });
|
||||
|
||||
await act(async () => {
|
||||
textarea().dispatchEvent(imagePasteEvent(file));
|
||||
await flushTimers();
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor(() => Boolean(document.querySelector(".composer-context__thumb img")));
|
||||
const thumb = document.querySelector(".composer-context__thumb") as HTMLElement | null;
|
||||
if (!thumb) throw new Error("missing composer image thumbnail");
|
||||
await act(async () => {
|
||||
thumb.click();
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor(imageViewerOpen);
|
||||
ok(imageViewerOpen(), "composer image thumbnail opens the image viewer");
|
||||
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor(() => !document.querySelector(".image-viewer-backdrop"));
|
||||
ok(!document.querySelector(".image-viewer-backdrop"), "composer image viewer closes on Escape");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
installBridgeApp({
|
||||
AttachmentDataURL: async () => "data:image/png;base64,iVBORw0KGgo=",
|
||||
});
|
||||
const { root, paint } = renderUserMessage("check @[photo.png](.reasonix/attachments/mock.png)");
|
||||
await paint();
|
||||
await waitFor(() => Boolean(document.querySelector(".msg-attachment--image img")));
|
||||
const thumb = document.querySelector(".msg-attachment--image") as HTMLElement | null;
|
||||
if (!thumb) throw new Error("missing message image thumbnail");
|
||||
await act(async () => {
|
||||
thumb.click();
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor(() => Boolean(document.querySelector(".chat-pane > .image-viewer-backdrop")));
|
||||
ok(Boolean(document.querySelector(".chat-pane > .image-viewer-backdrop")), "sent message image preview portals into the chat pane");
|
||||
|
||||
const close = document.querySelector(".image-viewer__close") as HTMLButtonElement | null;
|
||||
if (!close) throw new Error("missing image viewer close button");
|
||||
await act(async () => {
|
||||
close.click();
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor(() => !document.querySelector(".image-viewer-backdrop"));
|
||||
ok(!document.querySelector(".image-viewer-backdrop"), "sent message image viewer closes from the close button");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
installBridgeApp({
|
||||
AttachmentDataURL: async () => "data:image/png;base64,iVBORw0KGgo=",
|
||||
});
|
||||
const { root, paint } = renderUserMessage("check @[photo.png](.reasonix/attachments/mock.png)", {
|
||||
turn: 1,
|
||||
onEdit: () => true,
|
||||
});
|
||||
await paint();
|
||||
await waitFor(() => Boolean(document.querySelector(".msg-attachment--image img")));
|
||||
const edit = document.querySelector("button.msg-meta__btn:not(.msg-meta__copy)") as HTMLButtonElement | null;
|
||||
if (!edit) throw new Error("missing message edit button");
|
||||
await act(async () => {
|
||||
edit.click();
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor(() => Boolean(document.querySelector(".msg-edit .composer-context__thumb img")));
|
||||
const thumb = document.querySelector(".msg-edit .composer-context__thumb") as HTMLElement | null;
|
||||
if (!thumb) throw new Error("missing edit image thumbnail");
|
||||
await act(async () => {
|
||||
thumb.click();
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor(imageViewerOpen);
|
||||
ok(imageViewerOpen(), "edit message image thumbnail opens the image viewer");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -1,6 +1,6 @@
|
||||
// Run: tsx src/__tests__/context-panel-breakdown.test.ts
|
||||
|
||||
import { contextBreakdown, contextCostDisplay, contextSourceRows, formatCacheHitRate, formatMetricTokens } from "../components/ContextPanel";
|
||||
import { cacheHitTone, contextBreakdown, contextCostDisplay, contextSourceRows, contextUsageRefreshKey, contextWindowStatus, formatCacheHitRate, formatMetricTokens } from "../components/ContextPanel";
|
||||
import { currencySymbol, formatMoney, formatMoneyLocalized } from "../lib/money";
|
||||
|
||||
let passed = 0;
|
||||
@@ -49,7 +49,7 @@ eq(
|
||||
42_124,
|
||||
"legend values sum to used context tokens",
|
||||
);
|
||||
eq(Math.round(mock.otherPct), 33, "donut endpoint follows used/window percent");
|
||||
eq(Math.round(mock.otherPct), 33, "usage endpoint follows used/window percent");
|
||||
|
||||
const issue5283 = contextBreakdown(6888, 1_000_000, 6840, 48, 48);
|
||||
eq(
|
||||
@@ -90,9 +90,16 @@ eq(
|
||||
reasoningPct: 0,
|
||||
otherPct: 0,
|
||||
},
|
||||
"unknown context window keeps donut segments empty",
|
||||
"unknown context window keeps usage segments empty",
|
||||
);
|
||||
|
||||
console.log("\ncontext window status");
|
||||
|
||||
eq(contextWindowStatus(33, 80), { tone: "good", key: "context.windowStatusHealthy" }, "low usage stays healthy");
|
||||
eq(contextWindowStatus(72, 80), { tone: "notice", key: "context.windowStatusWatch" }, "usage near compact threshold warns early");
|
||||
eq(contextWindowStatus(80, 80), { tone: "warn", key: "context.windowStatusPastCompact" }, "compact threshold reached takes warning tone");
|
||||
eq(contextWindowStatus(91, 80), { tone: "warn", key: "context.windowStatusNearLimit" }, "near hard limit overrides compact status");
|
||||
|
||||
console.log("\ncontext panel cost");
|
||||
|
||||
const infoCost = contextCostDisplay({
|
||||
@@ -118,6 +125,32 @@ console.log("\ncontext panel cache rate");
|
||||
eq(formatCacheHitRate(99_950, 50), "99.95%", "cache hit rate preserves two decimal places");
|
||||
eq(formatCacheHitRate(0, 10_000), "0.00%", "cache hit rate shows zero when usage data exists");
|
||||
eq(formatCacheHitRate(0, 0), "-", "cache hit rate stays empty before usage data exists");
|
||||
eq(cacheHitTone(8700, 1300), "good", "healthy cache hit rate uses positive tone");
|
||||
eq(cacheHitTone(6000, 4000), "notice", "mid cache hit rate uses notice tone");
|
||||
eq(cacheHitTone(5999, 4001), "warn", "low cache hit rate uses warning tone");
|
||||
eq(cacheHitTone(0, 0), undefined, "missing cache data stays uncolored");
|
||||
|
||||
console.log("\ncontext panel usage refresh key");
|
||||
|
||||
eq(contextUsageRefreshKey(undefined), "", "missing usage does not request a streaming refresh");
|
||||
ok(
|
||||
contextUsageRefreshKey({
|
||||
totalTokens: 10,
|
||||
promptTokens: 10,
|
||||
completionTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
sessionCacheHitTokens: 0,
|
||||
sessionCacheMissTokens: 0,
|
||||
}) !== contextUsageRefreshKey({
|
||||
totalTokens: 11,
|
||||
promptTokens: 10,
|
||||
completionTokens: 1,
|
||||
reasoningTokens: 0,
|
||||
sessionCacheHitTokens: 0,
|
||||
sessionCacheMissTokens: 0,
|
||||
}),
|
||||
"general token changes refresh even when cache counters stay unchanged",
|
||||
);
|
||||
|
||||
console.log("\ncontext panel source rows");
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Run: tsx src/__tests__/message-pasted-blocks.test.ts
|
||||
|
||||
import { parsePastedBlocks } from "../components/Message";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function eq(a: unknown, b: unknown, label: string) {
|
||||
if (JSON.stringify(a) === JSON.stringify(b)) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function wrapped(label: string, content: string): string {
|
||||
return `${label}\n\n--- Begin ${label} ---\n${content}\n--- End ${label} ---`;
|
||||
}
|
||||
|
||||
console.log("\nmessage pasted blocks");
|
||||
|
||||
for (const [label, name] of [
|
||||
["[已粘贴文本 #2 · 31 行]", "Simplified Chinese"],
|
||||
["[已貼上文字 #2 · 31 行]", "Traditional Chinese"],
|
||||
["[Pasted text #2 · 31 lines]", "English"],
|
||||
] as const) {
|
||||
eq(
|
||||
parsePastedBlocks(`before\n${label}\nafter`, wrapped(label, "line 1\nline 2")),
|
||||
[{ label, content: "line 1\nline 2\n" }],
|
||||
`${name} pasted text labels are parsed from submit text`,
|
||||
);
|
||||
}
|
||||
|
||||
eq(parsePastedBlocks("[unknown paste #1]", "--- Begin [unknown paste #1] ---\nnope\n--- End [unknown paste #1] ---"), [], "unknown labels are ignored");
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -74,7 +74,7 @@ function tabMeta(overrides: Partial<TabMeta> = {}): TabMeta {
|
||||
};
|
||||
}
|
||||
|
||||
function meta(): Meta {
|
||||
function meta(overrides: Partial<Meta> = {}): Meta {
|
||||
return {
|
||||
label: "model",
|
||||
ready: true,
|
||||
@@ -91,6 +91,7 @@ function meta(): Meta {
|
||||
tokenMode: "full",
|
||||
goal: "",
|
||||
goalStatus: "stopped",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -211,10 +212,14 @@ await act(async () => {
|
||||
});
|
||||
|
||||
const guardedStartupTabs = deferred<TabMeta[]>();
|
||||
let ensureBlankSurfaceCalls = 0;
|
||||
const staleProjectA = "/repo/project-a";
|
||||
const targetProjectB = "/repo/project-b";
|
||||
const ensureBlankSurfaceCalls: Array<{ scope: string; workspaceRoot: string }> = [];
|
||||
window.go.main.App = {
|
||||
ListTabs: async () => guardedStartupTabs.promise,
|
||||
MetaForTab: async () => meta(),
|
||||
MetaForTab: async (tabID: string) => tabID === "tab-new"
|
||||
? meta({ cwd: targetProjectB, workspaceRoot: targetProjectB, workspaceName: "project-b", workspacePath: targetProjectB })
|
||||
: meta({ cwd: staleProjectA, workspaceRoot: staleProjectA, workspaceName: "project-a", workspacePath: staleProjectA }),
|
||||
ContextUsageForTab: async () => context,
|
||||
EffortForTab: async () => effort,
|
||||
BalanceForTab: async () => balance,
|
||||
@@ -224,9 +229,17 @@ window.go.main.App = {
|
||||
HistoryPageForTab: async () => ({ messages: [], startTurn: 0, endTurn: 0, totalTurns: 0, hasOlder: false }),
|
||||
HistoryCheckpointTurnsForTab: async () => [],
|
||||
ReplayPendingPrompts: async () => {},
|
||||
EnsureBlankSurface: async () => {
|
||||
ensureBlankSurfaceCalls += 1;
|
||||
return tabMeta({ id: "tab-new", topicId: "topic-new", topicTitle: "New session" });
|
||||
EnsureBlankSurface: async (scope: string, workspaceRoot: string) => {
|
||||
ensureBlankSurfaceCalls.push({ scope, workspaceRoot });
|
||||
return tabMeta({
|
||||
id: "tab-new",
|
||||
topicId: "topic-new",
|
||||
topicTitle: "New session",
|
||||
workspaceRoot: targetProjectB,
|
||||
workspaceName: "project-b",
|
||||
workspacePath: targetProjectB,
|
||||
cwd: targetProjectB,
|
||||
});
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings;
|
||||
|
||||
@@ -239,20 +252,31 @@ await act(async () => {
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await controller?.ensureBlankSurface("project", "/repo");
|
||||
await controller?.ensureBlankSurface("project", targetProjectB);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
eq(ensureBlankSurfaceCalls, 1, "EnsureBlankSurface is called once");
|
||||
eq(ensureBlankSurfaceCalls.length, 1, "EnsureBlankSurface is called once");
|
||||
eq(ensureBlankSurfaceCalls[0]?.workspaceRoot, targetProjectB, "EnsureBlankSurface keeps the requested project root");
|
||||
eq(controller?.activeTabId, "tab-new", "blank surface becomes active before startup sync resolves");
|
||||
eq(controller?.state.meta?.workspaceRoot, targetProjectB, "blank surface exposes the new project root");
|
||||
|
||||
await act(async () => {
|
||||
guardedStartupTabs.resolve([tabMeta({ id: "tab-old", topicId: "topic-old", topicTitle: "Old session" })]);
|
||||
guardedStartupTabs.resolve([tabMeta({
|
||||
id: "tab-old",
|
||||
topicId: "topic-old",
|
||||
topicTitle: "Old session",
|
||||
workspaceRoot: staleProjectA,
|
||||
workspaceName: "project-a",
|
||||
workspacePath: staleProjectA,
|
||||
cwd: staleProjectA,
|
||||
})]);
|
||||
await guardedStartupTabs.promise;
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
eq(controller?.activeTabId, "tab-new", "guarded startup sync cannot restore an older active tab");
|
||||
eq(controller?.state.meta?.workspaceRoot, targetProjectB, "guarded startup sync cannot restore the old project root");
|
||||
|
||||
await act(async () => {
|
||||
guardRoot.unmount();
|
||||
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
activeSessionAncestorKeys,
|
||||
projectTreeTopicOpenRequest,
|
||||
projectTreeShouldSuppressOpenForRename,
|
||||
projectTreeReadActivityKey,
|
||||
projectTreeTopicHasUnreadActivity,
|
||||
projectTreeShouldRenderTopicActions,
|
||||
} from "../components/ProjectTree";
|
||||
import type { ProjectNode } from "../lib/types";
|
||||
|
||||
@@ -104,6 +107,58 @@ eq(
|
||||
"regular project topic still opens by topic",
|
||||
);
|
||||
|
||||
const completedTopic: ProjectNode = {
|
||||
key: "topic_complete",
|
||||
kind: "topic",
|
||||
label: "Completed",
|
||||
root: "/repo",
|
||||
topicId: "topic-complete",
|
||||
lastActivityAt: 2000,
|
||||
};
|
||||
const completedTopicKey = projectTreeReadActivityKey(completedTopic) ?? "";
|
||||
|
||||
eq(
|
||||
projectTreeTopicHasUnreadActivity(completedTopic, { [completedTopicKey]: 1000 }, "project", "/repo", "other-topic"),
|
||||
true,
|
||||
"completed inactive topic with newer activity shows unread attention",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeTopicHasUnreadActivity(completedTopic, { [completedTopicKey]: 2000 }, "project", "/repo", "other-topic"),
|
||||
false,
|
||||
"completed topic stops showing unread attention once opened at its latest activity",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeTopicHasUnreadActivity(completedTopic, { [completedTopicKey]: 1000 }, "project", "/repo", "topic-complete"),
|
||||
false,
|
||||
"active topic does not show unread attention",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeTopicHasUnreadActivity({ ...completedTopic, status: "streaming", running: true }, { [completedTopicKey]: 1000 }, "project", "/repo", "other-topic"),
|
||||
false,
|
||||
"running topic keeps runtime status instead of completed-unread attention",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeShouldRenderTopicActions(false, true, false),
|
||||
true,
|
||||
"read workbench topic renders hover actions",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeShouldRenderTopicActions(false, true, true),
|
||||
false,
|
||||
"unread workbench topic omits hover actions from the keyboard tab order",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeShouldRenderTopicActions(true, true, false),
|
||||
false,
|
||||
"runtime session rows do not render topic hover actions",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeShouldSuppressOpenForRename(
|
||||
{ rowKey: "topic-a", canRename: true },
|
||||
|
||||
@@ -47,14 +47,14 @@ eq(
|
||||
);
|
||||
|
||||
eq(
|
||||
mergedFetchedProviderModels(["mimo-v2.5-pro"], ["mimo-v2-flash", "mimo-v2-omni", "mimo-v2.5-pro"], { preserveCurated: true }),
|
||||
mergedFetchedProviderModels(["mimo-v2.5-pro"], ["mimo-v2.5", "mimo-v2.5-pro"], { preserveCurated: true }),
|
||||
["mimo-v2.5-pro"],
|
||||
"manual access refresh preserves selected MiMo model instead of importing provider catalog",
|
||||
);
|
||||
|
||||
eq(
|
||||
providerModelCandidates(["mimo-v2.5-pro"], ["mimo-v2-flash", "mimo-v2-omni", "mimo-v2.5-pro"]),
|
||||
["mimo-v2.5-pro", "mimo-v2-flash", "mimo-v2-omni"],
|
||||
providerModelCandidates(["mimo-v2.5-pro"], ["mimo-v2.5", "mimo-v2.5-pro"]),
|
||||
["mimo-v2.5-pro", "mimo-v2.5"],
|
||||
"manual access refresh can show provider catalog as unsaved candidates",
|
||||
);
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import {
|
||||
SettingsPanel,
|
||||
formatProviderExtraBody,
|
||||
parseProviderExtraBody,
|
||||
providerBaseURLFromChatURL,
|
||||
providerChatURLPreview,
|
||||
providerEditorEffectiveKind,
|
||||
@@ -58,18 +60,52 @@ function baseSettings(displayMode: "standard" | "compact" = "standard"): Setting
|
||||
autoPlan: "off",
|
||||
providers: [],
|
||||
officialProviders: [],
|
||||
providerPresets: [],
|
||||
permissions: { mode: "ask", allow: [], ask: [], deny: [] },
|
||||
sandbox: { bash: "enforce", network: false, workspaceRoot: "", allowWrite: [], shell: "auto" },
|
||||
sandbox: { bash: "enforce", network: false, workspaceRoot: "", allowWrite: [], effectiveWorkspaceRoot: "/work", effectiveWriteRoots: ["/work"], shell: "auto" },
|
||||
network: { proxyMode: "auto", proxyUrl: "", noProxy: "", proxy: { type: "socks5", server: "", port: 0, username: "", password: "" } },
|
||||
agent: { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, systemPrompt: "", coldResumePrune: true, reasoningLanguage: "auto" },
|
||||
agent: { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, systemPrompt: "", coldResumePrune: true, reasoningLanguage: "auto" },
|
||||
bot: {
|
||||
enabled: false,
|
||||
model: "",
|
||||
toolApprovalMode: "",
|
||||
maxSteps: 0,
|
||||
debounceMs: 0,
|
||||
allowlist: { enabled: false, allowAll: false, qqUsers: [], feishuUsers: [], weixinUsers: [], qqGroups: [], feishuGroups: [], weixinGroups: [] },
|
||||
qq: { enabled: false, appId: "", appSecretEnv: "", secretSet: false, sandbox: false },
|
||||
toolApprovalMode: "",
|
||||
maxSteps: 0,
|
||||
debounceMs: 0,
|
||||
queueMode: "steer",
|
||||
queueCap: 20,
|
||||
queueDrop: "summarize",
|
||||
ignoreSelfMessages: true,
|
||||
selfUserIds: { qq: [], feishu: [], weixin: [] },
|
||||
control: { enabled: false, addr: "127.0.0.1:37913", tokenEnv: "REASONIX_BOT_CONTROL_TOKEN" },
|
||||
pairing: { enabled: true, requestTtlMinutes: 60, maxPendingPerPlatform: 3 },
|
||||
routes: [],
|
||||
allowlist: {
|
||||
enabled: false,
|
||||
allowAll: false,
|
||||
qqUsers: [],
|
||||
feishuUsers: [],
|
||||
weixinUsers: [],
|
||||
qqApprovers: [],
|
||||
feishuApprovers: [],
|
||||
weixinApprovers: [],
|
||||
qqAdmins: [],
|
||||
feishuAdmins: [],
|
||||
weixinAdmins: [],
|
||||
qqGroups: [],
|
||||
feishuGroups: [],
|
||||
weixinGroups: [],
|
||||
},
|
||||
qq: {
|
||||
enabled: false,
|
||||
appId: "",
|
||||
appSecretEnv: "",
|
||||
secretSet: false,
|
||||
sandbox: false,
|
||||
model: "",
|
||||
toolApprovalMode: "ask",
|
||||
workspaceRoot: "",
|
||||
access: { enabled: true, allowAll: false, pairingEnabled: true, users: [], groups: [], approvers: [], admins: [] },
|
||||
},
|
||||
feishu: { enabled: false, domain: "feishu", appId: "", appSecretEnv: "", secretSet: false, verificationToken: "", mode: "webhook", webhookPort: 0, requireMention: false },
|
||||
weixin: { enabled: false, accountId: "", tokenEnv: "", tokenSet: false, apiBase: "" },
|
||||
connections: [],
|
||||
@@ -96,11 +132,20 @@ function baseSettings(displayMode: "standard" | "compact" = "standard"): Setting
|
||||
|
||||
console.log("\nsettings refresh snapshot");
|
||||
|
||||
eq(providerEditorEffectiveKind(true, "anthropic", ["anthropic", "openai"]), "openai", "new custom providers ignore sorted providerKinds and default to OpenAI");
|
||||
eq(providerEditorEffectiveKind(true, "anthropic", ["anthropic", "openai"]), "anthropic", "new custom providers keep the selected Anthropic-compatible kind");
|
||||
eq(providerEditorEffectiveKind(false, "anthropic", ["anthropic", "openai"]), "anthropic", "existing providers preserve their stored kind");
|
||||
eq(providerChatURLPreview("https://proxy.example.com/v1", "", false), "https://proxy.example.com/v1/chat/completions", "base URL mode previews chat completions URL");
|
||||
eq(providerChatURLPreview("", "https://proxy.example.com/custom/chat", true), "https://proxy.example.com/custom/chat", "full URL mode previews configured URL");
|
||||
eq(providerBaseURLFromChatURL("https://proxy.example.com/v1/chat/completions"), "https://proxy.example.com/v1", "chat URL derives base URL for model discovery");
|
||||
eq(formatProviderExtraBody({ top_p: 0.7, enable_thinking: true }), "{\n \"enable_thinking\": true,\n \"top_p\": 0.7\n}", "extra body editor formats stable JSON");
|
||||
eq(JSON.stringify(parseProviderExtraBody('{ "enable_thinking": true, "top_p": 0.7 }')), "{\"enable_thinking\":true,\"top_p\":0.7}", "extra body editor parses JSON object");
|
||||
let extraBodyRejected = false;
|
||||
try {
|
||||
parseProviderExtraBody("[true]");
|
||||
} catch {
|
||||
extraBodyRejected = true;
|
||||
}
|
||||
ok(extraBodyRejected, "extra body editor rejects non-object JSON");
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
@@ -220,6 +265,88 @@ ok(document.body.textContent?.includes("Settings could not be loaded.") === fals
|
||||
await act(async () => {
|
||||
retryRoot.unmount();
|
||||
});
|
||||
|
||||
// Bots tab: direct four-channel bot manager.
|
||||
const botsRootEl = document.createElement("div");
|
||||
document.body.appendChild(botsRootEl);
|
||||
const botsRoot = createRoot(botsRootEl);
|
||||
const botsSettings = baseSettings("standard");
|
||||
botsSettings.bot.connections = [
|
||||
{
|
||||
id: "conn-feishu-1",
|
||||
provider: "feishu",
|
||||
domain: "feishu",
|
||||
label: "kun",
|
||||
enabled: true,
|
||||
status: "connected",
|
||||
model: "",
|
||||
toolApprovalMode: "",
|
||||
workspaceRoot: "",
|
||||
credential: { appId: "cli_mock", appSecretEnv: "FEISHU_BOT_APP_SECRET", accountId: "", tokenEnv: "", secretSet: true },
|
||||
sessionMappings: [],
|
||||
lastError: "",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
access: { enabled: true, allowAll: false, pairingEnabled: true, users: ["ou_mock_user_001"], groups: [], approvers: [], admins: [] },
|
||||
},
|
||||
];
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
Settings: async () => botsSettings,
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
botsRoot.render(
|
||||
<LocaleProvider>
|
||||
<SettingsPanel initialTab="bots" onClose={() => {}} onChanged={() => {}} />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("bot channel manager", () => Boolean(document.querySelector(".bot-channel-manager")));
|
||||
|
||||
ok(!document.querySelector(".bot-overview-grid"), "bots tab does not render the removed entry overview");
|
||||
ok(!document.getElementById("bot-mobile-remote"), "bots tab no longer renders the mobile remote entry card");
|
||||
ok(!document.querySelector(".bot-channel-entry"), "bots tab no longer renders the Bot Channel entry panel");
|
||||
ok(!document.getElementById("bot-step-access"), "bots tab omits the old global access step card");
|
||||
ok(!document.getElementById("bot-step-behavior"), "bots tab omits global default behavior card");
|
||||
eq(document.querySelectorAll(".bot-step-chip").length, 0, "hero no longer shows the old two-step chips");
|
||||
|
||||
eq(document.querySelectorAll(".bot-channel-tabs [role=\"tab\"]").length, 4, "bot manager uses four fixed channel tabs on the left");
|
||||
ok(document.querySelector(".bot-channel-setup-card")?.textContent?.includes("Configure QQ") === true, "unconfigured QQ tab shows key setup on the right");
|
||||
ok(document.body.textContent?.includes("Back to entry") === false, "bot manager does not show a return-to-entry action");
|
||||
|
||||
const feishuTab = Array.from(document.querySelectorAll(".bot-channel-tabs [role=\"tab\"]")).find((button) => button.textContent?.includes("Feishu")) as HTMLButtonElement | undefined;
|
||||
if (!feishuTab) throw new Error("Feishu channel tab did not render");
|
||||
await act(async () => {
|
||||
feishuTab.click();
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("selected Feishu detail", () => Boolean(document.querySelector(".bot-channel-manager__detail .bot-detail-card")));
|
||||
|
||||
ok(Boolean(document.querySelector(".bot-channel-manager__detail .bot-detail-card")), "configured channel renders selected bot detail on the right");
|
||||
ok(Boolean(document.querySelector(".bot-channel-manager__detail .bot-detail-section--access")), "selected bot detail owns its access control");
|
||||
ok(document.body.textContent?.includes("Access control") === true, "selected bot detail labels per-bot access control");
|
||||
const selectedBotDetailText = document.querySelector(".bot-channel-manager__detail .bot-detail-card")?.textContent ?? "";
|
||||
const connectionSummaryIndex = selectedBotDetailText.indexOf("Connection summary");
|
||||
const enableBotIndex = selectedBotDetailText.indexOf("Enable bot");
|
||||
const toolApprovalIndex = selectedBotDetailText.indexOf("Tool approval");
|
||||
const modelIndex = selectedBotDetailText.indexOf("Model");
|
||||
const accessControlIndex = selectedBotDetailText.indexOf("Access control");
|
||||
ok(
|
||||
connectionSummaryIndex >= 0 && enableBotIndex > connectionSummaryIndex && toolApprovalIndex > enableBotIndex && modelIndex > toolApprovalIndex && accessControlIndex > modelIndex,
|
||||
"selected bot detail places enable, approval, and model controls between summary and access control",
|
||||
);
|
||||
ok(document.body.textContent?.includes("ou_mock_user_001") === true, "selected bot detail shows its trusted user");
|
||||
ok(document.body.textContent?.includes("Legacy global allowlist") === true, "advanced area keeps the legacy global allowlist");
|
||||
ok(document.querySelector(".bot-simple-advanced")?.textContent?.includes("local control API") === false, "advanced area no longer owns mobile/control API setup");
|
||||
|
||||
await act(async () => {
|
||||
botsRoot.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
|
||||
@@ -130,23 +130,34 @@ const tabA = tabMeta("tab-a", { active: true });
|
||||
const tabB = tabMeta("tab-b");
|
||||
const tabC = tabMeta("tab-c");
|
||||
const tabD = tabMeta("tab-d");
|
||||
const tabE = tabMeta("tab-e");
|
||||
const tabF = tabMeta("tab-f");
|
||||
const tabG = tabMeta("tab-g");
|
||||
let backendActiveId = "tab-a";
|
||||
const historyB = deferred<HistoryMessage[]>();
|
||||
const historyD = deferred<HistoryMessage[]>();
|
||||
const contextDGate = deferred<ContextInfo>();
|
||||
const setActiveBGate = deferred<void>();
|
||||
const setActiveEGate = deferred<void>();
|
||||
const setActiveFGate = deferred<void>();
|
||||
const submitTabCGate = deferred<void>();
|
||||
const historyCalls: string[] = [];
|
||||
const cancelCalls: string[] = [];
|
||||
let contextDCalls = 0;
|
||||
let holdNextContextForD = false;
|
||||
let setActiveCalls = 0;
|
||||
let newSessionCalls = 0;
|
||||
let failSetActiveFor = "";
|
||||
const runningTabs = new Set<string>();
|
||||
const tabsById = new Map([tabA, tabB, tabC, tabD].map((tab) => [tab.id, tab]));
|
||||
const tabsById = new Map([tabA, tabB, tabC, tabD, tabE, tabF, tabG].map((tab) => [tab.id, tab]));
|
||||
const eventHandlers: Array<(e: WireEvent) => void> = [];
|
||||
const readyHandlers: Array<(tabId?: string) => void> = [];
|
||||
|
||||
function currentTabs(): TabMeta[] {
|
||||
return [tabA, tabB, tabC, tabD].map((tab) => ({ ...tab, active: tab.id === backendActiveId, running: runningTabs.has(tab.id) }));
|
||||
return Array.from(tabsById.values()).map((tab) => {
|
||||
const running = runningTabs.has(tab.id);
|
||||
return { ...tab, active: tab.id === backendActiveId, running, cancellable: running };
|
||||
});
|
||||
}
|
||||
|
||||
window.runtime = {
|
||||
@@ -179,6 +190,7 @@ window.go = {
|
||||
historyCalls.push(tabID);
|
||||
if (tabID === "tab-b") return historyB.promise;
|
||||
if (tabID === "tab-d") return historyD.promise;
|
||||
if (tabID === "tab-e") return [userMessage("fork E")];
|
||||
return [userMessage("cached A")];
|
||||
},
|
||||
HistoryPageForTab: async (tabID: string) => {
|
||||
@@ -193,14 +205,28 @@ window.go = {
|
||||
NewSession: async () => {
|
||||
newSessionCalls += 1;
|
||||
},
|
||||
Fork: async () => {
|
||||
tabsById.set("tab-e", tabE);
|
||||
backendActiveId = "tab-e";
|
||||
runningTabs.add("tab-e");
|
||||
return { ...tabE, active: true, running: true };
|
||||
},
|
||||
ReplayPendingPrompts: async () => {},
|
||||
SetActiveTab: async (tabID: string) => {
|
||||
setActiveCalls += 1;
|
||||
if (tabID === "tab-b") await setActiveBGate.promise;
|
||||
if (tabID === "tab-e") await setActiveEGate.promise;
|
||||
if (tabID === "tab-f") await setActiveFGate.promise;
|
||||
if (tabID === failSetActiveFor) throw new Error("persist failed");
|
||||
backendActiveId = tabID;
|
||||
},
|
||||
CancelTab: async (tabID: string) => {
|
||||
cancelCalls.push(tabID);
|
||||
runningTabs.delete(tabID);
|
||||
},
|
||||
SubmitToTab: async (tabID: string) => {
|
||||
runningTabs.add(tabID);
|
||||
if (tabID === "tab-c") await submitTabCGate.promise;
|
||||
},
|
||||
SubmitDisplayToTab: async (tabID: string) => {
|
||||
runningTabs.add(tabID);
|
||||
@@ -275,6 +301,93 @@ eq(controller?.activeTabId, "tab-a", "late history for another tab does not chan
|
||||
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "cached A") ?? false, "late history for another tab does not overwrite the active transcript");
|
||||
ok(!(controller?.state.items.some((item) => item.kind === "user" && item.text === "late B") ?? false), "late history stays scoped to its tab state");
|
||||
|
||||
const historyCallsBeforeFallbackSync = historyCalls.length;
|
||||
await act(async () => {
|
||||
for (const handler of eventHandlers) handler({ kind: "approval_request", tabId: "tab-b", approval: { id: "stale-fallback-approval", tool: "bash", subject: "stale fallback approval" } });
|
||||
await flushPromises();
|
||||
});
|
||||
backendActiveId = "tab-b";
|
||||
await act(async () => {
|
||||
await controller?.syncActiveTab(false);
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, "tab-b", "backend fallback sync activates the backend-selected cached tab");
|
||||
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "late B") ?? false, "backend fallback sync keeps the cached transcript");
|
||||
eq(historyCalls.length, historyCallsBeforeFallbackSync, "backend fallback sync preserves cached history instead of reloading it");
|
||||
eq(controller?.state.approval?.id, undefined, "backend fallback sync reconciles stale approval state");
|
||||
eq(controller?.state.running, false, "backend fallback sync reconciles stale running state");
|
||||
await act(async () => {
|
||||
await controller?.switchTab("tab-a", tabA);
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("tab-a restored after fallback sync", () => controller?.activeTabId === "tab-a" && controller.state.items.some((item) => item.kind === "user" && item.text === "cached A"));
|
||||
|
||||
runningTabs.add("tab-e");
|
||||
let switchToE: Promise<TabMeta[] | undefined> | undefined;
|
||||
await act(async () => {
|
||||
switchToE = controller?.switchTab("tab-e", { ...tabE, running: true, cancellable: true });
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, "tab-e", "switching to a backend-running tab updates the active tab immediately");
|
||||
eq(controller?.state.running, true, "backend-running tab restores the stop state before backend activation settles");
|
||||
eq(controller?.state.cancellable, true, "backend-running tab remains cancellable before backend activation settles");
|
||||
await act(async () => {
|
||||
controller?.cancel();
|
||||
await flushPromises();
|
||||
});
|
||||
eq(cancelCalls.join(","), "tab-e", "cancel targets the backend-running tab while activation is pending");
|
||||
await act(async () => {
|
||||
setActiveEGate.resolve();
|
||||
await switchToE;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.state.running, false, "cancelled backend-running tab reconciles to idle after activation");
|
||||
await act(async () => {
|
||||
await controller?.switchTab("tab-a", tabA);
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("tab-a restored after backend-running switch", () => controller?.activeTabId === "tab-a" && controller.state.items.some((item) => item.kind === "user" && item.text === "cached A"));
|
||||
|
||||
let switchToF: Promise<TabMeta[] | undefined> | undefined;
|
||||
await act(async () => {
|
||||
switchToF = controller?.switchTab("tab-f", tabF);
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, "tab-f", "first rapid switch activates the slow target optimistically");
|
||||
let switchToG: Promise<TabMeta[] | undefined> | undefined;
|
||||
await act(async () => {
|
||||
switchToG = controller?.switchTab("tab-g", tabG);
|
||||
await switchToG;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, "tab-g", "second rapid switch wins immediately");
|
||||
eq(backendActiveId, "tab-g", "second rapid switch activates the backend");
|
||||
await act(async () => {
|
||||
setActiveFGate.resolve();
|
||||
await switchToF;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, "tab-g", "late completion from the first rapid switch does not replace the visible tab");
|
||||
eq(backendActiveId, "tab-g", "late completion from the first rapid switch reasserts the last-clicked backend tab");
|
||||
ok(!historyCalls.includes("tab-f"), "late completion from the first rapid switch does not hydrate the stale target");
|
||||
|
||||
await act(async () => {
|
||||
await controller?.switchTab("tab-a", tabA);
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("tab-a restored after rapid switch", () => controller?.activeTabId === "tab-a" && controller.state.items.some((item) => item.kind === "user" && item.text === "cached A"));
|
||||
|
||||
failSetActiveFor = "tab-b";
|
||||
const historyCallsBeforeFailedSwitch = historyCalls.length;
|
||||
await act(async () => {
|
||||
await controller?.switchTab("tab-b", tabB);
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, "tab-a", "failed backend tab switch reverts to the previous active tab");
|
||||
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "cached A") ?? false, "failed backend tab switch keeps the previous transcript visible");
|
||||
eq(historyCalls.length, historyCallsBeforeFailedSwitch, "failed backend tab switch does not hydrate the rejected target");
|
||||
failSetActiveFor = "";
|
||||
|
||||
await act(async () => {
|
||||
for (const handler of eventHandlers) handler({ kind: "phase", text: "Planner is thinking", tabId: "tab-a" });
|
||||
for (const handler of eventHandlers) handler({ kind: "message", text: "Planner kept", reasoning: "Planner notes", tabId: "tab-a" });
|
||||
@@ -293,10 +406,15 @@ eq(historyCalls.length, historyCallsBeforeReady, "agent ready with cached transc
|
||||
ok(controller?.state.items.some((item) => item.kind === "phase" && item.text === "Planner is thinking") ?? false, "agent ready keeps cached planner phase");
|
||||
ok(controller?.state.items.some((item) => item.kind === "assistant" && item.text === "Planner kept" && item.reasoning === "Planner notes") ?? false, "agent ready keeps cached planner answer");
|
||||
|
||||
let tabCSendResolved = false;
|
||||
await act(async () => {
|
||||
controller?.sendToTab("tab-c", "streaming C");
|
||||
const sendPromise = controller?.sendToTab("tab-c", "streaming C");
|
||||
sendPromise?.then(() => {
|
||||
tabCSendResolved = true;
|
||||
});
|
||||
await flushPromises();
|
||||
});
|
||||
eq(tabCSendResolved, true, "sendToTab resolves after optimistic dispatch before backend submit completes");
|
||||
await act(async () => {
|
||||
await controller?.switchTab("tab-c", tabC);
|
||||
await flushPromises();
|
||||
@@ -304,6 +422,11 @@ await act(async () => {
|
||||
eq(controller?.activeTabId, "tab-c", "switching to a cached running tab still updates the active tab");
|
||||
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "streaming C") ?? false, "cached running tab keeps its optimistic transcript");
|
||||
ok(!historyCalls.includes("tab-c"), "cached running tab skips history hydration");
|
||||
await act(async () => {
|
||||
submitTabCGate.resolve();
|
||||
await submitTabCGate.promise;
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
holdNextContextForD = true;
|
||||
await act(async () => {
|
||||
@@ -342,6 +465,11 @@ ok(controller?.state.items.some((item) => item.kind === "user" && item.text ===
|
||||
eq(controller?.state.hydratePlaceholderItems?.length ?? 0, 0, "topic history clears the hydration placeholder");
|
||||
|
||||
const historyCallsBeforeReopenD = historyCalls.length;
|
||||
await act(async () => {
|
||||
for (const handler of eventHandlers) handler({ kind: "approval_request", tabId: "tab-d", approval: { id: "stale-approval", tool: "bash", subject: "stale approval" } });
|
||||
await controller?.switchTab("tab-a", tabA);
|
||||
await flushPromises();
|
||||
});
|
||||
await act(async () => {
|
||||
await controller?.openProjectTab(tabD.workspaceRoot, tabD.topicId || "");
|
||||
await flushPromises();
|
||||
@@ -349,6 +477,17 @@ await act(async () => {
|
||||
eq(controller?.activeTabId, "tab-d", "reopening an already hydrated topic keeps it active");
|
||||
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "history D") ?? false, "reopened cached topic keeps its transcript");
|
||||
eq(historyCalls.length, historyCallsBeforeReopenD, "reopening an already hydrated topic skips history hydration");
|
||||
eq(controller?.state.approval?.id, undefined, "reopening a topic reconciles stale approval state");
|
||||
eq(controller?.state.running, false, "reopening a topic reconciles stale running state");
|
||||
|
||||
await act(async () => {
|
||||
await controller?.rewind(0, "fork");
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, "tab-e", "fork activates the forked tab");
|
||||
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "fork E") ?? false, "fork loads the forked transcript");
|
||||
eq(controller?.state.running, true, "fork reconciles backend running state after reset hydration");
|
||||
runningTabs.delete("tab-e");
|
||||
|
||||
const contextCallsBeforeInactiveD = contextDCalls;
|
||||
await act(async () => {
|
||||
|
||||
@@ -113,6 +113,10 @@ eq(
|
||||
"var(--statusbar-dock-height)",
|
||||
"status bar min-height follows the scaled dock token",
|
||||
);
|
||||
eq(finalDeclaration(".provider-template-grid", "grid-auto-rows"), "92px", "provider preset cards use compact equal-height grid rows");
|
||||
eq(finalDeclaration(".provider-template-card", "height"), "100%", "provider preset cards stretch to the grid row height");
|
||||
eq(finalDeclaration(".provider-template-card strong", "-webkit-line-clamp"), "1", "provider preset card titles clamp to one line");
|
||||
eq(finalDeclaration(".provider-template-card span", "-webkit-line-clamp"), "2", "provider preset card descriptions clamp to two lines");
|
||||
|
||||
eq(finalDeclaration(".statusbar", "white-space"), "nowrap", "status bar keeps metrics on one row");
|
||||
eq(finalDeclaration(".statusbar", "overflow"), "hidden", "status bar clips instead of overflowing");
|
||||
@@ -160,6 +164,16 @@ ok(
|
||||
);
|
||||
|
||||
eq(finalDeclaration(".composer-modebar", "overflow"), "hidden", "chat mode switcher contains enlarged labels");
|
||||
eq(
|
||||
finalDeclaration(".app--creation .tool:not(.tool--open) > .tool__body", "height"),
|
||||
"0 !important",
|
||||
"collapsed creation tool bodies keep mounted content clipped",
|
||||
);
|
||||
eq(
|
||||
finalDeclaration(".app--creation .tool:not(.tool--open) > .tool__body", "visibility"),
|
||||
"hidden",
|
||||
"collapsed creation tool bodies do not paint hidden tool text",
|
||||
);
|
||||
ok(
|
||||
/@container\s*\(max-width:\s*760px\)[\s\S]*?\.composer-meta__control--model\s*\{[\s\S]*?flex\s*:\s*0 1 auto[\s\S]*?width\s*:\s*fit-content[\s\S]*?max-width\s*:\s*min\(240px,\s*42vw\)[\s\S]*?\.composer-meta--has-intent-chip\s+\.composer-meta__control--model\s*\{[\s\S]*?flex\s*:\s*0 1 auto[\s\S]*?width\s*:\s*fit-content[\s\S]*?max-width\s*:\s*min\(220px,\s*38vw\)[\s\S]*?\.composer-meta__control--effort\s*\{[\s\S]*?display\s*:\s*none[\s\S]*?\.composer-meta__control--more\s*\{[\s\S]*?display\s*:\s*inline-flex/.test(styles),
|
||||
"composer compact controls activate at the capped theme width",
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
// Run: tsx src/__tests__/use-controller-send-fallback.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import { useController } from "../lib/useController";
|
||||
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, JobView, Meta, TabMeta } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function eq(actual: unknown, expected: unknown, label: string) {
|
||||
ok(actual === expected, `${label}${actual === expected ? "" : `: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`}`);
|
||||
}
|
||||
|
||||
function flushPromises(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function tabMeta(overrides: Partial<TabMeta> = {}): TabMeta {
|
||||
return {
|
||||
id: "tab-send",
|
||||
scope: "project",
|
||||
workspaceRoot: "/repo/send",
|
||||
workspaceName: "send",
|
||||
workspacePath: "/repo/send",
|
||||
gitBranch: "main",
|
||||
topicId: "topic-send",
|
||||
topicTitle: "Send",
|
||||
label: "model-send",
|
||||
ready: true,
|
||||
running: false,
|
||||
mode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
active: true,
|
||||
cwd: "/repo/send",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function metaFor(tab: TabMeta): Meta {
|
||||
return {
|
||||
label: tab.label,
|
||||
ready: tab.ready,
|
||||
startupErr: tab.startupErr,
|
||||
eventChannel: "agent:event",
|
||||
cwd: tab.cwd || tab.workspaceRoot,
|
||||
workspaceRoot: tab.workspaceRoot,
|
||||
workspaceName: tab.workspaceName,
|
||||
workspacePath: tab.workspacePath,
|
||||
gitBranch: tab.gitBranch,
|
||||
autoApproveTools: false,
|
||||
bypass: false,
|
||||
collaborationMode: tab.collaborationMode ?? "normal",
|
||||
toolApprovalMode: tab.toolApprovalMode ?? "ask",
|
||||
tokenMode: tab.tokenMode ?? "full",
|
||||
goal: "",
|
||||
goalStatus: "stopped",
|
||||
};
|
||||
}
|
||||
|
||||
console.log("\nuse controller send fallback");
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
|
||||
const backendTab = tabMeta({ backgroundJobs: 2 });
|
||||
const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 };
|
||||
const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
|
||||
const balance: BalanceInfo = { available: false, display: "" };
|
||||
const jobs: JobView[] = [];
|
||||
const checkpoints: CheckpointMeta[] = [];
|
||||
let tabsAvailable = false;
|
||||
let submitCalls = 0;
|
||||
|
||||
window.runtime = {
|
||||
EventsOn: () => () => {},
|
||||
BrowserOpenURL: () => {},
|
||||
};
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListTabs: async () => (tabsAvailable ? [backendTab] : []),
|
||||
MetaForTab: async () => metaFor(backendTab),
|
||||
ContextUsageForTab: async () => context,
|
||||
EffortForTab: async () => effort,
|
||||
BalanceForTab: async () => balance,
|
||||
JobsForTab: async () => jobs,
|
||||
CheckpointsForTab: async () => checkpoints,
|
||||
HistoryForTab: async (): Promise<HistoryMessage[]> => [],
|
||||
HistoryPageForTab: async () => ({ messages: [], startTurn: 0, endTurn: 0, totalTurns: 0, hasOlder: false }),
|
||||
HistoryCheckpointTurnsForTab: async () => [],
|
||||
ReplayPendingPrompts: async () => {},
|
||||
SubmitToTab: async (tabId: string) => {
|
||||
submitCalls += tabId === "tab-send" ? 1 : 0;
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
|
||||
type Controller = ReturnType<typeof useController>;
|
||||
let controller: Controller | undefined;
|
||||
|
||||
function Probe() {
|
||||
controller = useController();
|
||||
return null;
|
||||
}
|
||||
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Probe />);
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, undefined, "startup has no active tab when backend has no tabs");
|
||||
|
||||
tabsAvailable = true;
|
||||
await act(async () => {
|
||||
await controller?.send("hello from fallback");
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
eq(controller?.activeTabId, "tab-send", "send fallback activates the backend-selected tab");
|
||||
eq(controller?.state.backgroundJobs, 2, "send fallback reconciles backend runtime metadata");
|
||||
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "hello from fallback") ?? false, "send fallback keeps the optimistic user turn");
|
||||
eq(submitCalls, 1, "send fallback submits to the activated tab");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -7,7 +7,7 @@ import { createRoot } from "react-dom/client";
|
||||
import { WorkspacePanel } from "../components/WorkspacePanel";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import type { WorkspaceChangesView } from "../lib/types";
|
||||
import type { DirEntry, WorkspaceChangesView } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -101,6 +101,47 @@ async function renderWorkspace(changes: WorkspaceChangesView) {
|
||||
return { dom, root };
|
||||
}
|
||||
|
||||
async function renderFilesWorkspace(methods: Partial<AppBindings>, props: Partial<Parameters<typeof WorkspacePanel>[0]> = {}) {
|
||||
const dom = installDom();
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListDir: async () => [],
|
||||
SearchFileRefs: async () => [],
|
||||
WorkspaceGitHistory: async () => [],
|
||||
WorkspaceChanges: async () => ({ files: [], gitAvailable: true }),
|
||||
...methods,
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
let currentProps: Parameters<typeof WorkspacePanel>[0] = {
|
||||
open: true,
|
||||
tabId: "tab-a",
|
||||
cwd: "/repo",
|
||||
maximized: false,
|
||||
initialViewMode: "files",
|
||||
onClose: () => {},
|
||||
onToggleMaximized: () => {},
|
||||
...props,
|
||||
};
|
||||
const rerender = async (nextProps: Partial<Parameters<typeof WorkspacePanel>[0]> = {}) => {
|
||||
currentProps = { ...currentProps, ...nextProps };
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<WorkspacePanel {...currentProps} />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushPromises();
|
||||
});
|
||||
};
|
||||
await rerender();
|
||||
return { dom, root, rerender };
|
||||
}
|
||||
|
||||
console.log("\nworkspace changes git errors");
|
||||
|
||||
{
|
||||
@@ -151,5 +192,28 @@ console.log("\nworkspace changes git errors");
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const calls: string[] = [];
|
||||
const listDir = async (dir: string): Promise<DirEntry[]> => {
|
||||
calls.push(dir);
|
||||
return [];
|
||||
};
|
||||
const { dom, root, rerender } = await renderFilesWorkspace(
|
||||
{ ListDir: listDir },
|
||||
{ fileListRequest: { id: 1, paths: ["src/app.ts"] } },
|
||||
);
|
||||
|
||||
await waitFor("initial referenced file dirs", () => calls.filter((dir) => dir === "src/").length === 1);
|
||||
await rerender({ fileListRequest: { id: 2, paths: ["src/app.ts"] } });
|
||||
await waitFor("referenced file dirs revalidated", () => calls.filter((dir) => dir === "src/").length === 2);
|
||||
|
||||
ok(calls.filter((dir) => dir === "src/").length === 2, "workspace file tree revalidates cached directories for repeated file-list requests");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -70,6 +70,9 @@ eq(
|
||||
);
|
||||
eq(finalDeclaration(".workspace-preview__body--code .workspace-note", "flex"), "0 0 auto", "code truncation note keeps its own row");
|
||||
eq(finalDeclaration(".workspace-preview__body--code .code-block", "display"), "flex", "code block fills the preview viewport");
|
||||
eq(finalDeclaration(".workspace-preview__body--code .code-block__wrap", "display"), "flex", "code wrapper keeps the preview viewport height");
|
||||
eq(finalDeclaration(".workspace-preview__body--code .code-block__wrap", "flex"), "1 1 auto", "code wrapper participates in the preview flex height");
|
||||
eq(finalDeclaration(".workspace-preview__body--code .code-block__wrap", "min-height"), "0", "code wrapper can shrink around the scrollable code viewport");
|
||||
eq(finalDeclaration(".workspace-preview__body--code .code", "overflow"), "auto", "code viewport owns horizontal and vertical scrolling");
|
||||
eq(finalDeclaration(".workspace-preview__body--code .code", "min-height"), "0", "code viewport can shrink inside the preview pane");
|
||||
eq(finalDeclaration(".workspace-preview__body--code .code", "margin"), "0", "code viewport scrollbar sits at the visible pane bottom");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Minus, PanelLeft, PanelRight, Search, Square, X } from "lucide-react";
|
||||
import { PanelLeft, PanelRight, Search } from "lucide-react";
|
||||
import { TabBar } from "./TabBar";
|
||||
import type { TabMeta } from "../lib/types";
|
||||
import { useT } from "../lib/i18n";
|
||||
@@ -58,14 +58,13 @@ export function AppChrome({
|
||||
}: AppChromeProps) {
|
||||
const t = useT();
|
||||
const darwinChrome = platform === "darwin";
|
||||
const showWindowsPreviewControls = browserPreviewChrome && platform === "windows";
|
||||
const titlebarDragRail = darwinChrome || platform === "windows";
|
||||
const chromeClassName = [
|
||||
"app-chrome",
|
||||
"app-chrome--tabs",
|
||||
darwinChrome ? "app-chrome--darwin-tabs" : "app-chrome--native-tabs",
|
||||
workbenchChrome ? "app-chrome--workbench" : "",
|
||||
!darwinChrome ? "app-chrome--identityless" : "",
|
||||
showWindowsPreviewControls ? "app-chrome--preview-window-controls" : "",
|
||||
`app-chrome--platform-${platform}`,
|
||||
].filter(Boolean).join(" ");
|
||||
const tabBar = (
|
||||
@@ -92,7 +91,7 @@ export function AppChrome({
|
||||
<span />
|
||||
</div>
|
||||
)}
|
||||
{darwinChrome && <span className="app-chrome__drag-rail" aria-hidden="true" />}
|
||||
{titlebarDragRail && <span className="app-chrome__drag-rail" aria-hidden="true" />}
|
||||
<button
|
||||
className={[
|
||||
"app-chrome__panel-toggle",
|
||||
@@ -194,19 +193,6 @@ export function AppChrome({
|
||||
<PanelRight size={16} />
|
||||
</button>
|
||||
)}
|
||||
{showWindowsPreviewControls && (
|
||||
<div className="app-chrome__window-controls app-chrome__window-controls--windows" aria-hidden="true">
|
||||
<span className="app-chrome__window-control app-chrome__window-control--minimize">
|
||||
<Minus size={12} strokeWidth={1.9} />
|
||||
</span>
|
||||
<span className="app-chrome__window-control app-chrome__window-control--maximize">
|
||||
<Square size={10} strokeWidth={1.8} />
|
||||
</span>
|
||||
<span className="app-chrome__window-control app-chrome__window-control--close">
|
||||
<X size={12} strokeWidth={1.9} />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,10 @@ function animateShelfExit(
|
||||
options.onComplete();
|
||||
}
|
||||
|
||||
function requiresFreshHumanApproval(tool: string): boolean {
|
||||
return tool === "remember" || tool === "forget" || tool === "exit_plan_mode";
|
||||
}
|
||||
|
||||
export function ApprovalModal({
|
||||
approval,
|
||||
onAnswer,
|
||||
@@ -48,8 +52,17 @@ export function ApprovalModal({
|
||||
}) {
|
||||
const t = useT();
|
||||
const isPlanApproval = approval.tool === "exit_plan_mode";
|
||||
const isPlanModeReadOnlyCommand = approval.tool === "plan_mode_read_only_command";
|
||||
const toolLabel = isPlanModeReadOnlyCommand ? "bash" : approval.tool;
|
||||
const isFreshHumanApproval = requiresFreshHumanApproval(approval.tool);
|
||||
const subject = approval.subject.trim();
|
||||
const subjectSummary = subject.split(/\r?\n/).find((line) => line.trim())?.trim() ?? "";
|
||||
const toolMeta = approval.reason?.trim() || subjectSummary || approval.tool;
|
||||
const hasToolDetails = Boolean(approval.reason || subject);
|
||||
const showToolDetailsByDefault = !isPlanApproval && hasToolDetails;
|
||||
const [revisionOpen, setRevisionOpen] = useState(false);
|
||||
const [revisionText, setRevisionText] = useState("");
|
||||
const [detailsOpen, setDetailsOpen] = useState(() => showToolDetailsByDefault);
|
||||
const [selectedIndex, setSelectedIndex] = useState(() => (isPlanApproval ? 1 : 0));
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
const shelfRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -59,7 +72,6 @@ export function ApprovalModal({
|
||||
// the new one slides in. GSAP fromTo on the shelf wrapper avoids the
|
||||
// jarring pop when the API cycles through 4+ pending approvals.
|
||||
const closingRef = useRef(false);
|
||||
const subject = approval.subject.trim();
|
||||
const fileMenu = useFileReferenceMenu(revisionText, cwd);
|
||||
|
||||
const answerWithExit = (fn: () => void) => {
|
||||
@@ -88,9 +100,10 @@ export function ApprovalModal({
|
||||
|
||||
const chooseToolAction = (key: string) => {
|
||||
if (key === "1") answerWithExit(() => onAnswer(true, false, false));
|
||||
else if (key === "2") answerWithExit(() => onAnswer(true, true, false));
|
||||
else if (key === "3") answerWithExit(() => onAnswer(true, true, true));
|
||||
else if (key === "4") answerWithExit(() => onAnswer(false, false, false));
|
||||
else if (isFreshHumanApproval && key === "4") answerWithExit(() => onAnswer(false, false, false));
|
||||
else if (!isFreshHumanApproval && key === "2") answerWithExit(() => onAnswer(true, true, false));
|
||||
else if (!isFreshHumanApproval && key === "3") answerWithExit(() => onAnswer(true, true, true));
|
||||
else if (!isFreshHumanApproval && key === "4") answerWithExit(() => onAnswer(false, false, false));
|
||||
else if (key === "Escape") answerWithExit(onStop);
|
||||
};
|
||||
|
||||
@@ -98,11 +111,12 @@ export function ApprovalModal({
|
||||
cardRef.current?.focus();
|
||||
setRevisionOpen(false);
|
||||
setRevisionText("");
|
||||
setDetailsOpen(showToolDetailsByDefault);
|
||||
setSelectedIndex(isPlanApproval ? 1 : 0);
|
||||
playAttentionChime();
|
||||
}, [approval.id, isPlanApproval]);
|
||||
}, [approval.id, isPlanApproval, showToolDetailsByDefault]);
|
||||
|
||||
const actionCount = isPlanApproval ? 3 : 4;
|
||||
const actionCount = isPlanApproval ? 3 : isFreshHumanApproval ? 2 : 4;
|
||||
const selectedIndexRef = useRef(selectedIndex);
|
||||
selectedIndexRef.current = selectedIndex;
|
||||
|
||||
@@ -123,6 +137,7 @@ export function ApprovalModal({
|
||||
event.preventDefault();
|
||||
const key = String(selectedIndexRef.current + 1);
|
||||
if (isPlanApproval) choosePlanAction(key);
|
||||
else if (isFreshHumanApproval) chooseToolAction(selectedIndexRef.current === 0 ? "1" : "4");
|
||||
else chooseToolAction(key);
|
||||
} else if (event.key === "1" || event.key === "2" || event.key === "3" || event.key === "4" || event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
@@ -132,7 +147,7 @@ export function ApprovalModal({
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [isPlanApproval, onAnswer, onExitPlan, onStop, actionCount]);
|
||||
}, [isPlanApproval, isFreshHumanApproval, onAnswer, onExitPlan, onStop, actionCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (revisionOpen) {
|
||||
@@ -219,6 +234,7 @@ export function ApprovalModal({
|
||||
return (
|
||||
<div ref={shelfRef}>
|
||||
<PromptShelf
|
||||
className="prompt-shelf--compact prompt-shelf--plan-approval"
|
||||
barRef={cardRef}
|
||||
titleId="plan-approval-title"
|
||||
title={t("approval.planReady")}
|
||||
@@ -280,27 +296,46 @@ export function ApprovalModal({
|
||||
return (
|
||||
<div ref={shelfRef}>
|
||||
<PromptShelf
|
||||
className="prompt-shelf--compact prompt-shelf--tool-approval"
|
||||
barRef={cardRef}
|
||||
titleId="tool-approval-title"
|
||||
title={t("approval.toolPending")}
|
||||
badges={<PromptBadge>{approval.tool}</PromptBadge>}
|
||||
badges={<PromptBadge>{toolLabel}</PromptBadge>}
|
||||
meta={toolMeta}
|
||||
headerActions={
|
||||
<PromptHeaderAction onClick={() => answerWithExit(onStop)} ariaLabel={t("composer.stopShort")}>
|
||||
Esc
|
||||
</PromptHeaderAction>
|
||||
<>
|
||||
{hasToolDetails && (
|
||||
<PromptHeaderAction onClick={() => setDetailsOpen((open) => !open)}>
|
||||
{t(detailsOpen ? "approval.hideDetails" : "approval.details")}
|
||||
</PromptHeaderAction>
|
||||
)}
|
||||
<PromptHeaderAction onClick={() => answerWithExit(onStop)} ariaLabel={t("composer.stopShort")}>
|
||||
Esc
|
||||
</PromptHeaderAction>
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<PromptAction keyLabel="1" label={t("approval.allowOnce")} onClick={() => answerWithExit(() => onAnswer(true, false, false))} selected={selectedIndex === 0} />
|
||||
<PromptAction keyLabel="2" label={t("approval.allowRuleSession")} onClick={() => answerWithExit(() => onAnswer(true, true, false))} selected={selectedIndex === 1} />
|
||||
<PromptAction keyLabel="3" label={t("approval.allowRulePersistent")} onClick={() => answerWithExit(() => onAnswer(true, true, true))} selected={selectedIndex === 2} />
|
||||
<PromptAction keyLabel="4" label={t("approval.deny")} onClick={() => answerWithExit(() => onAnswer(false, false, false))} selected={selectedIndex === 3} />
|
||||
{isFreshHumanApproval ? (
|
||||
<PromptAction keyLabel="4" label={t("approval.deny")} onClick={() => answerWithExit(() => onAnswer(false, false, false))} selected={selectedIndex === 1} />
|
||||
) : (
|
||||
<>
|
||||
<PromptAction keyLabel="2" label={t("approval.allowRuleSession")} onClick={() => answerWithExit(() => onAnswer(true, true, false))} selected={selectedIndex === 1} />
|
||||
<PromptAction keyLabel="3" label={t("approval.allowRulePersistent")} onClick={() => answerWithExit(() => onAnswer(true, true, true))} selected={selectedIndex === 2} />
|
||||
<PromptAction keyLabel="4" label={t("approval.deny")} onClick={() => answerWithExit(() => onAnswer(false, false, false))} selected={selectedIndex === 3} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{approval.reason && <div className="approval-reason">{approval.reason}</div>}
|
||||
{subject && (
|
||||
<pre className="approval-subject">{subject}</pre>
|
||||
{detailsOpen && (
|
||||
<div className="approval-details">
|
||||
{approval.reason && <div className="approval-reason">{approval.reason}</div>}
|
||||
{subject && (
|
||||
<pre className="approval-subject">{subject}</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</PromptShelf>
|
||||
</div>
|
||||
|
||||
@@ -22,8 +22,10 @@ export function AskCard({
|
||||
// Per-question state: selected option labels, and an optional typed answer.
|
||||
const [sel, setSel] = useState<Record<string, string[]>>({});
|
||||
const [custom, setCustom] = useState<Record<string, string>>({});
|
||||
const [customOpen, setCustomOpen] = useState(false);
|
||||
const [active, setActive] = useState(0);
|
||||
const shelfRef = useRef<HTMLDivElement | null>(null);
|
||||
const customInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const advanceTimer = useRef<number | null>(null);
|
||||
|
||||
const questions = ask.questions;
|
||||
@@ -36,6 +38,7 @@ export function AskCard({
|
||||
shelfRef.current?.focus();
|
||||
setSel({});
|
||||
setCustom({});
|
||||
setCustomOpen(false);
|
||||
setActive(0);
|
||||
if (advanceTimer.current != null) window.clearTimeout(advanceTimer.current);
|
||||
playAttentionChime();
|
||||
@@ -66,7 +69,15 @@ export function AskCard({
|
||||
(sel[question.id]?.length ?? 0) > 0 || (custom[question.id]?.trim() ?? "") !== "";
|
||||
|
||||
const currentAnswered = q ? answered(q) : false;
|
||||
const showSubmitAction = q ? q.multi || Boolean(custom[q.id]?.trim()) : false;
|
||||
const showSubmitAction = q ? q.multi || customOpen || Boolean(custom[q.id]?.trim()) : false;
|
||||
|
||||
useEffect(() => {
|
||||
setCustomOpen(false);
|
||||
}, [active]);
|
||||
|
||||
useEffect(() => {
|
||||
if (customOpen) customInputRef.current?.focus();
|
||||
}, [customOpen]);
|
||||
|
||||
const finishOrAdvance = (nextSel = sel, nextCustom = custom) => {
|
||||
if (advanceTimer.current != null) {
|
||||
@@ -89,6 +100,7 @@ export function AskCard({
|
||||
|
||||
setCustom(nextCustom);
|
||||
setSel(nextSel);
|
||||
setCustomOpen(false);
|
||||
|
||||
if (!question.multi) {
|
||||
if (advanceTimer.current != null) window.clearTimeout(advanceTimer.current);
|
||||
@@ -148,6 +160,7 @@ export function AskCard({
|
||||
|
||||
return (
|
||||
<PromptShelf
|
||||
className="prompt-shelf--compact prompt-shelf--ask"
|
||||
barRef={shelfRef}
|
||||
titleId="ask-shelf-title"
|
||||
title={t("ask.title")}
|
||||
@@ -165,6 +178,9 @@ export function AskCard({
|
||||
headerActions={
|
||||
<>
|
||||
<PromptHeaderAction onClick={onDismiss}>{t("ask.justChat")}</PromptHeaderAction>
|
||||
{!customOpen && (
|
||||
<PromptHeaderAction onClick={() => setCustomOpen(true)}>{t("ask.customAnswer")}</PromptHeaderAction>
|
||||
)}
|
||||
<PromptHeaderAction onClick={onStop} ariaLabel={t("composer.stopShort")}>Esc</PromptHeaderAction>
|
||||
</>
|
||||
}
|
||||
@@ -213,8 +229,10 @@ export function AskCard({
|
||||
)
|
||||
}
|
||||
>
|
||||
{customOpen && (
|
||||
<div className="ask-shelf__custom-row">
|
||||
<input
|
||||
ref={customInputRef}
|
||||
className="ask-shelf__custom"
|
||||
placeholder={t("ask.customPlaceholder")}
|
||||
value={custom[q.id] ?? ""}
|
||||
@@ -225,6 +243,7 @@ export function AskCard({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PromptShelf>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { asArray } from "../lib/array";
|
||||
import { app, openExternal } from "../lib/bridge";
|
||||
import { useT } from "../lib/i18n";
|
||||
import { mcpServerLifecycleActions, mcpServerRetryableFromAvailableList } from "../lib/mcpServerLifecycle";
|
||||
import type { CapabilitiesView, MCPServerInput, ServerView, SkillRootSkillView, SkillRootView, SkillsSettingsView, SkillView, TabMeta } from "../lib/types";
|
||||
import type { CapabilitiesView, MCPServerInput, PluginHookView, PluginInstallOptions, PluginMCPServerView, PluginSkillView, PluginView, ServerView, SkillRootSkillView, SkillRootView, SkillsSettingsView, SkillView, TabMeta } from "../lib/types";
|
||||
import { InlineConfirmButton } from "./InlineConfirmButton";
|
||||
import { ResizableDrawer } from "./ResizableDrawer";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
@@ -20,6 +20,7 @@ type SettingsSnapshot<T> = { key: string; value: T };
|
||||
|
||||
let mcpSettingsSnapshot: SettingsSnapshot<ServerView[]> | null = null;
|
||||
let skillsSettingsSnapshot: SettingsSnapshot<SkillsSettingsView> | null = null;
|
||||
let pluginsSettingsSnapshot: SettingsSnapshot<PluginView[]> | null = null;
|
||||
|
||||
function settingsSnapshotKey(meta: Awaited<ReturnType<typeof app.Meta>> | null | undefined, tabs: TabMeta[] | null | undefined): string {
|
||||
const active = tabs?.find((tab) => tab.active);
|
||||
@@ -50,7 +51,7 @@ export function CapabilitiesPanel({
|
||||
const [expandedServerTools, setExpandedServerTools] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setView(normalizeCapabilitiesView(await app.Capabilities().catch(() => ({ servers: [], skills: [], skillRoots: [] }))));
|
||||
setView(normalizeCapabilitiesView(await app.Capabilities().catch(() => ({ servers: [], skills: [], skillRoots: [], plugins: [] }))));
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
@@ -314,6 +315,7 @@ export function CapabilitiesPanel({
|
||||
function normalizeCapabilitiesView(view: CapabilitiesView | null | undefined): CapabilitiesView {
|
||||
return {
|
||||
servers: normalizeServerViews(view?.servers),
|
||||
plugins: asArray(view?.plugins),
|
||||
...normalizeSkillsSettingsView(view),
|
||||
};
|
||||
}
|
||||
@@ -1590,6 +1592,619 @@ function AddServerForm({
|
||||
);
|
||||
}
|
||||
|
||||
type PluginInstallPlanAction = {
|
||||
action?: string;
|
||||
kind?: string;
|
||||
name?: string;
|
||||
source?: string;
|
||||
status?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type PluginInstallPlanView = {
|
||||
raw: string;
|
||||
ok?: boolean;
|
||||
status?: string;
|
||||
name?: string;
|
||||
actions: PluginInstallPlanAction[];
|
||||
warnings: string[];
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type PluginInstallMode = "local" | "git";
|
||||
|
||||
// PluginsSettingsPage is the desktop plugin package manager embedded inside
|
||||
// Settings. It mirrors the MCP/Skills density: install planning on top, package
|
||||
// rows below, and diagnostics/details only when a row is expanded.
|
||||
export function PluginsSettingsPage() {
|
||||
const t = useT();
|
||||
const [snapshotKey, setSnapshotKey] = useState("");
|
||||
const [plugins, setPlugins] = useState<PluginView[] | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [installMode, setInstallMode] = useState<PluginInstallMode>("local");
|
||||
const [localSource, setLocalSource] = useState("");
|
||||
const [gitSource, setGitSource] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [link, setLink] = useState(false);
|
||||
const [replace, setReplace] = useState(false);
|
||||
const [plan, setPlan] = useState<PluginInstallPlanView | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
|
||||
const [diagnostics, setDiagnostics] = useState<Record<string, PluginView>>({});
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const [meta, tabs] = await Promise.all([
|
||||
app.Meta().catch(() => null),
|
||||
app.ListTabs().catch(() => []),
|
||||
]);
|
||||
const key = settingsSnapshotKey(meta, tabs);
|
||||
setSnapshotKey(key);
|
||||
const cached = key ? pluginsSettingsSnapshot : null;
|
||||
if (cached?.key === key) {
|
||||
setPlugins(cached.value);
|
||||
} else {
|
||||
setPlugins(null);
|
||||
}
|
||||
const next = normalizePluginViews(await app.Plugins().catch(() => []));
|
||||
pluginsSettingsSnapshot = { key, value: next };
|
||||
setPlugins(next);
|
||||
}, []);
|
||||
useEffect(() => { void reload(); }, [reload]);
|
||||
|
||||
const run = async (fn: () => Promise<unknown>, reloadAfter = true) => {
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const result = await fn();
|
||||
if (typeof result === "string" && result.trim()) {
|
||||
const parsed = parsePluginInstallPlan(result);
|
||||
setNotice(pluginPlanNotice(parsed, t));
|
||||
}
|
||||
if (reloadAfter) await reload();
|
||||
return true;
|
||||
} catch (e) {
|
||||
setErr(String((e as Error)?.message ?? e));
|
||||
if (reloadAfter) await reload();
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sourceValue = (installMode === "local" ? localSource : gitSource).trim();
|
||||
const installOptions = (): PluginInstallOptions => ({
|
||||
dryRun: false,
|
||||
link: installMode === "local" ? link : false,
|
||||
replace,
|
||||
name: installMode === "git" ? name.trim() || undefined : undefined,
|
||||
});
|
||||
const actionBusy = busy || !snapshotKey || !plugins;
|
||||
const canPlan = sourceValue.length > 0 && !actionBusy;
|
||||
const summary = plugins ? pluginListSummary(plugins, t) : "";
|
||||
const togglePlugin = useCallback((pluginName: string) => {
|
||||
setExpanded((prev) => { const next = new Set(prev); if (next.has(pluginName)) next.delete(pluginName); else next.add(pluginName); return next; });
|
||||
}, []);
|
||||
const setMode = (mode: PluginInstallMode) => {
|
||||
setInstallMode(mode);
|
||||
setPlan(null);
|
||||
};
|
||||
const previewInstall = () => {
|
||||
if (!sourceValue) return;
|
||||
void run(async () => {
|
||||
const raw = await app.PlanPluginInstall(sourceValue, { ...installOptions(), dryRun: true });
|
||||
setPlan(parsePluginInstallPlan(raw));
|
||||
}, false);
|
||||
};
|
||||
const install = () => {
|
||||
if (!sourceValue) return;
|
||||
void run(async () => {
|
||||
const raw = await app.InstallPlugin(sourceValue, installOptions());
|
||||
setPlan(parsePluginInstallPlan(raw));
|
||||
return raw;
|
||||
});
|
||||
};
|
||||
const runDoctor = (pluginName: string) => {
|
||||
void run(async () => {
|
||||
const view = normalizePluginView(await app.PluginDoctor(pluginName));
|
||||
setDiagnostics((prev) => ({ ...prev, [pluginName]: view }));
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(pluginName);
|
||||
return next;
|
||||
});
|
||||
}, false);
|
||||
};
|
||||
const updateLocalSource = (value: string) => {
|
||||
setLocalSource(value);
|
||||
setPlan(null);
|
||||
};
|
||||
const updateGitSource = (value: string) => {
|
||||
setGitSource(value);
|
||||
setPlan(null);
|
||||
};
|
||||
const pickPluginFolder = () => {
|
||||
void run(async () => {
|
||||
const path = await app.PickPluginFolder();
|
||||
if (path) {
|
||||
setInstallMode("local");
|
||||
updateLocalSource(path);
|
||||
}
|
||||
}, false);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="mem-section">
|
||||
{err && <div className="banner banner--error">{err}</div>}
|
||||
{notice && !err && <div className="banner banner--success">{notice}</div>}
|
||||
<div className="cap-plugin-installer">
|
||||
<div className="cap-plugin-installer__head">
|
||||
<div className="cap-plugin-installer__copy">
|
||||
<div className="cap-plugin-installer__title">{t("caps.pluginInstallTitle")}</div>
|
||||
<div className="cap-plugin-installer__hint">{t("caps.pluginInstallHint")}</div>
|
||||
</div>
|
||||
<div className="cap-tabs cap-plugin-installer__mode" role="group" aria-label={t("caps.pluginInstallMethod")}>
|
||||
<button
|
||||
className={`cap-tab${installMode === "local" ? " cap-tab--active" : ""}`}
|
||||
type="button"
|
||||
aria-pressed={installMode === "local"}
|
||||
onClick={() => setMode("local")}
|
||||
>
|
||||
{t("caps.pluginInstallLocal")}
|
||||
</button>
|
||||
<button
|
||||
className={`cap-tab${installMode === "git" ? " cap-tab--active" : ""}`}
|
||||
type="button"
|
||||
aria-pressed={installMode === "git"}
|
||||
onClick={() => setMode("git")}
|
||||
>
|
||||
{t("caps.pluginInstallGit")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cap-plugin-form-grid">
|
||||
{installMode === "local" ? (
|
||||
<div className="cap-plugin-fields cap-plugin-fields--local">
|
||||
<div className="cap-plugin-folder-field">
|
||||
<button className="btn btn--small" disabled={actionBusy} type="button" onClick={pickPluginFolder}>
|
||||
{t("caps.pluginChooseLocalFolder")}
|
||||
</button>
|
||||
<div
|
||||
className={`cap-plugin-path${localSource ? "" : " cap-plugin-path--empty"}`}
|
||||
aria-label={t("caps.pluginLocalFolder")}
|
||||
>
|
||||
{localSource || t("caps.pluginNoLocalFolder")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cap-plugin-fields cap-plugin-fields--git">
|
||||
<input
|
||||
className="mem-input"
|
||||
aria-label={t("caps.pluginGitSource")}
|
||||
placeholder={t("caps.pluginSourcePlaceholder")}
|
||||
value={gitSource}
|
||||
onInput={(e) => updateGitSource(e.currentTarget.value)}
|
||||
onChange={(e) => updateGitSource(e.target.value)}
|
||||
/>
|
||||
<div className="cap-plugin-field">
|
||||
<input
|
||||
className="mem-input"
|
||||
aria-label={t("caps.pluginInstallName")}
|
||||
placeholder={t("caps.pluginInstallNamePlaceholder")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="cap-plugin-installer__options">
|
||||
<div className="cap-plugin-option-block">
|
||||
<label className="cap-plugin-option">
|
||||
<input type="checkbox" checked={replace} disabled={actionBusy} onChange={(e) => setReplace(e.target.checked)} />
|
||||
<span>{t("caps.pluginReplace")}</span>
|
||||
</label>
|
||||
<div className="cap-plugin-option-hint">{t("caps.pluginReplaceHint")}</div>
|
||||
</div>
|
||||
{installMode === "local" && (
|
||||
<div className="cap-plugin-option-block">
|
||||
<label className="cap-plugin-option">
|
||||
<input type="checkbox" checked={link} disabled={actionBusy} onChange={(e) => setLink(e.target.checked)} />
|
||||
<span>{t("caps.pluginLink")}</span>
|
||||
</label>
|
||||
<div className="cap-plugin-option-hint">{t("caps.pluginLinkHint")}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="cap-plugin-installer__actions">
|
||||
<button className="btn btn--small" type="button" disabled={!canPlan} onClick={previewInstall}>
|
||||
{t("caps.pluginPreview")}
|
||||
</button>
|
||||
<button className="btn btn--primary btn--small" type="button" disabled={!canPlan} onClick={install}>
|
||||
{t("caps.pluginInstall")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{plan && <PluginPlanPreview plan={plan} />}
|
||||
<div className="cap-server-section cap-plugin-section">
|
||||
<div className="cap-server-section__head">
|
||||
<div className="cap-server-section__copy">
|
||||
<div className="cap-server-section__title">{t("caps.installedPlugins")}</div>
|
||||
{plugins && plugins.length > 0 && <div className="drawer__summary">{summary}</div>}
|
||||
</div>
|
||||
<button className="btn btn--small" disabled={actionBusy} type="button" onClick={() => void reload()}>
|
||||
{t("caps.pluginRefresh")}
|
||||
</button>
|
||||
</div>
|
||||
{!plugins ? (
|
||||
<div className="mem-empty">{t("caps.loading")}</div>
|
||||
) : plugins.length === 0 ? (
|
||||
<div className="mem-empty mem-empty--cta">
|
||||
<strong>{t("caps.noPluginsTitle")}</strong>
|
||||
<span>{t("caps.noPluginsHint")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cap-server-group">
|
||||
{plugins.map((plugin) => (
|
||||
<PluginRow
|
||||
key={plugin.name}
|
||||
plugin={plugin}
|
||||
diagnostic={diagnostics[plugin.name]}
|
||||
busy={actionBusy}
|
||||
expanded={expanded.has(plugin.name)}
|
||||
onToggleDetails={() => togglePlugin(plugin.name)}
|
||||
onToggleEnabled={(enabled) => void run(() => app.SetPluginEnabled(plugin.name, enabled))}
|
||||
onUpdate={() => void run(() => app.UpdatePlugin(plugin.name))}
|
||||
onDoctor={() => runDoctor(plugin.name)}
|
||||
onRemove={() => void run(() => app.RemovePlugin(plugin.name))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginPlanPreview({ plan }: { plan: PluginInstallPlanView }) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div className={`cap-plugin-plan${plan.error ? " cap-plugin-plan--error" : ""}`}>
|
||||
<div className="cap-plugin-plan__head">
|
||||
<div className="cap-plugin-plan__title">{plan.error ? t("caps.pluginPlanError") : t("caps.pluginPlanReady")}</div>
|
||||
{plan.status && <span className="cap-source-badge">{plan.status}</span>}
|
||||
</div>
|
||||
{plan.name && <div className="cap-plugin-plan__meta">{plan.name}</div>}
|
||||
{plan.error && <div className="cap-plugin-plan__warning">{plan.error}</div>}
|
||||
{plan.warnings.map((warning, idx) => (
|
||||
<div className="cap-plugin-plan__warning" key={`${warning}-${idx}`}>{warning}</div>
|
||||
))}
|
||||
{plan.actions.length > 0 ? (
|
||||
<div className="cap-plugin-actions">
|
||||
{plan.actions.map((action, idx) => (
|
||||
<div className="cap-plugin-action" key={`${action.action || action.kind || "action"}-${idx}`}>
|
||||
<span className="cap-plugin-action__name">{pluginPlanActionLabel(action, t)}</span>
|
||||
{action.status && <span className="cap-source-badge">{action.status}</span>}
|
||||
{action.source && <span className="cap-plugin-action__source">{action.source}</span>}
|
||||
{action.message && <span className="cap-plugin-action__source">{action.message}</span>}
|
||||
{action.error && <span className="cap-plugin-plan__warning">{action.error}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<pre className="cap-plugin-plan__raw">{plan.raw}</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginRow({
|
||||
plugin,
|
||||
diagnostic,
|
||||
busy,
|
||||
expanded,
|
||||
onToggleDetails,
|
||||
onToggleEnabled,
|
||||
onUpdate,
|
||||
onDoctor,
|
||||
onRemove,
|
||||
}: {
|
||||
plugin: PluginView;
|
||||
diagnostic?: PluginView;
|
||||
busy: boolean;
|
||||
expanded: boolean;
|
||||
onToggleDetails: () => void;
|
||||
onToggleEnabled: (enabled: boolean) => void;
|
||||
onUpdate: () => void;
|
||||
onDoctor: () => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const status = plugin.error ? "failed" : plugin.enabled ? "connected" : "disabled";
|
||||
const warnings = pluginWarnings(plugin, diagnostic);
|
||||
const sub = plugin.error || pluginCapabilitiesSummary(plugin, t);
|
||||
return (
|
||||
<div className={`cap-server-entry cap-plugin-entry${plugin.enabled ? "" : " cap-server-entry--disabled"}`}>
|
||||
<Tooltip label={plugin.error} disabled={!plugin.error} fill block>
|
||||
<div className={`cap-row${plugin.enabled ? "" : " cap-row--disabled"}`}>
|
||||
<Tooltip label={expanded ? t("caps.collapseDetails") : t("caps.expandDetails")}>
|
||||
<button
|
||||
className="cap-disclosure"
|
||||
aria-expanded={expanded}
|
||||
type="button"
|
||||
onClick={onToggleDetails}
|
||||
>
|
||||
{expanded ? "⌄" : "›"}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<span className={`cap-dot cap-dot--${status}`} />
|
||||
<div className="cap-row__text">
|
||||
<div className="cap-row__head">
|
||||
<span className="cap-row__name">{plugin.name}</span>
|
||||
{plugin.manifestKind && <span className="cap-row__transport">{plugin.manifestKind}</span>}
|
||||
{plugin.version && <span className="cap-source-badge">{plugin.version}</span>}
|
||||
{warnings.length > 0 && <span className="cap-row__update cap-row__update--error">{t("caps.pluginWarnings", { count: warnings.length })}</span>}
|
||||
</div>
|
||||
<div className="cap-row__sub">{sub}</div>
|
||||
</div>
|
||||
<div className="cap-row__actions">
|
||||
<Tooltip label={plugin.enabled ? t("caps.pluginDisable") : t("caps.pluginEnable")}>
|
||||
<label className="cap-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={plugin.enabled}
|
||||
disabled={busy}
|
||||
onChange={(e) => onToggleEnabled(e.target.checked)}
|
||||
/>
|
||||
<span className="cap-switch__track" />
|
||||
</label>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{expanded && (
|
||||
<div className="cap-server-details">
|
||||
<div className="cap-detail-grid">
|
||||
<div className="cap-detail">
|
||||
<span className="cap-detail__label">{t("caps.status")}</span>
|
||||
<span className="cap-detail__value">{plugin.enabled ? t("caps.pluginEnabled") : t("caps.pluginDisabled")}</span>
|
||||
</div>
|
||||
{plugin.version && (
|
||||
<div className="cap-detail">
|
||||
<span className="cap-detail__label">{t("caps.pluginVersion")}</span>
|
||||
<span className="cap-detail__value">{plugin.version}</span>
|
||||
</div>
|
||||
)}
|
||||
{plugin.source && (
|
||||
<div className="cap-detail cap-detail--wide">
|
||||
<span className="cap-detail__label">{t("caps.pluginSource")}</span>
|
||||
<span className="cap-detail__code">{plugin.source}</span>
|
||||
</div>
|
||||
)}
|
||||
{plugin.root && (
|
||||
<div className="cap-detail cap-detail--wide">
|
||||
<span className="cap-detail__label">{t("caps.pluginRoot")}</span>
|
||||
<span className="cap-detail__code">{plugin.root}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{plugin.description && <div className="cap-plugin-description">{plugin.description}</div>}
|
||||
<PluginUsageDetails plugin={plugin} />
|
||||
{diagnostic?.error && <div className="cap-source__warning">{diagnostic.error}</div>}
|
||||
{warnings.map((warning, idx) => (
|
||||
<div className="cap-source__warning" key={`${plugin.name}-warning-${idx}`}>{warning}</div>
|
||||
))}
|
||||
<div className="cap-detail-actions">
|
||||
<button className="btn btn--small" disabled={busy} type="button" onClick={onUpdate}>
|
||||
{t("caps.pluginUpdate")}
|
||||
</button>
|
||||
<button className="btn btn--small" disabled={busy} type="button" onClick={onDoctor}>
|
||||
{t("caps.pluginDoctor")}
|
||||
</button>
|
||||
<InlineConfirmButton
|
||||
label={t("caps.pluginRemove")}
|
||||
confirmLabel={t("caps.pluginConfirmRemove")}
|
||||
cancelLabel={t("common.cancel")}
|
||||
disabled={busy}
|
||||
danger
|
||||
onConfirm={onRemove}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginUsageDetails({ plugin }: { plugin: PluginView }) {
|
||||
const t = useT();
|
||||
const skills = asArray(plugin.skillDetails);
|
||||
const hooks = asArray(plugin.hookDetails);
|
||||
const mcps = asArray(plugin.mcpServerDetails);
|
||||
const hasDetails = skills.length > 0 || hooks.length > 0 || mcps.length > 0;
|
||||
return (
|
||||
<div className="cap-plugin-usage">
|
||||
<div className="cap-plugin-usage__title">{t("caps.pluginUsageTitle")}</div>
|
||||
<div className="cap-plugin-usage__hint">
|
||||
{plugin.enabled ? t("caps.pluginUsageEnabledHint") : t("caps.pluginUsageDisabledHint")}
|
||||
</div>
|
||||
{hasDetails ? (
|
||||
<div className="cap-plugin-capabilities">
|
||||
{skills.length > 0 && <PluginSkillList skills={skills} />}
|
||||
{hooks.length > 0 && <PluginHookList hooks={hooks} />}
|
||||
{mcps.length > 0 && <PluginMCPList servers={mcps} />}
|
||||
</div>
|
||||
) : (
|
||||
<div className="cap-plugin-usage__empty">{t("caps.pluginNoCapabilityDetails")}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginSkillList({ skills }: { skills: PluginSkillView[] }) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div className="cap-plugin-capability">
|
||||
<div className="cap-plugin-capability__head">{t("caps.pluginSkillsTitle")}</div>
|
||||
<div className="cap-plugin-capability__hint">{t("caps.pluginSkillsHint")}</div>
|
||||
<div className="cap-plugin-capability__list">
|
||||
{skills.map((skill) => (
|
||||
<div className="cap-plugin-capability__item" key={`${skill.name}-${skill.path || skill.invocation || ""}`}>
|
||||
<div className="cap-plugin-capability__line">
|
||||
<span className="cap-plugin-capability__name">{skill.invocation || `/${skill.name}`}</span>
|
||||
{skill.runAs && <span className="cap-source-badge">{skill.runAs}</span>}
|
||||
</div>
|
||||
<div className="cap-plugin-capability__desc">{skill.description || t("caps.pluginNoDescription")}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginHookList({ hooks }: { hooks: PluginHookView[] }) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div className="cap-plugin-capability">
|
||||
<div className="cap-plugin-capability__head">{t("caps.pluginHooksTitle")}</div>
|
||||
<div className="cap-plugin-capability__hint">{t("caps.pluginHooksHint")}</div>
|
||||
<div className="cap-plugin-capability__list">
|
||||
{hooks.map((hook, idx) => {
|
||||
const target = hook.command || hook.contextFile || t("caps.pluginHookNoTarget");
|
||||
return (
|
||||
<div className="cap-plugin-capability__item" key={`${hook.event}-${hook.match || "*"}-${target}-${idx}`}>
|
||||
<div className="cap-plugin-capability__line">
|
||||
<span className="cap-plugin-capability__name">{hook.event}</span>
|
||||
<span className="cap-source-badge">{hook.match || "*"}</span>
|
||||
</div>
|
||||
<div className="cap-plugin-capability__desc">{hook.description || target}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginMCPList({ servers }: { servers: PluginMCPServerView[] }) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div className="cap-plugin-capability">
|
||||
<div className="cap-plugin-capability__head">{t("caps.pluginMCPTitle")}</div>
|
||||
<div className="cap-plugin-capability__hint">{t("caps.pluginMCPHint")}</div>
|
||||
<div className="cap-plugin-capability__list">
|
||||
{servers.map((server) => (
|
||||
<div className="cap-plugin-capability__item" key={server.name}>
|
||||
<div className="cap-plugin-capability__line">
|
||||
<span className="cap-plugin-capability__name">{server.name}</span>
|
||||
{server.transport && <span className="cap-source-badge">{server.transport}</span>}
|
||||
</div>
|
||||
<div className="cap-plugin-capability__desc">{server.command || server.url || t("caps.pluginMCPNoTarget")}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizePluginViews(plugins: PluginView[] | null | undefined): PluginView[] {
|
||||
return sortPluginsForDisplay(asArray(plugins).map(normalizePluginView));
|
||||
}
|
||||
|
||||
function normalizePluginView(plugin: PluginView): PluginView {
|
||||
return {
|
||||
...plugin,
|
||||
name: plugin.name || "plugin",
|
||||
root: plugin.root || "",
|
||||
enabled: Boolean(plugin.enabled),
|
||||
skills: Number.isFinite(plugin.skills) ? plugin.skills : 0,
|
||||
hooks: Number.isFinite(plugin.hooks) ? plugin.hooks : 0,
|
||||
mcpServers: Number.isFinite(plugin.mcpServers) ? plugin.mcpServers : 0,
|
||||
skillDetails: asArray(plugin.skillDetails),
|
||||
hookDetails: asArray(plugin.hookDetails),
|
||||
mcpServerDetails: asArray(plugin.mcpServerDetails),
|
||||
warnings: asArray(plugin.warnings),
|
||||
};
|
||||
}
|
||||
|
||||
function sortPluginsForDisplay(plugins: PluginView[]): PluginView[] {
|
||||
return [...plugins].sort((a, b) => {
|
||||
const priority = pluginDisplayPriority(a) - pluginDisplayPriority(b);
|
||||
if (priority !== 0) return priority;
|
||||
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
|
||||
});
|
||||
}
|
||||
|
||||
function pluginDisplayPriority(plugin: PluginView): number {
|
||||
if (plugin.error) return 0;
|
||||
if (plugin.enabled) return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
function pluginListSummary(plugins: PluginView[], t: ReturnType<typeof useT>): string {
|
||||
const enabled = plugins.filter((plugin) => plugin.enabled && !plugin.error).length;
|
||||
const issues = plugins.filter((plugin) => Boolean(plugin.error) || asArray(plugin.warnings).length > 0).length;
|
||||
return t("caps.pluginsSummary", { enabled, total: plugins.length, issues });
|
||||
}
|
||||
|
||||
function pluginCapabilitiesSummary(plugin: PluginView, t: ReturnType<typeof useT>): string {
|
||||
if (plugin.skills === 0 && plugin.hooks === 0 && plugin.mcpServers === 0) return t("caps.pluginNoCapabilities");
|
||||
return t("caps.pluginCounts", { skills: plugin.skills, hooks: plugin.hooks, mcps: plugin.mcpServers });
|
||||
}
|
||||
|
||||
function pluginWarnings(plugin: PluginView, diagnostic?: PluginView): string[] {
|
||||
const warnings = [...asArray(plugin.warnings), ...asArray(diagnostic?.warnings)];
|
||||
return Array.from(new Set(warnings.filter((warning) => warning.trim().length > 0)));
|
||||
}
|
||||
|
||||
function parsePluginInstallPlan(raw: string): PluginInstallPlanView {
|
||||
try {
|
||||
const value = JSON.parse(raw) as Record<string, unknown>;
|
||||
const actions = (Array.isArray(value.actions) ? value.actions : []).flatMap((action) => {
|
||||
if (!action || typeof action !== "object") return [];
|
||||
const item = action as Record<string, unknown>;
|
||||
return [{
|
||||
action: stringValue(item.action),
|
||||
kind: stringValue(item.kind),
|
||||
name: stringValue(item.name),
|
||||
source: stringValue(item.source),
|
||||
status: stringValue(item.status),
|
||||
message: stringValue(item.message),
|
||||
error: stringValue(item.error),
|
||||
}];
|
||||
});
|
||||
return {
|
||||
raw,
|
||||
ok: typeof value.ok === "boolean" ? value.ok : undefined,
|
||||
status: stringValue(value.status),
|
||||
name: stringValue(value.name),
|
||||
actions,
|
||||
warnings: (Array.isArray(value.warnings) ? value.warnings : []).flatMap((warning) => typeof warning === "string" ? [warning] : []),
|
||||
error: stringValue(value.error),
|
||||
};
|
||||
} catch {
|
||||
return { raw, actions: [], warnings: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function pluginPlanActionLabel(action: PluginInstallPlanAction, t: ReturnType<typeof useT>): string {
|
||||
const verb = action.action || action.kind || t("caps.pluginAction");
|
||||
return [verb, action.name].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
function pluginPlanNotice(plan: PluginInstallPlanView, t: ReturnType<typeof useT>): string {
|
||||
if (plan.error) return plan.error;
|
||||
if (plan.status === "done" || plan.status === "applied" || plan.status === "complete") return t("caps.pluginPlanInstalled");
|
||||
return plan.status ? t("caps.pluginPlanStatus", { status: plan.status }) : t("caps.pluginPlanComplete");
|
||||
}
|
||||
|
||||
// MCPServersSettingsPage is a self-contained MCP servers management page
|
||||
// embedded inside the settings centre.
|
||||
export function MCPServersSettingsPage() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import type { CSSProperties, ClipboardEvent, DragEvent, KeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
|
||||
import { ArrowUp, Check, ChevronsUpDown, Eye, FileText, Folder, Gauge, List, MessageSquare, Search, Shield, ShieldAlert, ShieldCheck, SlidersHorizontal, Square, Target, Trash2, X } from "lucide-react";
|
||||
import { ArrowUp, Check, ChevronDown, ChevronUp, ChevronsUpDown, CornerDownRight, Eye, FileText, Folder, Gauge, List, MessageSquare, Search, Shield, ShieldAlert, ShieldCheck, SlidersHorizontal, Square, Target, Trash2, X } from "lucide-react";
|
||||
import { asArray } from "../lib/array";
|
||||
import { filterAtMatches } from "../lib/atMatches";
|
||||
import { DedupIndex, sha256 } from "../lib/attachDedup";
|
||||
@@ -26,6 +26,7 @@ import { EffortSwitcher } from "./EffortSwitcher";
|
||||
import { ModelSwitcher } from "./ModelSwitcher";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { ComposerContextCard } from "./ComposerContextCard";
|
||||
import { ImageViewer } from "./ImageViewer";
|
||||
import { VirtualMenu } from "./VirtualMenu";
|
||||
import { dirEntryMenuLabel, dirEntrySubmitPath } from "./FileReferenceMenu";
|
||||
|
||||
@@ -57,12 +58,24 @@ const PROMPT_HISTORY_PREFETCH_REMAINING = 3;
|
||||
// it; the real gap is a few ms, so keep it short or a deliberate quick second
|
||||
// Enter (submit) gets eaten too.
|
||||
const IME_CONFIRM_GRACE_MS = 100;
|
||||
const FILE_REF_SEARCH_CACHE_TTL_MS = 5000;
|
||||
|
||||
type PastedBlock = {
|
||||
label: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
type PendingGuidance = {
|
||||
id: number;
|
||||
text: string;
|
||||
submitText: string;
|
||||
};
|
||||
|
||||
type FileRefSearchCacheEntry = {
|
||||
entries: DirEntry[];
|
||||
cachedAt: number;
|
||||
};
|
||||
|
||||
type ComposerDraft = {
|
||||
text: string;
|
||||
attachments: Attachment[];
|
||||
@@ -163,6 +176,13 @@ function emptyComposerDraft(): ComposerDraft {
|
||||
};
|
||||
}
|
||||
|
||||
function guidanceTextMatches(queued: string, consumed: string): boolean {
|
||||
const left = queued.trim();
|
||||
const right = consumed.trim();
|
||||
if (!left || !right) return false;
|
||||
return left === right || left.includes(right) || right.includes(left);
|
||||
}
|
||||
|
||||
function cloneComposerDraft(draft: ComposerDraft): ComposerDraft {
|
||||
return {
|
||||
text: draft.text,
|
||||
@@ -431,6 +451,10 @@ export function Composer({
|
||||
retry,
|
||||
transientDismissSignal,
|
||||
sessionKey,
|
||||
fileRefRefreshKey,
|
||||
guidanceConsumedKey,
|
||||
guidanceConsumedText,
|
||||
guidanceQueuePreviewItems,
|
||||
}: {
|
||||
running: boolean;
|
||||
collaborationMode: CollaborationMode;
|
||||
@@ -470,6 +494,10 @@ export function Composer({
|
||||
retry?: { attempt: number; max: number };
|
||||
transientDismissSignal?: number;
|
||||
sessionKey?: string;
|
||||
fileRefRefreshKey?: number | string;
|
||||
guidanceConsumedKey?: string;
|
||||
guidanceConsumedText?: string;
|
||||
guidanceQueuePreviewItems?: readonly string[];
|
||||
}) {
|
||||
const { t, locale } = useI18n();
|
||||
const { showToast } = useToast();
|
||||
@@ -477,6 +505,15 @@ export function Composer({
|
||||
const now = useTick(running);
|
||||
const [text, setText] = useState("");
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const [imageViewer, setImageViewer] = useState<{ open: boolean; url: string; name: string }>({ open: false, url: "", name: "" });
|
||||
const openComposerImageViewer = useCallback((url: string, name: string) => {
|
||||
setImageViewer({ open: true, url, name });
|
||||
}, []);
|
||||
|
||||
const closeComposerImageViewer = useCallback(() => {
|
||||
setImageViewer((prev) => (prev.open ? { ...prev, open: false } : prev));
|
||||
}, []);
|
||||
|
||||
const [workspaceRefs, setWorkspaceRefs] = useState<WorkspaceReference[]>([]);
|
||||
const [pastedBlocks, setPastedBlocks] = useState<PastedBlock[]>([]);
|
||||
const [openPastedLabels, setOpenPastedLabels] = useState<string[]>([]);
|
||||
@@ -498,6 +535,10 @@ export function Composer({
|
||||
const [pastChats, setPastChats] = useState<SessionMeta[]>([]);
|
||||
const [pastChatQuery, setPastChatQuery] = useState("");
|
||||
const [sessionRefs, setSessionRefs] = useState<SessionReference[]>([]);
|
||||
const [pendingGuidance, setPendingGuidance] = useState<PendingGuidance[]>([]);
|
||||
const [guidanceExpanded, setGuidanceExpanded] = useState(false);
|
||||
const [guidanceSendingId, setGuidanceSendingId] = useState<number | null>(null);
|
||||
const nextGuidanceId = useRef(1);
|
||||
const [loadingPastChats, setLoadingPastChats] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [composerPrompt, setComposerPrompt] = useState<string | null>(null);
|
||||
@@ -524,6 +565,8 @@ export function Composer({
|
||||
const lastSelectionRef = useRef({ start: 0, end: 0 });
|
||||
const consumedInsertIdRef = useRef(0);
|
||||
const lastTransientDismissSignal = useRef(transientDismissSignal);
|
||||
const lastGuidanceConsumedKey = useRef(guidanceConsumedKey);
|
||||
const selfDispatchedGuidanceRef = useRef<string[]>([]);
|
||||
const submittingRef = useRef(false);
|
||||
const nativeClipboardPasteTimerRef = useRef<number | null>(null);
|
||||
// Snapshot of the current cwd so async callbacks (openPastChats) can detect
|
||||
@@ -533,6 +576,7 @@ export function Composer({
|
||||
const attachmentDedupRef = useRef(new DedupIndex());
|
||||
const attachmentDedupKeysRef = useRef<Record<string, AttachmentDedupKey>>({});
|
||||
const draftKey = sessionKey || tabId || DEFAULT_COMPOSER_DRAFT_KEY;
|
||||
const guidanceQueuePreviewKey = (guidanceQueuePreviewItems ?? []).map((item) => item.trim()).filter(Boolean).join("\n");
|
||||
const draftsBySessionRef = useRef<Record<string, ComposerDraft>>({});
|
||||
const activeDraftKeyRef = useRef(draftKey);
|
||||
const textRef = useRef(text);
|
||||
@@ -609,14 +653,37 @@ export function Composer({
|
||||
useEffect(() => () => clearNativeClipboardPasteTimer(), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (wasRunning.current && !running && text.trim() === "") {
|
||||
pastedBlocksRef.current = [];
|
||||
setPastedBlocks([]);
|
||||
setOpenPastedLabels([]);
|
||||
if (wasRunning.current && !running) {
|
||||
setPendingGuidance([]);
|
||||
setGuidanceExpanded(false);
|
||||
if (text.trim() === "") {
|
||||
pastedBlocksRef.current = [];
|
||||
setPastedBlocks([]);
|
||||
setOpenPastedLabels([]);
|
||||
}
|
||||
}
|
||||
wasRunning.current = running;
|
||||
}, [running, text]);
|
||||
|
||||
useEffect(() => {
|
||||
setPendingGuidance([]);
|
||||
setGuidanceExpanded(false);
|
||||
}, [draftKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!running || !guidanceQueuePreviewKey) return;
|
||||
setGuidanceExpanded(false);
|
||||
setPendingGuidance(
|
||||
guidanceQueuePreviewKey
|
||||
.split("\n")
|
||||
.map((text) => ({ id: nextGuidanceId.current++, text, submitText: text })),
|
||||
);
|
||||
}, [guidanceQueuePreviewKey, running]);
|
||||
|
||||
useEffect(() => {
|
||||
if (guidanceExpanded && pendingGuidance.length <= 2) setGuidanceExpanded(false);
|
||||
}, [guidanceExpanded, pendingGuidance.length]);
|
||||
|
||||
// --- slash commands (whole-input "/token") ---
|
||||
const [commands, setCommands] = useState<CommandInfo[]>([]);
|
||||
useEffect(() => {
|
||||
@@ -694,16 +761,9 @@ export function Composer({
|
||||
const [entries, setEntries] = useState<DirEntry[]>([]);
|
||||
const [searchEntries, setSearchEntries] = useState<DirEntry[]>([]);
|
||||
const dirCache = useRef<Record<string, DirEntry[]>>({});
|
||||
const searchCache = useRef<Record<string, DirEntry[]>>({});
|
||||
const searchCache = useRef<Record<string, FileRefSearchCacheEntry>>({});
|
||||
|
||||
// When the workspace/project changes (cwd prop), invalidate all @ mention
|
||||
// state so the picker reloads candidates for the new project. Without this,
|
||||
// dirCache/searchCache retain entries from the old project and the picker
|
||||
// shows stale results (issue #3601).
|
||||
const prevCwdRef = useRef(cwd);
|
||||
useEffect(() => {
|
||||
if (prevCwdRef.current === cwd) return; // skip mount — state already initial
|
||||
prevCwdRef.current = cwd;
|
||||
const clearFileRefState = useCallback(() => {
|
||||
dirCache.current = {};
|
||||
searchCache.current = {};
|
||||
setEntries([]);
|
||||
@@ -714,29 +774,50 @@ export function Composer({
|
||||
setLoadingPastChats(false);
|
||||
setActive(0);
|
||||
setDismissed(false);
|
||||
}, [cwd]);
|
||||
}, []);
|
||||
|
||||
// When the workspace/project changes (cwd prop), invalidate all @ mention
|
||||
// state so the picker reloads candidates for the new project. Without this,
|
||||
// dirCache/searchCache retain entries from the old project and the picker
|
||||
// shows stale results (issue #3601).
|
||||
const prevCwdRef = useRef(cwd);
|
||||
useEffect(() => {
|
||||
if (prevCwdRef.current === cwd) return; // skip mount — state already initial
|
||||
prevCwdRef.current = cwd;
|
||||
clearFileRefState();
|
||||
}, [clearFileRefState, cwd]);
|
||||
|
||||
const prevFileRefRefreshKeyRef = useRef(fileRefRefreshKey);
|
||||
useEffect(() => {
|
||||
if (prevFileRefRefreshKeyRef.current === fileRefRefreshKey) return;
|
||||
prevFileRefRefreshKeyRef.current = fileRefRefreshKey;
|
||||
clearFileRefState();
|
||||
}, [clearFileRefState, fileRefRefreshKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (atRaw === null) return;
|
||||
const cached = dirCache.current[atDir];
|
||||
if (cached) {
|
||||
setEntries(cached);
|
||||
return;
|
||||
} else {
|
||||
setEntries([]);
|
||||
}
|
||||
let live = true;
|
||||
app
|
||||
.ListDir(atDir)
|
||||
.then((es) => {
|
||||
const list = asArray(es);
|
||||
if (!live) return;
|
||||
dirCache.current[atDir] = list;
|
||||
if (live) setEntries(list);
|
||||
setEntries(list);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// re-fetch when the menu opens or the directory level changes
|
||||
}, [atRaw === null, atDir, cwd]);
|
||||
// Re-fetch when the menu opens, the directory level changes, or the
|
||||
// workspace tree refreshes; cached data is only a fast first paint.
|
||||
}, [atRaw === null, atDir, cwd, fileRefRefreshKey]);
|
||||
useEffect(() => {
|
||||
if (atRaw === null || atDir !== "" || atFrag === "") {
|
||||
setSearchEntries([]);
|
||||
@@ -744,23 +825,25 @@ export function Composer({
|
||||
}
|
||||
const cached = searchCache.current[atFrag];
|
||||
if (cached) {
|
||||
setSearchEntries(cached);
|
||||
return;
|
||||
setSearchEntries(cached.entries);
|
||||
if (Date.now() - cached.cachedAt < FILE_REF_SEARCH_CACHE_TTL_MS) return;
|
||||
} else {
|
||||
setSearchEntries([]);
|
||||
}
|
||||
setSearchEntries([]);
|
||||
let live = true;
|
||||
app
|
||||
.SearchFileRefs(atFrag)
|
||||
.then((es) => {
|
||||
const list = es ?? [];
|
||||
searchCache.current[atFrag] = list;
|
||||
if (live) setSearchEntries(list);
|
||||
const list = asArray(es);
|
||||
if (!live) return;
|
||||
searchCache.current[atFrag] = { entries: list, cachedAt: Date.now() };
|
||||
setSearchEntries(list);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [atRaw === null, atDir, atFrag, cwd]);
|
||||
}, [atRaw === null, atDir, atFrag, cwd, fileRefRefreshKey]);
|
||||
const atMatches = useMemo(
|
||||
() => {
|
||||
if (atRaw === null) return [];
|
||||
@@ -817,6 +900,28 @@ export function Composer({
|
||||
setDismissed(true);
|
||||
}, [transientDismissSignal]);
|
||||
|
||||
const takeSelfDispatchedGuidance = useCallback((text: string): boolean => {
|
||||
const idx = selfDispatchedGuidanceRef.current.findIndex((queued) => guidanceTextMatches(queued, text));
|
||||
if (idx < 0) return false;
|
||||
selfDispatchedGuidanceRef.current.splice(idx, 1);
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!guidanceConsumedKey || guidanceConsumedKey === lastGuidanceConsumedKey.current) return;
|
||||
lastGuidanceConsumedKey.current = guidanceConsumedKey;
|
||||
const consumed = (guidanceConsumedText ?? "").trim();
|
||||
if (consumed && takeSelfDispatchedGuidance(consumed)) return;
|
||||
setPendingGuidance((items) => {
|
||||
if (items.length === 0) return items;
|
||||
const idx = consumed
|
||||
? items.findIndex((item) => guidanceTextMatches(item.submitText, consumed) || guidanceTextMatches(item.text, consumed))
|
||||
: -1;
|
||||
const removeAt = idx >= 0 ? idx : 0;
|
||||
return items.filter((_, index) => index !== removeAt);
|
||||
});
|
||||
}, [guidanceConsumedKey, guidanceConsumedText, takeSelfDispatchedGuidance]);
|
||||
|
||||
// When the @ trigger disappears (user deleted the @), close the past:chats
|
||||
// sub-menu and reset related state. Without this, showPastChats can outlive
|
||||
// the @ token and leave the session list visible with no way to dismiss it.
|
||||
@@ -1111,7 +1216,7 @@ export function Composer({
|
||||
}, [showToast, t]);
|
||||
|
||||
const submit = async () => {
|
||||
if (disabled || submitDisabled || readOnly || submittingRef.current) return;
|
||||
if (disabled || (!running && submitDisabled) || readOnly || submittingRef.current) return;
|
||||
const submitDraftKey = activeDraftKeyRef.current;
|
||||
const currentText = textRef.current;
|
||||
const trimmedText = currentText.trim();
|
||||
@@ -1150,6 +1255,16 @@ export function Composer({
|
||||
const sessionContext = currentSessionRefs.length === 0 ? "" : await buildSessionContext(currentSessionRefs);
|
||||
const baseSubmitText = [expandPastedBlocks(trimmedText), refs].filter(Boolean).join(trimmedText && refs ? " " : "");
|
||||
const submitText = sessionContext ? `${sessionContext}${baseSubmitText}` : baseSubmitText;
|
||||
if (running) {
|
||||
const guidanceText = displayText.trim();
|
||||
const guidanceSubmitText = submitText.trim();
|
||||
if (guidanceText) {
|
||||
const id = nextGuidanceId.current++;
|
||||
setPendingGuidance((items) => [...items, { id, text: guidanceText, submitText: guidanceSubmitText || guidanceText }]);
|
||||
}
|
||||
clearSubmittedDraft(submitDraftKey);
|
||||
return;
|
||||
}
|
||||
await onSend(displayText, submitText);
|
||||
clearSubmittedDraft(submitDraftKey);
|
||||
} catch (error) {
|
||||
@@ -1160,6 +1275,27 @@ export function Composer({
|
||||
}
|
||||
};
|
||||
|
||||
const sendQueuedGuidance = async (item: PendingGuidance) => {
|
||||
if (disabled || readOnly || guidanceSendingId !== null) return;
|
||||
const displayText = item.text.trim();
|
||||
const submitText = item.submitText.trim() || displayText;
|
||||
if (!displayText || !submitText) return;
|
||||
selfDispatchedGuidanceRef.current.push(submitText);
|
||||
setGuidanceSendingId(item.id);
|
||||
try {
|
||||
await onSend(displayText, submitText);
|
||||
setPendingGuidance((items) => items.filter((queued) => queued.id !== item.id));
|
||||
window.setTimeout(() => {
|
||||
takeSelfDispatchedGuidance(submitText);
|
||||
}, 5000);
|
||||
} catch (error) {
|
||||
takeSelfDispatchedGuidance(submitText);
|
||||
showToast(error instanceof Error ? error.message : String(error), "warn");
|
||||
} finally {
|
||||
setGuidanceSendingId((current) => (current === item.id ? null : current));
|
||||
}
|
||||
};
|
||||
|
||||
const readFileAsDataURL = (file: File) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
@@ -1929,6 +2065,21 @@ export function Composer({
|
||||
return `${word}… ${fmtElapsed(elapsedMs)}${tok}`;
|
||||
})()
|
||||
: null;
|
||||
const submitEmpty = !text.trim() && attachments.length === 0 && workspaceRefs.length === 0;
|
||||
const submitBlocked = submitting || pendingPaste > 0 || (submitEmpty && !(goalModeOn && !activeGoal)) || disabled || (!running && submitDisabled) || readOnly;
|
||||
const submitTooltip = running ? t("composer.queueGuidance") : t("composer.send");
|
||||
const composerPlaceholder = readOnly
|
||||
? t("composer.readOnlyChannel")
|
||||
: disabled
|
||||
? t("common.loading")
|
||||
: running
|
||||
? t("composer.steerPlaceholder")
|
||||
: goalModeOn && !activeGoal
|
||||
? t("composer.goalInputPlaceholder")
|
||||
: t("composer.placeholder");
|
||||
const hiddenGuidanceCount = Math.max(0, pendingGuidance.length - 2);
|
||||
const visibleGuidance = guidanceExpanded ? pendingGuidance : pendingGuidance.slice(0, 2);
|
||||
const showGuidanceExpander = pendingGuidance.length > 2;
|
||||
const composerMetaClass = [
|
||||
"composer-meta",
|
||||
hasEffort ? "composer-meta--has-effort" : "composer-meta--no-effort",
|
||||
@@ -2185,6 +2336,58 @@ export function Composer({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{pendingGuidance.length > 0 && (
|
||||
<div className="composer-guidance-shelf" aria-label={t("composer.guidanceQueue")}>
|
||||
<div className="composer-guidance-head">
|
||||
<span className="composer-guidance-head__label">
|
||||
<CornerDownRight size={14} />
|
||||
<span>{t("composer.guidanceCount", { n: pendingGuidance.length })}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="composer-guidance-list">
|
||||
{visibleGuidance.map((item) => (
|
||||
<div className="composer-guidance-item" key={item.id}>
|
||||
<CornerDownRight size={14} className="composer-guidance-item__icon" />
|
||||
<span className="composer-guidance-item__text">{item.text}</span>
|
||||
<Tooltip label={t("composer.guidanceSend")}>
|
||||
<button
|
||||
className="composer-guidance-item__guide"
|
||||
type="button"
|
||||
aria-label={t("composer.guidanceSend")}
|
||||
disabled={!running || disabled || readOnly || guidanceSendingId !== null}
|
||||
onClick={() => void sendQueuedGuidance(item)}
|
||||
>
|
||||
<CornerDownRight size={13} />
|
||||
<span>{t("composer.guidanceMode")}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("composer.guidanceDismiss")}>
|
||||
<button
|
||||
className="composer-guidance-item__action"
|
||||
type="button"
|
||||
aria-label={t("composer.guidanceDismiss")}
|
||||
disabled={guidanceSendingId === item.id}
|
||||
onClick={() => setPendingGuidance((items) => items.filter((queued) => queued.id !== item.id))}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
{showGuidanceExpander && (
|
||||
<button
|
||||
className="composer-guidance-more"
|
||||
type="button"
|
||||
aria-expanded={guidanceExpanded}
|
||||
onClick={() => setGuidanceExpanded((value) => !value)}
|
||||
>
|
||||
{guidanceExpanded ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
|
||||
<span>{guidanceExpanded ? t("composer.guidanceCollapse") : t("composer.guidanceRemaining", { n: hiddenGuidanceCount })}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(attachments.length > 0 || workspaceRefs.length > 0 || sessionRefs.length > 0) && (
|
||||
<div className="composer-context" aria-label={t("composer.contextItems")}>
|
||||
{sortComposerAttachments(attachments).map((a) => {
|
||||
@@ -2193,10 +2396,11 @@ export function Composer({
|
||||
<ComposerContextCard
|
||||
key={a.path}
|
||||
variant="attachment"
|
||||
tooltipLabel={a.path}
|
||||
tooltipLabel={a.previewUrl ? `${t("imageViewer.clickToPreview")} — ${a.path}` : a.path}
|
||||
removeLabel={t("composer.removeImage")}
|
||||
onRemove={() => removeAttachment(a.path)}
|
||||
previewUrl={a.previewUrl}
|
||||
onImageClick={a.previewUrl ? () => openComposerImageViewer(a.previewUrl!, attachmentName(a)) : undefined}
|
||||
imageOnly={imageOnly}
|
||||
name={attachmentName(a)}
|
||||
meta={attachmentExt(attachmentName(a)) || t("msg.fileAttachment")}
|
||||
@@ -2240,6 +2444,12 @@ export function Composer({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ImageViewer
|
||||
open={imageViewer.open}
|
||||
imageUrl={imageViewer.url}
|
||||
imageName={imageViewer.name}
|
||||
onClose={closeComposerImageViewer}
|
||||
/>
|
||||
{activePastedBlocks.length > 0 && (
|
||||
<div className="composer__pasted">
|
||||
{activePastedBlocks.map((block) => {
|
||||
@@ -2324,7 +2534,7 @@ export function Composer({
|
||||
lastCompositionEndAt.current = Date.now();
|
||||
}}
|
||||
style={textareaStyle}
|
||||
placeholder={readOnly ? t("composer.readOnlyChannel") : disabled ? t("common.loading") : goalModeOn && !activeGoal ? t("composer.goalInputPlaceholder") : t("composer.placeholder")}
|
||||
placeholder={composerPlaceholder}
|
||||
rows={1}
|
||||
disabled={disabled || readOnly}
|
||||
/>
|
||||
@@ -2333,17 +2543,16 @@ export function Composer({
|
||||
{composerPrompt}
|
||||
</span>
|
||||
)}
|
||||
{!running && (
|
||||
<Tooltip label={t("composer.send")}>
|
||||
<button
|
||||
className="composer__btn composer__btn--send"
|
||||
onClick={submit}
|
||||
disabled={submitting || pendingPaste > 0 || ((!text.trim() && attachments.length === 0 && workspaceRefs.length === 0) && !(goalModeOn && !activeGoal)) || disabled || submitDisabled || readOnly}
|
||||
>
|
||||
<ArrowUp size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={submitTooltip}>
|
||||
<button
|
||||
className={`composer__btn composer__btn--send${running ? " composer__btn--steer" : ""}`}
|
||||
onClick={submit}
|
||||
disabled={submitBlocked}
|
||||
aria-label={submitTooltip}
|
||||
>
|
||||
<ArrowUp size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={composerMetaClass}>
|
||||
<div className="composer-meta__params">
|
||||
|
||||
@@ -12,6 +12,7 @@ export function ComposerContextCard({
|
||||
removeDisabled = false,
|
||||
removeIconSize,
|
||||
previewUrl,
|
||||
onImageClick,
|
||||
imageOnly = false,
|
||||
folder = false,
|
||||
name,
|
||||
@@ -26,6 +27,8 @@ export function ComposerContextCard({
|
||||
removeDisabled?: boolean;
|
||||
removeIconSize?: number;
|
||||
previewUrl?: string;
|
||||
/** called when the image thumbnail is clicked (only for image attachments with previewUrl) */
|
||||
onImageClick?: () => void;
|
||||
imageOnly?: boolean;
|
||||
folder?: boolean;
|
||||
name?: string;
|
||||
@@ -46,7 +49,13 @@ export function ComposerContextCard({
|
||||
<Tooltip label={tooltipLabel}>
|
||||
<span className="composer-context__label">
|
||||
{previewUrl ? (
|
||||
<span className="composer-context__thumb">
|
||||
<span
|
||||
className={`composer-context__thumb${onImageClick ? " composer-context__thumb--interactive" : ""}`}
|
||||
onClick={onImageClick}
|
||||
role={onImageClick ? "button" : undefined}
|
||||
tabIndex={onImageClick ? 0 : undefined}
|
||||
onKeyDown={onImageClick ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onImageClick(); } } : undefined}
|
||||
>
|
||||
<img src={previewUrl} alt="" draggable={false} />
|
||||
</span>
|
||||
) : variant === "attachment" ? (
|
||||
|
||||
@@ -23,9 +23,9 @@ interface ContextPanelProps {
|
||||
refreshKey?: number;
|
||||
}
|
||||
|
||||
function fmtTokens(n: number): string {
|
||||
if (n >= 1000) return `${Math.round(n / 1000)}k`;
|
||||
return String(n);
|
||||
function fmtFullTokens(n: number): string {
|
||||
if (n <= 0) return "0";
|
||||
return String(Math.round(n));
|
||||
}
|
||||
|
||||
function fmtDuration(ms: number, t: Translator): string {
|
||||
@@ -62,11 +62,6 @@ export function formatMetricTokens(tokens: number | undefined, locale: Locale |
|
||||
return { display: exact, exact };
|
||||
}
|
||||
|
||||
function fmtTurns(turns: number | undefined, t: Translator): string {
|
||||
if (typeof turns !== "number" || turns < 0) return "-";
|
||||
return t(turns === 1 ? "history.turnOne" : "history.turnOther", { n: turns });
|
||||
}
|
||||
|
||||
function fmtUsageCacheRate(usage?: WireUsage): string {
|
||||
if (!usage) return "-";
|
||||
const denom = usage.cacheHitTokens + usage.cacheMissTokens;
|
||||
@@ -80,10 +75,44 @@ export function formatCacheHitRate(hitTokens: number, missTokens: number): strin
|
||||
return `${((hitTokens / denom) * 100).toFixed(2)}%`;
|
||||
}
|
||||
|
||||
interface HealthResult {
|
||||
type MetricTone = "accent" | "good" | "notice" | "warn";
|
||||
type UsageAnalysisView = "source" | "type";
|
||||
type ContextUsageRefreshFields = Pick<
|
||||
WireUsage,
|
||||
"totalTokens" | "promptTokens" | "completionTokens" | "reasoningTokens" | "sessionCacheHitTokens" | "sessionCacheMissTokens"
|
||||
>;
|
||||
|
||||
export function contextUsageRefreshKey(usage?: ContextUsageRefreshFields): string {
|
||||
if (!usage) return "";
|
||||
return [
|
||||
usage.totalTokens ?? 0,
|
||||
usage.promptTokens ?? 0,
|
||||
usage.completionTokens ?? 0,
|
||||
usage.reasoningTokens ?? 0,
|
||||
usage.sessionCacheHitTokens ?? 0,
|
||||
usage.sessionCacheMissTokens ?? 0,
|
||||
].join(":");
|
||||
}
|
||||
|
||||
export function cacheHitTone(hitTokens: number, missTokens: number): MetricTone | undefined {
|
||||
const denom = hitTokens + missTokens;
|
||||
if (denom <= 0) return undefined;
|
||||
const pct = (hitTokens / denom) * 100;
|
||||
if (pct >= 80) return "good";
|
||||
if (pct >= 60) return "notice";
|
||||
return "warn";
|
||||
}
|
||||
|
||||
function formatSharePercent(value: number, total: number): string {
|
||||
if (total <= 0 || value <= 0) return "-";
|
||||
const pct = (value / total) * 100;
|
||||
if (pct > 0 && pct < 1) return "<1%";
|
||||
return `${Math.round(pct)}%`;
|
||||
}
|
||||
|
||||
interface ContextWindowStatus {
|
||||
tone: "good" | "notice" | "warn";
|
||||
shortKey: DictKey;
|
||||
vars: Record<string, string | number>;
|
||||
key: DictKey;
|
||||
}
|
||||
|
||||
export function contextCostDisplay({
|
||||
@@ -171,33 +200,11 @@ export function contextBreakdown(
|
||||
};
|
||||
}
|
||||
|
||||
function contextHealth(usagePct: number, cachePct: number, readCount: number): HealthResult {
|
||||
if (usagePct >= 85) {
|
||||
return {
|
||||
tone: "warn",
|
||||
shortKey: "context.healthNearLimitShort",
|
||||
vars: { pct: usagePct },
|
||||
};
|
||||
}
|
||||
if (readCount >= 8) {
|
||||
return {
|
||||
tone: "notice",
|
||||
shortKey: "context.healthManyFilesShort",
|
||||
vars: { count: readCount },
|
||||
};
|
||||
}
|
||||
if (cachePct > 0 && cachePct < 50) {
|
||||
return {
|
||||
tone: "notice",
|
||||
shortKey: "context.healthLowCacheShort",
|
||||
vars: { pct: cachePct },
|
||||
};
|
||||
}
|
||||
return {
|
||||
tone: "good",
|
||||
shortKey: "context.healthGoodShort",
|
||||
vars: {},
|
||||
};
|
||||
export function contextWindowStatus(usagePct: number, compactPct: number): ContextWindowStatus {
|
||||
if (usagePct >= 90) return { tone: "warn", key: "context.windowStatusNearLimit" };
|
||||
if (compactPct > 0 && usagePct >= compactPct) return { tone: "warn", key: "context.windowStatusPastCompact" };
|
||||
if (compactPct > 0 && usagePct >= Math.max(0, compactPct - 10)) return { tone: "notice", key: "context.windowStatusWatch" };
|
||||
return { tone: "good", key: "context.windowStatusHealthy" };
|
||||
}
|
||||
|
||||
const SOURCE_ORDER = ["executor", "planner", "subagent", "compaction", "classifier", "title"];
|
||||
@@ -286,7 +293,6 @@ export function ContextPanel({
|
||||
sessionTokens,
|
||||
sessionCost,
|
||||
sessionCurrency,
|
||||
sessionTurns,
|
||||
turnTokens,
|
||||
turnCost,
|
||||
balance,
|
||||
@@ -295,7 +301,10 @@ export function ContextPanel({
|
||||
}: ContextPanelProps) {
|
||||
const { locale, t } = useI18n();
|
||||
const [info, setInfo] = useState<ContextPanelInfo | null>(null);
|
||||
const [analysisView, setAnalysisView] = useState<UsageAnalysisView>("source");
|
||||
const refreshSeq = useRef(0);
|
||||
const lastRefreshTime = useRef(0);
|
||||
const usageRefreshKey = contextUsageRefreshKey(usage);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!tabId) return;
|
||||
@@ -320,19 +329,23 @@ export function ContextPanel({
|
||||
void refresh();
|
||||
}, [refresh, refreshKey]);
|
||||
|
||||
const hasPanelUsage = Boolean(
|
||||
(info?.requestCount ?? 0) > 0 ||
|
||||
(info?.promptTokens ?? 0) > 0 ||
|
||||
(info?.completionTokens ?? 0) > 0 ||
|
||||
(info?.totalTokens ?? 0) > 0 ||
|
||||
(info?.reasoningTokens ?? 0) > 0 ||
|
||||
(info?.cacheHitTokens ?? 0) > 0 ||
|
||||
(info?.cacheMissTokens ?? 0) > 0
|
||||
);
|
||||
// Refresh the panel snapshot while usage events stream. The key includes
|
||||
// general token fields so providers without cache telemetry still tick.
|
||||
useEffect(() => {
|
||||
if (!usageRefreshKey) return;
|
||||
const now = Date.now();
|
||||
if (now - lastRefreshTime.current >= 1000) {
|
||||
lastRefreshTime.current = now;
|
||||
void refresh();
|
||||
}
|
||||
}, [usageRefreshKey, refresh]);
|
||||
|
||||
const usedTokens = context?.used && context.used > 0 ? context.used : info?.usedTokens ?? 0;
|
||||
const windowTokens = context?.window && context.window > 0 ? context.window : info?.windowTokens ?? 0;
|
||||
const promptTokens = hasPanelUsage ? info?.promptTokens ?? 0 : usage?.promptTokens ?? 0;
|
||||
const completionTokens = hasPanelUsage ? info?.completionTokens ?? 0 : usage?.completionTokens ?? 0;
|
||||
// Prefer live usage props (updated in real-time by the reducer during streaming)
|
||||
// over the async-fetched info snapshot (only refreshed on turn_done).
|
||||
const promptTokens = usage?.promptTokens ?? info?.promptTokens ?? 0;
|
||||
const completionTokens = usage?.completionTokens ?? info?.completionTokens ?? 0;
|
||||
const totalTokens = info?.totalTokens && info.totalTokens > 0
|
||||
? info.totalTokens
|
||||
: sessionTokens && sessionTokens > 0
|
||||
@@ -340,33 +353,28 @@ export function ContextPanel({
|
||||
: usage?.totalTokens && usage.totalTokens > 0
|
||||
? usage.totalTokens
|
||||
: promptTokens + completionTokens;
|
||||
const reasoningTokens = hasPanelUsage ? info?.reasoningTokens ?? 0 : usage?.reasoningTokens ?? 0;
|
||||
const cacheHitTokens = hasPanelUsage ? info?.cacheHitTokens ?? 0 : usage?.cacheHitTokens ?? 0;
|
||||
const cacheMissTokens = hasPanelUsage ? info?.cacheMissTokens ?? 0 : usage?.cacheMissTokens ?? 0;
|
||||
// Session-cumulative values for the metrics cards (方案A: 纯前端改数据源)
|
||||
const sessionCacheHit = info?.sessionCacheHitTokens ?? usage?.sessionCacheHitTokens ?? context?.cacheHitTokens ?? 0;
|
||||
const sessionCacheMiss = info?.sessionCacheMissTokens ?? usage?.sessionCacheMissTokens ?? context?.cacheMissTokens ?? 0;
|
||||
const sessionCompletion = info?.sessionCompletionTokens ?? 0;
|
||||
const sessionCacheHitMetric = formatMetricTokens(sessionCacheHit, locale);
|
||||
const sessionCacheMissMetric = formatMetricTokens(sessionCacheMiss, locale);
|
||||
const sessionCompletionMetric = formatMetricTokens(sessionCompletion, locale);
|
||||
const reasoningTokens = usage?.reasoningTokens ?? info?.reasoningTokens ?? 0;
|
||||
// Session-cumulative values for the top summary.
|
||||
// Prefer usage.sessionCacheHitTokens — it is the session-cumulative value from
|
||||
// the Go agent (includes background tasks) and arrives with every usage event.
|
||||
const sessionCacheHit = usage?.sessionCacheHitTokens ?? info?.sessionCacheHitTokens ?? context?.cacheHitTokens ?? 0;
|
||||
const sessionCacheMiss = usage?.sessionCacheMissTokens ?? info?.sessionCacheMissTokens ?? context?.cacheMissTokens ?? 0;
|
||||
const totalTokensMetric = formatMetricTokens(totalTokens, locale);
|
||||
const cost = contextCostDisplay({ info, sessionCost, sessionCurrency, usage });
|
||||
const sourceUsageRows = contextSourceRows(info, sessionCurrency);
|
||||
const showSourceUsageRows = sourceUsageRows.length > 0;
|
||||
const sourceTotalTokens = sourceUsageRows.reduce((sum, row) => sum + sourceTokenTotal(row), 0);
|
||||
const visibleSourceRows = sourceUsageRows.slice(0, 3);
|
||||
const hiddenSourceRows = sourceUsageRows.slice(3);
|
||||
const readFiles = asArray(info?.readFiles);
|
||||
const changedFiles = asArray(info?.changedFiles);
|
||||
|
||||
const usagePct = windowTokens > 0 ? Math.min(100, Math.round((usedTokens / windowTokens) * 100)) : 0;
|
||||
const compactPct = context?.compactRatio ? Math.round(context.compactRatio * 100) : 0;
|
||||
const cacheDenom = cacheHitTokens + cacheMissTokens;
|
||||
const cachePct = cacheDenom > 0 ? (cacheHitTokens / cacheDenom) * 100 : 0;
|
||||
const cachePctDisplay = formatCacheHitRate(cacheHitTokens, cacheMissTokens);
|
||||
const compactRatio = context?.compactRatio && context.compactRatio > 0 ? context.compactRatio : 0.8;
|
||||
const compactPct = Math.round(compactRatio * 100);
|
||||
const compactTokens = windowTokens > 0 ? Math.round(windowTokens * compactRatio) : 0;
|
||||
const tokensUntilCompact = compactTokens > usedTokens ? compactTokens - usedTokens : 0;
|
||||
const breakdown = contextBreakdown(usedTokens, windowTokens, promptTokens, completionTokens, reasoningTokens);
|
||||
const donutStyle = {
|
||||
background: `conic-gradient(#13a7a5 0 ${breakdown.promptPct}%, #2f6df6 ${breakdown.promptPct}% ${breakdown.completionPct}%, #f97316 ${breakdown.completionPct}% ${breakdown.reasoningPct}%, var(--border) ${breakdown.reasoningPct}% ${breakdown.otherPct}%, var(--border-soft) ${breakdown.otherPct}% 100%)`,
|
||||
};
|
||||
const eventTimes = [
|
||||
...readFiles.map((file) => file.time),
|
||||
...changedFiles.map((file) => file.latestTime ?? 0),
|
||||
@@ -375,100 +383,165 @@ export function ContextPanel({
|
||||
const elapsed = info?.elapsedMs && info.elapsedMs > 0 ? info.elapsedMs : derivedElapsed;
|
||||
const derivedRequestCount = Math.max(readFiles.length + changedFiles.length, 0);
|
||||
const requestCount = info?.requestCount && info.requestCount > 0 ? info.requestCount : derivedRequestCount;
|
||||
const health = contextHealth(usagePct, Math.round(cachePct), readFiles.length);
|
||||
const windowStatus = contextWindowStatus(usagePct, compactPct);
|
||||
const balanceLabel = balance?.available && balance.display ? balance.display : "-";
|
||||
const turnCostLabel = formatMoneyLocalized(turnCost, sessionCurrency, { locale, empty: "dash" });
|
||||
const sessionCostLabel = formatMoneyLocalized(sessionCost, sessionCurrency, { locale, empty: "dash" });
|
||||
const sessionCostLabel = formatMoneyLocalized(cost.amount, cost.currency, { locale, empty: "dash" });
|
||||
const totalTokensTitle = totalTokensMetric.exact === "-" ? "-" : t("context.tokensValue", { value: totalTokensMetric.exact });
|
||||
const usedLabel = fmtFullTokens(usedTokens);
|
||||
const windowLabel = fmtFullTokens(windowTokens);
|
||||
const compactRemainingLabel = tokensUntilCompact > 0 ? fmtFullTokens(tokensUntilCompact) : "0";
|
||||
const compactMarkerPct = Math.max(0, Math.min(100, compactPct));
|
||||
const usageMarkerPct = Math.max(6, Math.min(94, usagePct));
|
||||
const compactLabelPct = Math.max(6, Math.min(94, compactMarkerPct));
|
||||
const usageSummary = t("context.windowUsageSummary", { used: usedLabel, window: windowLabel, pct: usagePct });
|
||||
const compactSummary = t("context.windowCompactRemaining", { used: usedLabel, window: windowLabel, tokens: compactRemainingLabel, pct: compactPct });
|
||||
const activeAnalysisView: UsageAnalysisView = showSourceUsageRows ? analysisView : "type";
|
||||
const tokenTypeRows = [
|
||||
{ key: "prompt", label: t("context.prompt"), value: breakdown.promptTokens },
|
||||
{ key: "completion", label: t("context.completion"), value: breakdown.completionTokens },
|
||||
{ key: "reasoning", label: t("context.reasoning"), value: breakdown.reasoningTokens },
|
||||
{ key: "other", label: t("context.other"), value: breakdown.otherTokens },
|
||||
];
|
||||
const tokenCompositionTotal = tokenTypeRows.reduce((sum, row) => sum + row.value, 0);
|
||||
const renderSourceRow = (row: ContextSourceRow) => {
|
||||
const inputMetric = formatMetricTokens(row.promptTokens, locale);
|
||||
const outputMetric = formatMetricTokens(row.completionTokens, locale);
|
||||
const hitMetric = formatMetricTokens(row.cacheHitTokens, locale);
|
||||
const missMetric = formatMetricTokens(row.cacheMissTokens, locale);
|
||||
const totalMetric = formatMetricTokens(sourceTokenTotal(row), locale);
|
||||
const cacheReported = row.cacheHitTokens + row.cacheMissTokens > 0;
|
||||
const cacheRate = cacheReported ? formatCacheHitRate(row.cacheHitTokens, row.cacheMissTokens) : t("context.cacheNotReported");
|
||||
const costLabel = formatMoneyLocalized(row.cost, row.currency, { locale, empty: "dash" });
|
||||
return (
|
||||
<div className="context-panel__source-row" key={row.source}>
|
||||
<div className="context-panel__source-head">
|
||||
<span>
|
||||
<i className={`context-panel__source-dot context-panel__source-tone--${sourceTone(row.source)}`} aria-hidden="true" />
|
||||
{sourceLabel(row.label, t)}
|
||||
</span>
|
||||
<em>{t("context.sourceRequests", { count: row.requests })}</em>
|
||||
</div>
|
||||
<div className="context-panel__source-summary">
|
||||
<SourceMetric label={t("context.total")} value={totalMetric.display} title={totalMetric.exact} />
|
||||
<SourceMetric label={t("context.sourceCacheRate")} value={cacheRate} />
|
||||
<SourceMetric label={t("context.sourceCost")} value={costLabel} />
|
||||
</div>
|
||||
<details className="context-panel__source-details">
|
||||
<summary>{t("context.sourceDetails")}</summary>
|
||||
<div className="context-panel__source-details-body">
|
||||
<SourceSplitBar
|
||||
label={`${t("context.sourceInput")}/${t("context.sourceOutput")}`}
|
||||
segments={[
|
||||
{ label: t("context.sourceInput"), value: row.promptTokens, tone: "input" },
|
||||
{ label: t("context.sourceOutput"), value: row.completionTokens, tone: "output" },
|
||||
]}
|
||||
/>
|
||||
{cacheReported ? (
|
||||
<SourceSplitBar
|
||||
label={`${t("context.sourceCacheHit")}/${t("context.sourceCacheMiss")}`}
|
||||
segments={[
|
||||
{ label: t("context.sourceCacheHit"), value: row.cacheHitTokens, tone: "hit" },
|
||||
{ label: t("context.sourceCacheMiss"), value: row.cacheMissTokens, tone: "miss" },
|
||||
]}
|
||||
compact
|
||||
/>
|
||||
) : (
|
||||
<SourceSplitBar label={`${t("context.sourceCacheHit")}/${t("context.sourceCacheMiss")}`} segments={[]} compact />
|
||||
)}
|
||||
<div className="context-panel__source-metrics">
|
||||
<SourceMetric label={t("context.sourceInput")} value={inputMetric.display} title={inputMetric.exact} />
|
||||
<SourceMetric label={t("context.sourceOutput")} value={outputMetric.display} title={outputMetric.exact} />
|
||||
<SourceMetric label={t("context.sourceCacheHit")} value={hitMetric.display} title={hitMetric.exact} />
|
||||
<SourceMetric label={t("context.sourceCacheMiss")} value={missMetric.display} title={missMetric.exact} />
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="context-panel">
|
||||
<div className="context-panel__body">
|
||||
<section className="context-panel__overview">
|
||||
<section className="context-panel__usage">
|
||||
<SectionHeading title={t("context.windowTitle")} meta={t("context.windowSubtitle")} />
|
||||
<div className="context-panel__usage-visual">
|
||||
<div className="context-panel__donut" style={donutStyle}>
|
||||
<div className="context-panel__donut-core">
|
||||
<strong>{fmtTokens(usedTokens)}</strong>
|
||||
<span>/ {fmtTokens(windowTokens)} tokens</span>
|
||||
<SectionHeading title={t("context.windowTitle")} />
|
||||
<div className={`context-panel__capacity-card context-panel__capacity-card--${windowStatus.tone}`}>
|
||||
<div className="context-panel__capacity-top">
|
||||
<span className="context-panel__capacity-status">{t(windowStatus.key)}</span>
|
||||
<strong>{usedLabel}/{windowLabel}</strong>
|
||||
</div>
|
||||
<div className="context-panel__usage-progress context-panel__capacity-meter" aria-label={`${t(windowStatus.key)}. ${usageSummary}. ${compactSummary}`}>
|
||||
<div className="context-panel__capacity-scale" aria-hidden="true">
|
||||
<span className="context-panel__capacity-pin context-panel__capacity-pin--used" style={{ left: `${usageMarkerPct}%` }}>{usagePct}%</span>
|
||||
<span className="context-panel__capacity-pin context-panel__capacity-pin--compact" style={{ left: `${compactLabelPct}%` }}>{compactPct}%</span>
|
||||
</div>
|
||||
<div className="context-panel__progress-track" aria-hidden="true">
|
||||
<span className="context-panel__progress-segment context-panel__progress-segment--prompt" style={{ width: `${breakdown.promptPct}%` }} />
|
||||
<span className="context-panel__progress-segment context-panel__progress-segment--completion" style={{ width: `${Math.max(0, breakdown.completionPct - breakdown.promptPct)}%` }} />
|
||||
<span className="context-panel__progress-segment context-panel__progress-segment--reasoning" style={{ width: `${Math.max(0, breakdown.reasoningPct - breakdown.completionPct)}%` }} />
|
||||
<span className="context-panel__progress-segment context-panel__progress-segment--other" style={{ width: `${Math.max(0, breakdown.otherPct - breakdown.reasoningPct)}%` }} />
|
||||
<span className="context-panel__compact-marker" style={{ left: `${compactMarkerPct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="context-panel__percent">{usagePct}%</div>
|
||||
</div>
|
||||
<div className="context-panel__usage-progress" aria-label={t("context.windowSubtitle")}>
|
||||
<div className="context-panel__progress-head">
|
||||
<strong>{fmtTokens(usedTokens)} / {fmtTokens(windowTokens)}</strong>
|
||||
<span>{usagePct}%</span>
|
||||
</div>
|
||||
<div className="context-panel__progress-track" aria-hidden="true">
|
||||
<span className="context-panel__progress-fill" style={{ width: `${usagePct}%` }} />
|
||||
<div className="context-panel__capacity-foot">
|
||||
<span>{t("context.windowUsedLabel")}</span>
|
||||
<span className="context-panel__capacity-remaining">
|
||||
<span>{t("context.windowCompactDistance")}</span>
|
||||
<strong>{compactRemainingLabel}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="context-panel__summary-rows">
|
||||
<MiniStat label={t("status.compactLabel")} value={compactPct > 0 ? `${compactPct}%` : "-"} />
|
||||
<MiniStat label={t("status.cacheAvgLabel")} value={formatCacheHitRate(sessionCacheHit, sessionCacheMiss)} />
|
||||
<MiniStat label={t("context.sessionCost")} value={sessionCostLabel} />
|
||||
<MiniStat label={t("status.sessionTurnsLabel")} value={fmtTurns(sessionTurns, t)} />
|
||||
</div>
|
||||
<div className="context-panel__breakdown">
|
||||
<TokenLegend label={t("context.prompt")} value={breakdown.promptTokens} color="prompt" />
|
||||
<TokenLegend label={t("context.completion")} value={breakdown.completionTokens} color="completion" />
|
||||
<TokenLegend label={t("context.reasoning")} value={breakdown.reasoningTokens} color="reasoning" />
|
||||
<TokenLegend label={t("context.other")} value={breakdown.otherTokens} color="other" />
|
||||
<div className="context-panel__total">
|
||||
<span>{t("context.total")}</span>
|
||||
<strong>{usedTokens.toLocaleString()} / {windowTokens.toLocaleString()}</strong>
|
||||
</section>
|
||||
<section className="context-panel__section context-panel__session-section">
|
||||
<SectionHeading title={t("context.sessionMetrics")} />
|
||||
<div className="context-panel__session-metrics">
|
||||
<div className="context-panel__summary-rows">
|
||||
<MiniStat label={t("status.cacheAvgLabel")} value={formatCacheHitRate(sessionCacheHit, sessionCacheMiss)} tone={cacheHitTone(sessionCacheHit, sessionCacheMiss)} />
|
||||
<MiniStat label={t("context.sessionCost")} value={sessionCostLabel} />
|
||||
<MiniStat label={t("context.time")} value={fmtDuration(elapsed, t)} />
|
||||
<MiniStat label={t("context.requests")} value={requestCount > 0 ? String(requestCount) : "-"} />
|
||||
<MiniStat label={t("context.sessionTokensShort")} value={totalTokensMetric.display} title={totalTokensTitle} wide />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="context-panel__creation-grid" aria-label={t("context.overview")}>
|
||||
<MetricCard label={t("context.time")} value={fmtDuration(elapsed, t)} />
|
||||
<MetricCard label={t("context.requests")} value={requestCount > 0 ? String(requestCount) : "-"} />
|
||||
<MetricCard label={t("status.cacheLabel")} value={fmtUsageCacheRate(usage)} tone="accent" />
|
||||
<MetricCard label={t("status.turnTokensLabel")} value={fmtOptionalTokens(turnTokens)} />
|
||||
<MetricCard label={t("status.turnCostLabel")} value={turnCostLabel} />
|
||||
<MetricCard label={t("status.balanceLabel")} value={balanceLabel} tone="accent" />
|
||||
</section>
|
||||
<section className="context-panel__section">
|
||||
<SectionHeading title={t("context.runtimeMetrics")} />
|
||||
<div className="context-panel__runtime-card">
|
||||
<div className="context-panel__runtime-summary">
|
||||
<RuntimeMetric label={t("context.time")} value={fmtDuration(elapsed, t)} />
|
||||
<RuntimeMetric label={t("context.requests")} value={requestCount > 0 ? String(requestCount) : "-"} />
|
||||
<RuntimeMetric label={t("context.outputTokens")} value={sessionCompletionMetric.display} title={sessionCompletionMetric.exact} />
|
||||
<RuntimeMetric label={t("context.sessionTokens")} value={totalTokensMetric.display} title={totalTokensMetric.exact} />
|
||||
</div>
|
||||
<div className="context-panel__runtime-cache">
|
||||
<div className="context-panel__runtime-cache-head">
|
||||
<span>{t("context.inputCache")}</span>
|
||||
<strong>{formatCacheHitRate(sessionCacheHit, sessionCacheMiss)}</strong>
|
||||
<section className="context-panel__section context-panel__analysis">
|
||||
<SectionHeading title={t("context.usageAnalysis")}>
|
||||
{showSourceUsageRows && (
|
||||
<div className="context-panel__view-switch" role="tablist" aria-label={t("context.usageAnalysisView")}>
|
||||
<button
|
||||
type="button"
|
||||
className={`context-panel__view-tab${activeAnalysisView === "source" ? " context-panel__view-tab--active" : ""}`}
|
||||
role="tab"
|
||||
aria-selected={activeAnalysisView === "source"}
|
||||
onClick={() => setAnalysisView("source")}
|
||||
>
|
||||
{t("context.usageAnalysisSource")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`context-panel__view-tab${activeAnalysisView === "type" ? " context-panel__view-tab--active" : ""}`}
|
||||
role="tab"
|
||||
aria-selected={activeAnalysisView === "type"}
|
||||
onClick={() => setAnalysisView("type")}
|
||||
>
|
||||
{t("context.usageAnalysisType")}
|
||||
</button>
|
||||
</div>
|
||||
<SourceSplitBar
|
||||
label={`${t("context.sourceCacheHit")}/${t("context.sourceCacheMiss")}`}
|
||||
segments={[
|
||||
{ label: t("context.sourceCacheHit"), value: sessionCacheHit, tone: "hit" },
|
||||
{ label: t("context.sourceCacheMiss"), value: sessionCacheMiss, tone: "miss" },
|
||||
]}
|
||||
/>
|
||||
<div className="context-panel__runtime-cache-values">
|
||||
<SourceMetric label={t("context.sourceCacheHit")} value={sessionCacheHitMetric.display} title={sessionCacheHitMetric.exact} />
|
||||
<SourceMetric label={t("context.sourceCacheMiss")} value={sessionCacheMissMetric.display} title={sessionCacheMissMetric.exact} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="context-panel__section">
|
||||
<SectionHeading title={t("context.costMetrics")} />
|
||||
<div className="context-panel__stats">
|
||||
<MetricCard label={t("context.cacheHit")} value={cachePctDisplay} tone="accent" />
|
||||
<MetricCard label={t("context.sessionCost")} value={formatMoneyLocalized(cost.amount, cost.currency, { locale, empty: "dash" })} />
|
||||
</div>
|
||||
{showSourceUsageRows && (
|
||||
<div className="context-panel__source-list" aria-label={t("context.sourceBreakdown")}>
|
||||
)}
|
||||
</SectionHeading>
|
||||
{activeAnalysisView === "source" ? (
|
||||
<div className="context-panel__source-list" aria-label={t("context.sourceBreakdown")} role="tabpanel">
|
||||
<div className="context-panel__source-overview">
|
||||
<div className="context-panel__source-overview-head">
|
||||
<strong>{t("context.sourceBreakdown")}</strong>
|
||||
<span>{t("context.sourceShareTokens")}</span>
|
||||
<strong>{t("context.sourceShareTitle")}</strong>
|
||||
</div>
|
||||
<div className="context-panel__source-sharebar" aria-hidden="true">
|
||||
{sourceUsageRows.map((row) => {
|
||||
@@ -495,70 +568,51 @@ export function ContextPanel({
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{sourceUsageRows.map((row) => {
|
||||
const inputMetric = formatMetricTokens(row.promptTokens, locale);
|
||||
const outputMetric = formatMetricTokens(row.completionTokens, locale);
|
||||
const hitMetric = formatMetricTokens(row.cacheHitTokens, locale);
|
||||
const missMetric = formatMetricTokens(row.cacheMissTokens, locale);
|
||||
const totalMetric = formatMetricTokens(sourceTokenTotal(row), locale);
|
||||
const cacheReported = row.cacheHitTokens + row.cacheMissTokens > 0;
|
||||
const cacheRate = cacheReported ? formatCacheHitRate(row.cacheHitTokens, row.cacheMissTokens) : t("context.cacheNotReported");
|
||||
const costLabel = formatMoneyLocalized(row.cost, row.currency, { locale, empty: "dash" });
|
||||
return (
|
||||
<div className="context-panel__source-row" key={row.source}>
|
||||
<div className="context-panel__source-head">
|
||||
<span>
|
||||
<i className={`context-panel__source-dot context-panel__source-tone--${sourceTone(row.source)}`} aria-hidden="true" />
|
||||
{sourceLabel(row.label, t)}
|
||||
</span>
|
||||
<em>{t("context.sourceRequests", { count: row.requests })}</em>
|
||||
</div>
|
||||
<div className="context-panel__source-summary">
|
||||
<SourceMetric label={t("context.total")} value={totalMetric.display} title={totalMetric.exact} />
|
||||
<SourceMetric label={t("context.sourceCacheRate")} value={cacheRate} />
|
||||
<SourceMetric label={t("context.sourceCost")} value={costLabel} />
|
||||
</div>
|
||||
<SourceSplitBar
|
||||
label={`${t("context.sourceInput")}/${t("context.sourceOutput")}`}
|
||||
segments={[
|
||||
{ label: t("context.sourceInput"), value: row.promptTokens, tone: "input" },
|
||||
{ label: t("context.sourceOutput"), value: row.completionTokens, tone: "output" },
|
||||
]}
|
||||
/>
|
||||
{cacheReported ? (
|
||||
<SourceSplitBar
|
||||
label={`${t("context.sourceCacheHit")}/${t("context.sourceCacheMiss")}`}
|
||||
segments={[
|
||||
{ label: t("context.sourceCacheHit"), value: row.cacheHitTokens, tone: "hit" },
|
||||
{ label: t("context.sourceCacheMiss"), value: row.cacheMissTokens, tone: "miss" },
|
||||
]}
|
||||
compact
|
||||
/>
|
||||
) : (
|
||||
<SourceSplitBar label={`${t("context.sourceCacheHit")}/${t("context.sourceCacheMiss")}`} segments={[]} compact />
|
||||
)}
|
||||
<details className="context-panel__source-details">
|
||||
<summary>{t("context.sourceDetails")}</summary>
|
||||
<div className="context-panel__source-metrics">
|
||||
<SourceMetric label={t("context.sourceInput")} value={inputMetric.display} title={inputMetric.exact} />
|
||||
<SourceMetric label={t("context.sourceOutput")} value={outputMetric.display} title={outputMetric.exact} />
|
||||
<SourceMetric label={t("context.sourceCacheHit")} value={hitMetric.display} title={hitMetric.exact} />
|
||||
<SourceMetric label={t("context.sourceCacheMiss")} value={missMetric.display} title={missMetric.exact} />
|
||||
</div>
|
||||
</details>
|
||||
{visibleSourceRows.map(renderSourceRow)}
|
||||
{hiddenSourceRows.length > 0 && (
|
||||
<details className="context-panel__source-more">
|
||||
<summary>{t("context.moreSources", { count: hiddenSourceRows.length })}</summary>
|
||||
<div className="context-panel__source-more-list">
|
||||
{hiddenSourceRows.map(renderSourceRow)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="context-panel__type-panel" aria-label={t("context.tokenBreakdown")} role="tabpanel">
|
||||
<div className="context-panel__type-overview">
|
||||
<div className="context-panel__type-overview-head">
|
||||
<strong>{t("context.tokenBreakdown")}</strong>
|
||||
</div>
|
||||
<div className="context-panel__type-sharebar" aria-hidden="true">
|
||||
{tokenTypeRows.map((row) => row.value > 0 ? (
|
||||
<span
|
||||
className={`context-panel__type-share context-panel__type-share--${row.key}`}
|
||||
key={row.key}
|
||||
style={{ width: `${(row.value / Math.max(1, tokenCompositionTotal)) * 100}%` }}
|
||||
/>
|
||||
) : null)}
|
||||
</div>
|
||||
<div className="context-panel__type-legend">
|
||||
{tokenTypeRows.map((row) => (
|
||||
<span key={row.key}>
|
||||
<i className={`context-panel__type-dot context-panel__type-dot--${row.key}`} aria-hidden="true" />
|
||||
{row.label} {formatSharePercent(row.value, tokenCompositionTotal)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<details className="context-panel__breakdown-details">
|
||||
<summary>{t("context.sourceDetails")}</summary>
|
||||
<div className="context-panel__breakdown">
|
||||
{tokenTypeRows.map((row) => (
|
||||
<TokenLegend key={row.key} label={row.label} value={row.value} color={row.key} />
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<section className="context-panel__section context-panel__section--status">
|
||||
<SectionHeading title={t("context.sessionStatus")} />
|
||||
<div className="context-panel__stats">
|
||||
<MetricCard label={t("context.health")} value={t(health.shortKey, health.vars)} tone={health.tone} />
|
||||
<MetricCard label={t("context.compaction")} value={compactPct > 0 ? `${compactPct}%` : "-"} />
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -586,11 +640,14 @@ function TokenLegend({ label, value, color }: { label: string; value: number; co
|
||||
);
|
||||
}
|
||||
|
||||
function MiniStat({ label, value }: { label: string; value: string }) {
|
||||
function MiniStat({ label, value, title, tone, wide }: { label: string; value: string; title?: string; tone?: MetricTone; wide?: boolean }) {
|
||||
const toneClass = tone ? ` context-panel__mini-stat--${tone}` : "";
|
||||
const wideClass = wide ? " context-panel__mini-stat--wide" : "";
|
||||
const exactTitle = title && title !== value ? title : undefined;
|
||||
return (
|
||||
<div className="context-panel__mini-stat">
|
||||
<div className={`context-panel__mini-stat${toneClass}${wideClass}`} aria-label={exactTitle ? `${label}: ${exactTitle}` : undefined}>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
<strong title={exactTitle}>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -607,16 +664,6 @@ function MetricCard({ label, value, valueTitle, tone, wide }: { label: string; v
|
||||
);
|
||||
}
|
||||
|
||||
function RuntimeMetric({ label, value, title }: { label: string; value: string; title?: string }) {
|
||||
const exactTitle = title && title !== value ? title : undefined;
|
||||
return (
|
||||
<div className="context-panel__runtime-metric" aria-label={exactTitle ? `${label}: ${exactTitle}` : undefined}>
|
||||
<span>{label}</span>
|
||||
<strong title={exactTitle}>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceMetric({ label, value, title }: { label: string; value: string; title?: string }) {
|
||||
const exactTitle = title && title !== value ? title : undefined;
|
||||
return (
|
||||
|
||||
@@ -6,6 +6,13 @@ import { app } from "../lib/bridge";
|
||||
import type { DirEntry } from "../lib/types";
|
||||
import { VirtualMenu } from "./VirtualMenu";
|
||||
|
||||
const FILE_REF_SEARCH_CACHE_TTL_MS = 5000;
|
||||
|
||||
type FileRefSearchCacheEntry = {
|
||||
entries: DirEntry[];
|
||||
cachedAt: number;
|
||||
};
|
||||
|
||||
export function dirEntrySubmitPath(entry: DirEntry, atDir: string): string {
|
||||
return entry.path || atDir + entry.name;
|
||||
}
|
||||
@@ -58,7 +65,7 @@ export function useFileReferenceMenu(text: string, cwd?: string) {
|
||||
const [active, setActive] = useState(0);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const dirCache = useRef<Record<string, DirEntry[]>>({});
|
||||
const searchCache = useRef<Record<string, DirEntry[]>>({});
|
||||
const searchCache = useRef<Record<string, FileRefSearchCacheEntry>>({});
|
||||
const prevCwdRef = useRef(cwd);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -82,15 +89,17 @@ export function useFileReferenceMenu(text: string, cwd?: string) {
|
||||
const cached = dirCache.current[atDir];
|
||||
if (cached) {
|
||||
setEntries(cached);
|
||||
return;
|
||||
} else {
|
||||
setEntries([]);
|
||||
}
|
||||
let live = true;
|
||||
app
|
||||
.ListDir(atDir)
|
||||
.then((next) => {
|
||||
const list = asArray(next);
|
||||
if (!live) return;
|
||||
dirCache.current[atDir] = list;
|
||||
if (live) setEntries(list);
|
||||
setEntries(list);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
@@ -105,17 +114,19 @@ export function useFileReferenceMenu(text: string, cwd?: string) {
|
||||
}
|
||||
const cached = searchCache.current[atFrag];
|
||||
if (cached) {
|
||||
setSearchEntries(cached);
|
||||
return;
|
||||
setSearchEntries(cached.entries);
|
||||
if (Date.now() - cached.cachedAt < FILE_REF_SEARCH_CACHE_TTL_MS) return;
|
||||
} else {
|
||||
setSearchEntries([]);
|
||||
}
|
||||
setSearchEntries([]);
|
||||
let live = true;
|
||||
app
|
||||
.SearchFileRefs(atFrag)
|
||||
.then((next) => {
|
||||
const list = next ?? [];
|
||||
searchCache.current[atFrag] = list;
|
||||
if (live) setSearchEntries(list);
|
||||
const list = asArray(next);
|
||||
if (!live) return;
|
||||
searchCache.current[atFrag] = { entries: list, cachedAt: Date.now() };
|
||||
setSearchEntries(list);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
import { useT } from "../lib/i18n";
|
||||
|
||||
export interface ImageViewerProps {
|
||||
open: boolean;
|
||||
/** data-URL of the image to display */
|
||||
imageUrl: string;
|
||||
/** optional filename shown at the bottom */
|
||||
imageName?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* ImageViewer renders a full-size image preview in a portal overlay.
|
||||
*
|
||||
* It follows the same portal pattern as MermaidDiagram fullscreen:
|
||||
* the overlay is portaled into .chat-pane (fallback document.body) so it
|
||||
* covers the chat area without being clipped by overflow containers.
|
||||
*/
|
||||
export function ImageViewer({ open, imageUrl, imageName, onClose }: ImageViewerProps) {
|
||||
const t = useT();
|
||||
const [portalTarget, setPortalTarget] = useState<Element | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
// Resolve portal target when opening.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setVisible(false);
|
||||
setPortalTarget(null);
|
||||
return;
|
||||
}
|
||||
const target = document.querySelector(".chat-pane") ?? document.body;
|
||||
setPortalTarget(target);
|
||||
// Trigger enter animation on the next frame.
|
||||
const raf = requestAnimationFrame(() => setVisible(true));
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [open]);
|
||||
|
||||
// Close on Escape.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [open, onClose]);
|
||||
|
||||
// Prevent body scroll while open.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const handleBackdropClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
if (!open || !portalTarget || !imageUrl) return null;
|
||||
|
||||
const overlay = (
|
||||
<div
|
||||
className={`image-viewer-backdrop${visible ? " image-viewer--enter" : ""}`}
|
||||
onClick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={imageName || t("imageViewer.title")}
|
||||
>
|
||||
<button
|
||||
className="image-viewer__close"
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t("imageViewer.closePreview")}
|
||||
>
|
||||
<X size={22} />
|
||||
</button>
|
||||
<div className="image-viewer__content">
|
||||
<img
|
||||
className="image-viewer__image"
|
||||
src={imageUrl}
|
||||
alt={imageName || ""}
|
||||
draggable={false}
|
||||
/>
|
||||
{imageName && <div className="image-viewer__name">{imageName}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(overlay, portalTarget);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useEffect, useRef, useState } from "react";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { FormEvent, KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import { BrainCircuit, ChevronDown, ChevronRight, FileText, Folder, GitBranch, Image, MessageSquare, Pencil, RotateCcw, ScrollText } from "lucide-react";
|
||||
import { Markdown } from "./Markdown";
|
||||
@@ -10,6 +10,7 @@ import type { DisplayAttachment } from "../lib/attachmentDisplay";
|
||||
import { app } from "../lib/bridge";
|
||||
import { replaySubmitText } from "../lib/editReplay";
|
||||
import { useT } from "../lib/i18n";
|
||||
import { ImageViewer } from "./ImageViewer";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { useGSAPCollapse } from "../lib/useGSAPCollapse";
|
||||
import { displayReasoningText } from "../lib/reasoningDisplay";
|
||||
@@ -81,6 +82,31 @@ function mergeDisplayAttachments(existing: DisplayAttachment[], incoming: Displa
|
||||
return merged;
|
||||
}
|
||||
|
||||
type PastedBlockInfo = {
|
||||
label: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
const PASTE_LABEL_RE = /\[(?:已粘贴文本|已貼上文字|Pasted text) #\d+ · \d+ (?:行|lines)\]/g;
|
||||
|
||||
export function parsePastedBlocks(text: string, submitText?: string): PastedBlockInfo[] {
|
||||
const labels = text.match(PASTE_LABEL_RE);
|
||||
if (!labels || labels.length === 0 || !submitText) return [];
|
||||
const unique = [...new Set(labels)];
|
||||
const blocks: PastedBlockInfo[] = [];
|
||||
for (const label of unique) {
|
||||
const beginMarker = `--- Begin ${label} ---`;
|
||||
const endMarker = `--- End ${label} ---`;
|
||||
const beginIdx = submitText.indexOf(beginMarker);
|
||||
const endIdx = submitText.indexOf(endMarker);
|
||||
if (beginIdx < 0 || endIdx <= beginIdx) continue;
|
||||
const contentStart = beginIdx + beginMarker.length;
|
||||
const content = submitText.slice(contentStart, endIdx).replace(/^\r?\n/, "");
|
||||
blocks.push({ label, content });
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function MemoryCitations({ citations }: { citations?: MemoryCitation[] }) {
|
||||
const t = useT();
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
@@ -181,6 +207,65 @@ export function UserMessage({
|
||||
const [editSubmitting, setEditSubmitting] = useState(false);
|
||||
const editRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [imagePreviews, setImagePreviews] = useState<Record<string, string>>({});
|
||||
const [imageViewer, setImageViewer] = useState<{ open: boolean; url: string; name: string }>({ open: false, url: "", name: "" });
|
||||
const openImageViewer = useCallback(async (path: string, name: string) => {
|
||||
let url = imagePreviews[path];
|
||||
if (!url) {
|
||||
try {
|
||||
url = await app.AttachmentDataURL(path);
|
||||
setImagePreviews((prev) => (prev[path] ? prev : { ...prev, [path]: url }));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
setImageViewer({ open: true, url, name });
|
||||
}, [imagePreviews]);
|
||||
|
||||
const closeImageViewer = useCallback(() => {
|
||||
setImageViewer((prev) => (prev.open ? { ...prev, open: false } : prev));
|
||||
}, []);
|
||||
|
||||
const pasteBlocks = useMemo(() => parsePastedBlocks(actionText, submitText), [actionText, submitText]);
|
||||
const [expandedPasteLabels, setExpandedPasteLabels] = useState<Record<string, boolean>>({});
|
||||
|
||||
type DisplaySegment =
|
||||
| { type: "text"; content: string }
|
||||
| { type: "paste"; block: PastedBlockInfo };
|
||||
|
||||
const displaySegments = useMemo((): DisplaySegment[] => {
|
||||
if (pasteBlocks.length === 0) return [{ type: "text", content: displayText }];
|
||||
const segments: DisplaySegment[] = [];
|
||||
// Order blocks by their position in the text so cards appear inline.
|
||||
const ordered = pasteBlocks
|
||||
.map((b) => ({ block: b, pos: displayText.indexOf(b.label) }))
|
||||
.filter((x) => x.pos >= 0)
|
||||
.sort((a, b) => a.pos - b.pos);
|
||||
let remaining = displayText;
|
||||
for (const { block } of ordered) {
|
||||
const idx = remaining.indexOf(block.label);
|
||||
if (idx < 0) continue;
|
||||
// Text before the label: strip the trailing newline that separated the
|
||||
// label from the preceding line so the card sits tight against the text.
|
||||
if (idx > 0) {
|
||||
let before = remaining.slice(0, idx);
|
||||
before = before.replace(/\n$/, "");
|
||||
if (before) segments.push({ type: "text", content: before });
|
||||
}
|
||||
segments.push({ type: "paste", block });
|
||||
remaining = remaining.slice(idx + block.label.length);
|
||||
}
|
||||
// Strip the leading newline that followed the label.
|
||||
remaining = remaining.replace(/^\n/, "");
|
||||
if (remaining.trim()) segments.push({ type: "text", content: remaining });
|
||||
return segments.length > 0 ? segments : [{ type: "text", content: displayText }];
|
||||
}, [displayText, pasteBlocks]);
|
||||
|
||||
const togglePasteExpand = (label: string) => {
|
||||
setExpandedPasteLabels((prev) => ({
|
||||
...prev,
|
||||
[label]: !prev[label],
|
||||
}));
|
||||
};
|
||||
const orderedDraftAttachments = sortDisplayAttachments(draftAttachments);
|
||||
const imagePreviewKey = orderedAttachments
|
||||
.concat(orderedDraftAttachments)
|
||||
@@ -305,11 +390,12 @@ export function UserMessage({
|
||||
<ComposerContextCard
|
||||
key={attachment.path}
|
||||
variant={attachment.source === "workspace" ? "workspace" : "attachment"}
|
||||
tooltipLabel={attachment.source === "workspace" ? formatAttachmentRefForSubmit(attachment) : attachment.path}
|
||||
tooltipLabel={imagePreview ? `${t("imageViewer.clickToPreview")} — ${attachment.path}` : attachment.source === "workspace" ? formatAttachmentRefForSubmit(attachment) : attachment.path}
|
||||
removeLabel={attachment.source === "workspace" ? t("composer.removeReference") : t("composer.removeImage")}
|
||||
removeDisabled={editSubmitting}
|
||||
onRemove={() => removeDraftAttachment(attachment.path)}
|
||||
previewUrl={imagePreview}
|
||||
onImageClick={imagePreview ? () => openImageViewer(attachment.path, attachment.name) : undefined}
|
||||
imageOnly={imageOnly}
|
||||
folder={attachment.kind === "folder"}
|
||||
label={attachment.kind === "folder" ? `${attachment.name}/` : attachment.name}
|
||||
@@ -355,26 +441,78 @@ export function UserMessage({
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
displayText && <div className="msg__text">{displayText}</div>
|
||||
<>
|
||||
{displaySegments.map((seg, i) => {
|
||||
if (seg.type === "text") {
|
||||
return seg.content ? <div className="msg__text" key={`s${i}`}>{seg.content}</div> : null;
|
||||
}
|
||||
const expanded = Boolean(expandedPasteLabels[seg.block.label]);
|
||||
return (
|
||||
<div className="msg-pasted" key={seg.block.label}>
|
||||
<div className="msg-pasted-block">
|
||||
<div className="msg-pasted-head">
|
||||
<FileText size={15} />
|
||||
<span className="msg-pasted-label">{seg.block.label}</span>
|
||||
<div className="msg-pasted-actions">
|
||||
<Tooltip label={t(expanded ? "msg.pastedCollapseTooltip" : "msg.pastedExpandTooltip")}>
|
||||
<button type="button" onClick={() => togglePasteExpand(seg.block.label)}>
|
||||
{expanded ? t("common.collapse") : t("composer.pastedExpand")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="msg-pasted-expanded">{seg.block.content}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{failed && <div className="msg__send-failed">{t("msg.sendFailed")}</div>}
|
||||
{orderedAttachments.length > 0 && (
|
||||
<div className="msg-attachments" aria-label={t("msg.attachments")}>
|
||||
{orderedAttachments.map((attachment, index) => (
|
||||
<div className={`msg-attachment msg-attachment--${attachment.kind}`} key={`${attachment.path}:${index}`} title={attachment.path}>
|
||||
<span className={`msg-attachment__icon msg-attachment__icon--${attachment.kind}`} aria-hidden="true">
|
||||
{attachment.kind === "image" && imagePreviews[attachment.path] ? <img src={imagePreviews[attachment.path]} alt="" draggable={false} /> : attachmentIcon(attachment.kind)}
|
||||
</span>
|
||||
<span className="msg-attachment__main">
|
||||
<span className="msg-attachment__name">{attachment.name}</span>
|
||||
<span className="msg-attachment__meta">
|
||||
{attachment.kind === "folder"
|
||||
? t("msg.folderReference")
|
||||
: `${attachment.ext || t("msg.fileAttachment")} · ${attachment.source === "workspace" ? t("msg.workspaceReference") : attachment.kind === "image" ? t("msg.imageAttachment") : t("msg.fileAttachment")}`}
|
||||
{orderedAttachments.map((attachment, index) => {
|
||||
const isImage = attachment.kind === "image";
|
||||
const el = (
|
||||
<div
|
||||
className={`msg-attachment msg-attachment--${attachment.kind}`}
|
||||
key={isImage ? undefined : `${attachment.path}:${index}`}
|
||||
title={isImage ? undefined : attachment.path}
|
||||
onClick={isImage ? () => openImageViewer(attachment.path, attachment.name) : undefined}
|
||||
role={isImage ? "button" : undefined}
|
||||
tabIndex={isImage ? 0 : undefined}
|
||||
onKeyDown={isImage ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openImageViewer(attachment.path, attachment.name); } } : undefined}
|
||||
>
|
||||
<span className={`msg-attachment__icon msg-attachment__icon--${attachment.kind}`} aria-hidden="true">
|
||||
{isImage && imagePreviews[attachment.path] ? <img src={imagePreviews[attachment.path]} alt="" draggable={false} /> : attachmentIcon(attachment.kind)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<span className="msg-attachment__main">
|
||||
<span className="msg-attachment__name">{attachment.name}</span>
|
||||
<span className="msg-attachment__meta">
|
||||
{attachment.kind === "folder"
|
||||
? t("msg.folderReference")
|
||||
: `${attachment.ext || t("msg.fileAttachment")} · ${attachment.source === "workspace" ? t("msg.workspaceReference") : attachment.kind === "image" ? t("msg.imageAttachment") : t("msg.fileAttachment")}`}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
if (isImage) {
|
||||
return (
|
||||
<Tooltip key={`${attachment.path}:${index}`} label={t("imageViewer.clickToPreview")} block>
|
||||
{el}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return el;
|
||||
})}
|
||||
<ImageViewer
|
||||
open={imageViewer.open}
|
||||
imageUrl={imageViewer.url}
|
||||
imageName={imageViewer.name}
|
||||
onClose={closeImageViewer}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -483,9 +621,9 @@ export function TurnActions({
|
||||
}
|
||||
};
|
||||
const actionMeta = (scope: MessageActionScope): string => {
|
||||
if ((scope === "code" || scope === "both") && checkpoint?.files?.length) {
|
||||
const total = checkpoint.files.length;
|
||||
const turnCount = checkpoint.turnFileCount ?? 0;
|
||||
const total = checkpoint?.fileCount ?? checkpoint?.files?.length ?? 0;
|
||||
if ((scope === "code" || scope === "both") && total > 0) {
|
||||
const turnCount = checkpoint?.turnFileCount ?? 0;
|
||||
if (turnCount > 0 && turnCount < total) {
|
||||
return `${t("rewind.filesChanged", { count: total })} (${t("rewind.turnFiles", { count: turnCount })})`;
|
||||
}
|
||||
@@ -496,12 +634,16 @@ export function TurnActions({
|
||||
const actionTooltipLabel = (scope: MessageActionScope) => {
|
||||
const reason = actionDisabledReason(scope);
|
||||
if (reason) return <span>{reason}</span>;
|
||||
if ((scope === "code" || scope === "both") && checkpoint?.files?.length) {
|
||||
const files = checkpoint?.files ?? [];
|
||||
const total = checkpoint?.fileCount ?? files.length;
|
||||
if ((scope === "code" || scope === "both") && total > 0) {
|
||||
const hidden = Math.max(0, total - files.length);
|
||||
return (
|
||||
<div className="rewind__files-tooltip">
|
||||
{checkpoint.files.map((file) => (
|
||||
{files.map((file) => (
|
||||
<div key={file}>{file.split(/[/\\]/).pop() || file}</div>
|
||||
))}
|
||||
{hidden > 0 && <div>+{hidden}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -159,6 +159,43 @@ function topicStatusLabel(node: ProjectNode, t: Translator): string {
|
||||
return status ? t(topicStatusLabels[status]) : "";
|
||||
}
|
||||
|
||||
function topicActivityAt(node: ProjectNode): number {
|
||||
return node.lastActivityAt || node.createdAt || 0;
|
||||
}
|
||||
|
||||
export function projectTreeReadActivityKey(node: ProjectNode): string | null {
|
||||
const request = projectTreeTopicOpenRequest(node);
|
||||
if (!request?.topicId) return null;
|
||||
return [
|
||||
request.scope,
|
||||
request.workspaceRoot,
|
||||
request.topicId,
|
||||
request.sessionPath ?? "",
|
||||
].join("\u001f");
|
||||
}
|
||||
|
||||
type ProjectTreeReadActivity = Record<string, number>;
|
||||
|
||||
export function projectTreeTopicHasUnreadActivity(
|
||||
node: ProjectNode,
|
||||
readActivity: ProjectTreeReadActivity,
|
||||
activeScope?: string,
|
||||
activeWorkspaceRoot?: string,
|
||||
activeTopicId?: string,
|
||||
activeSessionPath?: string,
|
||||
): boolean {
|
||||
if (!isTopicNode(node) && !isRuntimeSessionNode(node)) return false;
|
||||
if (topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath)) return false;
|
||||
if (topicStatus(node) !== "") return false;
|
||||
const key = projectTreeReadActivityKey(node);
|
||||
const activityAt = topicActivityAt(node);
|
||||
return Boolean(key && activityAt > 0 && (readActivity[key] ?? 0) < activityAt);
|
||||
}
|
||||
|
||||
export function projectTreeShouldRenderTopicActions(isSessionNode: boolean, compactTopics: boolean, unread: boolean): boolean {
|
||||
return !isSessionNode && compactTopics && !unread;
|
||||
}
|
||||
|
||||
function topicActivityLabel(ms: number, t: Translator, compact = false): string {
|
||||
if (ms <= 0) return "";
|
||||
const delta = Date.now() - ms;
|
||||
@@ -226,6 +263,31 @@ type WorkbenchTreeSections = {
|
||||
const GLOBAL_PROJECT_ORDER_KEY = "__global__";
|
||||
const WORKBENCH_ORGANIZE_KEY = "projectTree:workbenchOrganize";
|
||||
const WORKBENCH_SORT_KEY = "projectTree:workbenchSort";
|
||||
const READ_ACTIVITY_KEY = "projectTree:readActivity";
|
||||
const READ_ACTIVITY_INIT_KEY = "projectTree:readActivityInitialized";
|
||||
|
||||
function loadReadActivity(): ProjectTreeReadActivity {
|
||||
try {
|
||||
const raw = localStorage.getItem(READ_ACTIVITY_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
const out: ProjectTreeReadActivity = {};
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveReadActivity(readActivity: ProjectTreeReadActivity) {
|
||||
try {
|
||||
localStorage.setItem(READ_ACTIVITY_KEY, JSON.stringify(readActivity));
|
||||
} catch {
|
||||
/* localStorage unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
function loadWorkbenchOrganizeMode(): WorkbenchOrganizeMode {
|
||||
try {
|
||||
@@ -497,6 +559,7 @@ export function ProjectTree({
|
||||
const [workbenchHeaderMenu, setWorkbenchHeaderMenu] = useState<WorkbenchHeaderMenu>(null);
|
||||
const [workbenchOrganizeMode, setWorkbenchOrganizeMode] = useState<WorkbenchOrganizeMode>(loadWorkbenchOrganizeMode);
|
||||
const [workbenchSortMode, setWorkbenchSortMode] = useState<WorkbenchSortMode>(loadWorkbenchSortMode);
|
||||
const [readActivity, setReadActivity] = useState<ProjectTreeReadActivity>(loadReadActivity);
|
||||
const filterRef = useRef<HTMLDivElement>(null);
|
||||
const filterTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -562,6 +625,67 @@ export function ProjectTree({
|
||||
void refresh();
|
||||
}, [refresh, refreshSignal]);
|
||||
|
||||
const markNodeRead = useCallback((node: ProjectNode) => {
|
||||
const key = projectTreeReadActivityKey(node);
|
||||
const activityAt = topicActivityAt(node);
|
||||
if (!key || activityAt <= 0) return;
|
||||
setReadActivity((prev) => {
|
||||
if ((prev[key] ?? 0) >= activityAt) return prev;
|
||||
const next = { ...prev, [key]: activityAt };
|
||||
saveReadActivity(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (tree.length === 0) return;
|
||||
try {
|
||||
if (localStorage.getItem(READ_ACTIVITY_INIT_KEY)) return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const baseline: ProjectTreeReadActivity = {};
|
||||
const collectBaseline = (nodes: ProjectNode[]) => {
|
||||
for (const node of nodes) {
|
||||
if ((isTopicNode(node) || isRuntimeSessionNode(node)) && topicStatus(node) === "") {
|
||||
const key = projectTreeReadActivityKey(node);
|
||||
const activityAt = topicActivityAt(node);
|
||||
if (key && activityAt > 0) baseline[key] = Math.max(baseline[key] ?? 0, activityAt);
|
||||
}
|
||||
collectBaseline(asArray(node.children));
|
||||
}
|
||||
};
|
||||
collectBaseline(tree);
|
||||
try {
|
||||
localStorage.setItem(READ_ACTIVITY_INIT_KEY, "1");
|
||||
} catch {
|
||||
/* localStorage unavailable */
|
||||
}
|
||||
if (Object.keys(baseline).length === 0) return;
|
||||
setReadActivity((prev) => {
|
||||
const next = { ...prev };
|
||||
let changed = false;
|
||||
for (const [key, value] of Object.entries(baseline)) {
|
||||
if ((next[key] ?? 0) >= value) continue;
|
||||
next[key] = value;
|
||||
changed = true;
|
||||
}
|
||||
if (!changed) return prev;
|
||||
saveReadActivity(next);
|
||||
return next;
|
||||
});
|
||||
}, [tree]);
|
||||
|
||||
useEffect(() => {
|
||||
const markActive = (nodes: ProjectNode[]) => {
|
||||
for (const node of nodes) {
|
||||
if (topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath)) markNodeRead(node);
|
||||
markActive(asArray(node.children));
|
||||
}
|
||||
};
|
||||
markActive(tree);
|
||||
}, [activeScope, activeSessionPath, activeTopicId, activeWorkspaceRoot, markNodeRead, tree]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(WORKBENCH_ORGANIZE_KEY, workbenchOrganizeMode);
|
||||
@@ -1010,12 +1134,21 @@ export function ProjectTree({
|
||||
const status = topicStatus(node);
|
||||
const statusLabel = topicStatusLabel(node, t);
|
||||
const showStatusInSide = status === "thinking" || status === "streaming" || status === "waiting_confirmation" || status === "background_job";
|
||||
const unread = projectTreeTopicHasUnreadActivity(node, readActivity, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath);
|
||||
const topicId = node.topicId ?? "";
|
||||
const imSource = scope === "global" && topicId ? imTopicSources[topicId] : undefined;
|
||||
const imSourceLabel = imSource?.label || "";
|
||||
const imSourceTitle = imSourceLabel ? t("msg.fromIm", { source: imSourceLabel }) : "";
|
||||
const imSourcePlatform = (imSource?.platform || "im").replace(/[^a-z0-9_-]/gi, "").toLowerCase() || "im";
|
||||
const title = [label, imSourceTitle, statusLabel, meta, exactTimeLabel].filter(Boolean).join(" · ");
|
||||
const recovered = Boolean(node.recovered);
|
||||
const recoveryTitle = recovered
|
||||
? [
|
||||
t("recovery.branch"),
|
||||
node.recoveryReason ? t("recovery.reason", { reason: node.recoveryReason }) : "",
|
||||
node.recoveryDigest ? t("recovery.digest", { digest: node.recoveryDigest.slice(0, 12) }) : "",
|
||||
].filter(Boolean).join(" · ")
|
||||
: "";
|
||||
const title = [label, recoveryTitle, imSourceTitle, statusLabel, meta, exactTimeLabel].filter(Boolean).join(" · ");
|
||||
const topicMenuOpen = !isSessionNode && menuTopic === topicId;
|
||||
const pinned = Boolean(node.pinned);
|
||||
const pinLabel = t(pinned ? "projectTree.unpinTopic" : "projectTree.pinTopic");
|
||||
@@ -1092,7 +1225,7 @@ export function ProjectTree({
|
||||
}
|
||||
const row = (
|
||||
<div
|
||||
className={`project-tree__topic${scopeClass}${isSessionNode ? " project-tree__topic--session" : ""}${active ? " project-tree__topic--active" : ""}${node.running ? " project-tree__topic--running" : ""}${status ? ` project-tree__topic--status-${status}` : ""}${!isSessionNode && pinned ? " project-tree__topic--pinned" : ""}${topicMenuOpen ? " project-tree__topic--menu-open" : ""}${sideTimeVisible && (timeLabel || showStatusInSide) ? " project-tree__topic--with-side" : meta ? " project-tree__topic--has-meta" : ""}${imSource ? " project-tree__topic--im-source" : ""}${shortcutIndex > 0 ? " project-tree__topic--show-shortcut" : ""}`}
|
||||
className={`project-tree__topic${scopeClass}${isSessionNode ? " project-tree__topic--session" : ""}${active ? " project-tree__topic--active" : ""}${node.running ? " project-tree__topic--running" : ""}${status ? ` project-tree__topic--status-${status}` : ""}${unread ? " project-tree__topic--unread" : ""}${!isSessionNode && pinned ? " project-tree__topic--pinned" : ""}${recovered ? " project-tree__topic--recovered" : ""}${topicMenuOpen ? " project-tree__topic--menu-open" : ""}${sideTimeVisible && (timeLabel || showStatusInSide) ? " project-tree__topic--with-side" : meta ? " project-tree__topic--has-meta" : ""}${imSource ? " project-tree__topic--im-source" : ""}${shortcutIndex > 0 ? " project-tree__topic--show-shortcut" : ""}`}
|
||||
style={accentStyle}
|
||||
onContextMenu={isSessionNode ? undefined : openTopicMenu}
|
||||
>
|
||||
@@ -1112,6 +1245,7 @@ export function ProjectTree({
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
if (clickTimerRef.current?.timer === timer) clickTimerRef.current = null;
|
||||
markNodeRead(node);
|
||||
onOpenTopic(openRequest.scope, openRequest.workspaceRoot, openRequest.topicId, openRequest.sessionPath);
|
||||
}, 200);
|
||||
clickTimerRef.current = { ...nextClick, timer };
|
||||
@@ -1145,6 +1279,11 @@ export function ProjectTree({
|
||||
</span>
|
||||
)}
|
||||
{!compactTopics && statusLabel && <span className={`project-tree__topic-status project-tree__topic-status--${status}`}>{statusLabel}</span>}
|
||||
{recovered && (
|
||||
<span className="project-tree__topic-recovery" title={recoveryTitle}>
|
||||
{t("recovery.badge")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{!compactTopics && !creationTopics && meta && (
|
||||
<span className="project-tree__topic-meta">
|
||||
@@ -1168,8 +1307,14 @@ export function ProjectTree({
|
||||
{meta}
|
||||
</span>
|
||||
)}
|
||||
{compactTopics && recovered && (
|
||||
<span className="sr-only">
|
||||
{t("recovery.branch")}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{!isSessionNode && compactTopics && (
|
||||
{unread && <span className="project-tree__topic-unread-dot" aria-hidden="true" />}
|
||||
{projectTreeShouldRenderTopicActions(isSessionNode, compactTopics, unread) && (
|
||||
<span className="project-tree__topic-actions" aria-label={t("projectTree.topicActions")}>
|
||||
<Tooltip label={pinLabel} side="top" className="project-tree__topic-action-slot">
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { ReactNode, RefObject } from "react";
|
||||
|
||||
export function PromptShelf({
|
||||
className,
|
||||
cardClassName,
|
||||
titleId,
|
||||
title,
|
||||
badges,
|
||||
@@ -13,6 +15,8 @@ export function PromptShelf({
|
||||
barRef,
|
||||
role = "dialog",
|
||||
}: {
|
||||
className?: string;
|
||||
cardClassName?: string;
|
||||
titleId: string;
|
||||
title: ReactNode;
|
||||
badges?: ReactNode;
|
||||
@@ -26,10 +30,10 @@ export function PromptShelf({
|
||||
role?: "dialog" | "region";
|
||||
}) {
|
||||
return (
|
||||
<div className="prompt-shelf" aria-live="polite">
|
||||
<div className={["prompt-shelf", className ?? ""].filter(Boolean).join(" ")} aria-live="polite">
|
||||
<div
|
||||
ref={barRef}
|
||||
className="prompt-shelf__card"
|
||||
className={["prompt-shelf__card", cardClassName ?? ""].filter(Boolean).join(" ")}
|
||||
role={role}
|
||||
aria-modal={role === "dialog" ? "false" : undefined}
|
||||
aria-labelledby={titleId}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,22 +1,39 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useT } from "../lib/i18n";
|
||||
import type { Todo } from "../lib/tools";
|
||||
import { shouldOpenTodoPanelByDefault } from "../lib/todoVisibility";
|
||||
import { PromptBadge, PromptHeaderAction, PromptShelf } from "./PromptShelf";
|
||||
|
||||
const STORAGE_KEY = "todoPanel:open";
|
||||
const STORAGE_KEY = "todoPanel:openStates";
|
||||
const MAX_STORED_OPEN_STATES = 80;
|
||||
|
||||
function loadOpenState(): boolean {
|
||||
function loadOpenStates(): Record<string, boolean> {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
return saved === null ? true : saved === "1";
|
||||
if (!saved) return {};
|
||||
const parsed = JSON.parse(saved) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
||||
const states: Record<string, boolean> = {};
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (typeof value === "boolean") states[key] = value;
|
||||
}
|
||||
return states;
|
||||
} catch {
|
||||
return true;
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveOpenState(open: boolean): void {
|
||||
function loadOpenState(stateKey: string, defaultOpen: boolean): boolean {
|
||||
const states = loadOpenStates();
|
||||
return Object.prototype.hasOwnProperty.call(states, stateKey) ? states[stateKey] : defaultOpen;
|
||||
}
|
||||
|
||||
function saveOpenState(stateKey: string, open: boolean): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, open ? "1" : "0");
|
||||
const entries = Object.entries(loadOpenStates()).filter(([key]) => key !== stateKey);
|
||||
entries.push([stateKey, open]);
|
||||
const trimmed = entries.slice(-MAX_STORED_OPEN_STATES);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(Object.fromEntries(trimmed)));
|
||||
} catch {
|
||||
/* ignore quota errors */
|
||||
}
|
||||
@@ -24,23 +41,35 @@ function saveOpenState(open: boolean): void {
|
||||
|
||||
// TodoPanel is the live task list pinned just above the composer — the kernel's
|
||||
// latest todo_write call drives it, and it updates in place as the agent flips
|
||||
// items to in_progress / completed. The collapsed/expanded state is persisted
|
||||
// globally in localStorage so the user's choice sticks across sessions.
|
||||
// items to in_progress / completed. Open state follows the current todo batch:
|
||||
// new incomplete work opens by default, finished work collapses to a reviewable
|
||||
// summary, and manual expand/collapse is restored only for the same batch.
|
||||
export function TodoPanel({
|
||||
stateKey,
|
||||
todos,
|
||||
onDismiss,
|
||||
}: {
|
||||
stateKey: string;
|
||||
todos: Todo[];
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const [open, setOpen] = useState(loadOpenState);
|
||||
const currentRef = useRef<HTMLLIElement | null>(null);
|
||||
|
||||
const done = todos.filter((t) => t.status === "completed").length;
|
||||
const current = todos.find((t) => t.status === "in_progress");
|
||||
const allDone = todos.length > 0 && done === todos.length;
|
||||
const summary = current?.activeForm || current?.content || todos[todos.length - 1]?.content || "";
|
||||
const [open, setOpen] = useState(() => loadOpenState(stateKey, shouldOpenTodoPanelByDefault(todos)));
|
||||
const wasAllDoneRef = useRef(allDone);
|
||||
|
||||
useEffect(() => {
|
||||
if (allDone && !wasAllDoneRef.current) {
|
||||
saveOpenState(stateKey, false);
|
||||
setOpen(false);
|
||||
}
|
||||
wasAllDoneRef.current = allDone;
|
||||
}, [allDone, stateKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -58,7 +87,13 @@ export function TodoPanel({
|
||||
role="region"
|
||||
headerActions={
|
||||
<>
|
||||
<PromptHeaderAction onClick={() => setOpen((value) => { const next = !value; saveOpenState(next); return next; })}>
|
||||
<PromptHeaderAction
|
||||
onClick={() => setOpen((value) => {
|
||||
const next = !value;
|
||||
saveOpenState(stateKey, next);
|
||||
return next;
|
||||
})}
|
||||
>
|
||||
{open ? t("common.collapse") : t("common.expand")}
|
||||
</PromptHeaderAction>
|
||||
{allDone && (
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { asArray } from "../lib/array";
|
||||
import { app } from "../lib/bridge";
|
||||
import { useT } from "../lib/i18n";
|
||||
import {
|
||||
@@ -206,6 +207,7 @@ export function WorkspacePanel({
|
||||
onPreviewModeChange,
|
||||
onAddToChat,
|
||||
onRequestPanelWidth,
|
||||
onFileTreeRefresh,
|
||||
refreshKey,
|
||||
initialViewMode = "files",
|
||||
revealPathRequest,
|
||||
@@ -224,6 +226,7 @@ export function WorkspacePanel({
|
||||
onPreviewModeChange?: (active: boolean) => void;
|
||||
onAddToChat?: (text: string) => void;
|
||||
onRequestPanelWidth?: (width: number) => void;
|
||||
onFileTreeRefresh?: () => void;
|
||||
refreshKey?: number;
|
||||
initialViewMode?: "files" | "changed";
|
||||
revealPathRequest?: WorkspaceRevealRequest | null;
|
||||
@@ -275,6 +278,8 @@ export function WorkspacePanel({
|
||||
const workspaceChangesRequestIdRef = useRef(0);
|
||||
const gitHistoryRequestIdRef = useRef(0);
|
||||
const commitDetailRequestIdRef = useRef(0);
|
||||
const dirLoadGenerationRef = useRef(0);
|
||||
const dirLoadRequestIdsRef = useRef<Record<string, number>>({});
|
||||
const recentAnchorRef = useRef<HTMLButtonElement>(null);
|
||||
const openDirsRef = useRef(openDirs);
|
||||
const pendingTreeRevealPathRef = useRef<string | null>(null);
|
||||
@@ -284,8 +289,12 @@ export function WorkspacePanel({
|
||||
}, [openDirs]);
|
||||
|
||||
const loadDir = useCallback(async (dir: string) => {
|
||||
const entries = await app.ListDir(dir).catch(() => []);
|
||||
setEntriesByDir((prev) => ({ ...prev, [dir]: entries ?? [] }));
|
||||
const generation = dirLoadGenerationRef.current;
|
||||
const requestId = (dirLoadRequestIdsRef.current[dir] ?? 0) + 1;
|
||||
dirLoadRequestIdsRef.current[dir] = requestId;
|
||||
const entries = await app.ListDir(dir).catch((): DirEntry[] => []);
|
||||
if (dirLoadGenerationRef.current !== generation || dirLoadRequestIdsRef.current[dir] !== requestId) return;
|
||||
setEntriesByDir((prev) => ({ ...prev, [dir]: asArray(entries) }));
|
||||
}, []);
|
||||
|
||||
const loadGitHistory = useCallback(async () => {
|
||||
@@ -399,15 +408,15 @@ export function WorkspacePanel({
|
||||
setOpenTabs((tabs) => [...tabs.filter((tab) => tab !== path), path].slice(-WORKSPACE_MAX_PREVIEW_TABS));
|
||||
const dirs = parentDirs(path);
|
||||
setOpenDirs((prev) => new Set([...Array.from(prev), ...dirs]));
|
||||
dirs.forEach((dir) => {
|
||||
if (!entriesByDir[dir]) void loadDir(dir);
|
||||
});
|
||||
dirs.forEach((dir) => void loadDir(dir));
|
||||
},
|
||||
[entriesByDir, loadDir, openTabs.length, panelWidth, selectedPath, treeVisible],
|
||||
[loadDir, openTabs.length, panelWidth, selectedPath, treeVisible],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
dirLoadGenerationRef.current += 1;
|
||||
dirLoadRequestIdsRef.current = {};
|
||||
setEntriesByDir({});
|
||||
setOpenDirs(new Set([""]));
|
||||
setSelectedPath(null);
|
||||
@@ -503,10 +512,8 @@ export function WorkspacePanel({
|
||||
setTreeMenu(null);
|
||||
const dirs = Array.from(new Set(paths.flatMap(parentDirs)));
|
||||
setOpenDirs((prev) => new Set([...Array.from(prev), ...dirs]));
|
||||
dirs.forEach((dir) => {
|
||||
if (!entriesByDir[dir]) void loadDir(dir);
|
||||
});
|
||||
}, [entriesByDir, fileListRequest, loadDir, open, scopedFilePaths, viewMode]);
|
||||
dirs.forEach((dir) => void loadDir(dir));
|
||||
}, [fileListRequest, loadDir, open, scopedFilePaths, viewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || changeListRequest) return;
|
||||
@@ -649,10 +656,10 @@ export function WorkspacePanel({
|
||||
void loadWorkspaceChanges();
|
||||
return;
|
||||
}
|
||||
onFileTreeRefresh?.();
|
||||
const dirs = Array.from(openDirsRef.current);
|
||||
setEntriesByDir({});
|
||||
dirs.forEach((dir) => void loadDir(dir));
|
||||
}, [loadGitHistory, loadWorkspaceChanges, loadDir, viewMode]);
|
||||
}, [loadGitHistory, loadWorkspaceChanges, loadDir, onFileTreeRefresh, viewMode]);
|
||||
|
||||
const refreshSelected = useCallback(() => {
|
||||
if (!selectedPath) return;
|
||||
@@ -698,12 +705,12 @@ export function WorkspacePanel({
|
||||
next.delete(dir);
|
||||
} else {
|
||||
next.add(dir);
|
||||
if (!entriesByDir[dir]) void loadDir(dir);
|
||||
void loadDir(dir);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[entriesByDir, loadDir],
|
||||
[loadDir],
|
||||
);
|
||||
|
||||
const breadcrumbDirs = selectedPath ? parentDirs(selectedPath) : [""];
|
||||
@@ -1262,6 +1269,7 @@ export function WorkspacePanel({
|
||||
setFilter("");
|
||||
showTreeEvenSplit();
|
||||
setOpenDirs((prev) => new Set([...Array.from(prev), ""]));
|
||||
void loadDir("");
|
||||
}}
|
||||
>
|
||||
{shortCwd(cwd) || t("workspace.title")}
|
||||
|
||||
@@ -190,6 +190,7 @@ export function HeartbeatPanel({ open, onClose, startNew, onOpenTopic }: Heartbe
|
||||
enabled: true,
|
||||
approvalMode: "yolo",
|
||||
newConversationEachRun: false,
|
||||
notifyChannels: false,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
});
|
||||
@@ -218,6 +219,7 @@ export function HeartbeatPanel({ open, onClose, startNew, onOpenTopic }: Heartbe
|
||||
enabled: true,
|
||||
approvalMode: "yolo",
|
||||
newConversationEachRun: false,
|
||||
notifyChannels: false,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
}, []);
|
||||
@@ -952,37 +954,63 @@ function TaskEditor({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Approval Mode */}
|
||||
<div className="heartbeat-editor__field">
|
||||
<label>{t("heartbeat.fieldApprovalMode")}</label>
|
||||
<div className="set-seg" style={{ alignSelf: "flex-start" }}>
|
||||
<button
|
||||
className={`set-seg__btn${normalizeMode(draft.approvalMode) === "ask" ? " set-seg__btn--on" : ""}`}
|
||||
onClick={() => setDraft((prev) => ({ ...prev, approvalMode: "ask" }))}
|
||||
title={t("heartbeat.approvalModeAskTooltip")}
|
||||
>
|
||||
{t("heartbeat.approvalModeAsk")}
|
||||
</button>
|
||||
<button
|
||||
className={`set-seg__btn${normalizeMode(draft.approvalMode) === "auto" ? " set-seg__btn--on" : ""}`}
|
||||
onClick={() => setDraft((prev) => ({ ...prev, approvalMode: "auto" }))}
|
||||
title={t("heartbeat.approvalModeAutoTooltip")}
|
||||
>
|
||||
{t("heartbeat.approvalModeAuto")}
|
||||
</button>
|
||||
<button
|
||||
className={`set-seg__btn${normalizeMode(draft.approvalMode) === "yolo" ? " set-seg__btn--on" : ""}`}
|
||||
onClick={() => setDraft((prev) => ({ ...prev, approvalMode: "yolo" }))}
|
||||
title={t("heartbeat.approvalModeYoloTooltip")}
|
||||
>
|
||||
{t("heartbeat.approvalModeYolo")}
|
||||
</button>
|
||||
{/* Approval Mode + Push to bot (side by side) */}
|
||||
<div style={{ display: "flex", gap: "16px", flexWrap: "wrap" }}>
|
||||
<div className="heartbeat-editor__field" style={{ flex: "1 1 45%", minWidth: "200px" }}>
|
||||
<label>{t("heartbeat.fieldApprovalMode")}</label>
|
||||
<div className="set-seg" style={{ alignSelf: "flex-start" }}>
|
||||
<button
|
||||
className={`set-seg__btn${normalizeMode(draft.approvalMode) === "ask" ? " set-seg__btn--on" : ""}`}
|
||||
onClick={() => setDraft((prev) => ({ ...prev, approvalMode: "ask" }))}
|
||||
title={t("heartbeat.approvalModeAskTooltip")}
|
||||
>
|
||||
{t("heartbeat.approvalModeAsk")}
|
||||
</button>
|
||||
<button
|
||||
className={`set-seg__btn${normalizeMode(draft.approvalMode) === "auto" ? " set-seg__btn--on" : ""}`}
|
||||
onClick={() => setDraft((prev) => ({ ...prev, approvalMode: "auto" }))}
|
||||
title={t("heartbeat.approvalModeAutoTooltip")}
|
||||
>
|
||||
{t("heartbeat.approvalModeAuto")}
|
||||
</button>
|
||||
<button
|
||||
className={`set-seg__btn${normalizeMode(draft.approvalMode) === "yolo" ? " set-seg__btn--on" : ""}`}
|
||||
onClick={() => setDraft((prev) => ({ ...prev, approvalMode: "yolo" }))}
|
||||
title={t("heartbeat.approvalModeYoloTooltip")}
|
||||
>
|
||||
{t("heartbeat.approvalModeYolo")}
|
||||
</button>
|
||||
</div>
|
||||
<span className="heartbeat-editor__mode-hint">
|
||||
{normalizeMode(draft.approvalMode) === "yolo" ? t("heartbeat.approvalModeYoloHint") :
|
||||
normalizeMode(draft.approvalMode) === "auto" ? t("heartbeat.approvalModeAutoHint") :
|
||||
t("heartbeat.approvalModeAskHint")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Push to bot channels */}
|
||||
<div className="heartbeat-editor__field" style={{ flex: "1 1 45%", minWidth: "200px", textAlign: "left" }}>
|
||||
<label>{t("heartbeat.notifyChannels")} <span className="heartbeat-editor__optional">{t("heartbeat.optional")}</span></label>
|
||||
<div className="set-seg" style={{ alignSelf: "flex-start" }}>
|
||||
<button
|
||||
className={`set-seg__btn${draft.notifyChannels === true ? " set-seg__btn--on" : ""}`}
|
||||
onClick={() => setDraft((prev) => ({ ...prev, notifyChannels: true }))}
|
||||
>
|
||||
{t("heartbeat.notifyChannelsOn")}
|
||||
</button>
|
||||
<button
|
||||
className={`set-seg__btn${draft.notifyChannels !== true ? " set-seg__btn--on" : ""}`}
|
||||
onClick={() => setDraft((prev) => ({ ...prev, notifyChannels: false }))}
|
||||
>
|
||||
{t("heartbeat.notifyChannelsOff")}
|
||||
</button>
|
||||
</div>
|
||||
<span className="heartbeat-editor__mode-hint">
|
||||
{draft.notifyChannels === true
|
||||
? t("heartbeat.notifyChannelsOnHint")
|
||||
: t("heartbeat.notifyChannelsOffHint")}
|
||||
</span>
|
||||
</div>
|
||||
<span className="heartbeat-editor__mode-hint">
|
||||
{normalizeMode(draft.approvalMode) === "yolo" ? t("heartbeat.approvalModeYoloHint") :
|
||||
normalizeMode(draft.approvalMode) === "auto" ? t("heartbeat.approvalModeAutoHint") :
|
||||
t("heartbeat.approvalModeAskHint")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* New conversation per run */}
|
||||
|
||||
@@ -15,4 +15,5 @@ export interface HeartbeatTask {
|
||||
approvalMode?: "ask" | "auto" | "yolo"; // empty defaults to "yolo"
|
||||
timeWindowStart?: string; // "HH:MM" — interval tasks only run after this time
|
||||
timeWindowEnd?: string; // "HH:MM" — interval tasks only run before this time
|
||||
notifyChannels?: boolean; // true = push to bot channels; false/nil = skip
|
||||
}
|
||||
@@ -48,13 +48,18 @@ import type {
|
||||
Mode,
|
||||
ModelInfo,
|
||||
NetworkView,
|
||||
PluginInstallOptions,
|
||||
PluginView,
|
||||
ProjectNode,
|
||||
PromptHistoryEntry,
|
||||
PromptHistoryResult,
|
||||
ProviderPresetView,
|
||||
ProviderView,
|
||||
QuestionAnswer,
|
||||
ServerView,
|
||||
SessionMeta,
|
||||
SessionRecoveryFailedEvent,
|
||||
SessionRecoveryEvent,
|
||||
SettingsView,
|
||||
SkillsSettingsView,
|
||||
SkillRootView,
|
||||
@@ -118,6 +123,10 @@ interface DesktopWindowState {
|
||||
// to AppBindings, then run `pnpm typecheck` to verify.
|
||||
export interface AppBindings {
|
||||
Platform(): Promise<string>;
|
||||
MinimiseMainWindow(): Promise<void>;
|
||||
ToggleMaximiseMainWindow(): Promise<void>;
|
||||
IsMainWindowMaximised(): Promise<boolean>;
|
||||
CloseMainWindow(): Promise<void>;
|
||||
// ── Heartbeat ──
|
||||
HeartbeatListTasks(): Promise<unknown>;
|
||||
HeartbeatReloadTasks(): Promise<unknown>;
|
||||
@@ -203,6 +212,13 @@ export interface AppBindings {
|
||||
Capabilities(): Promise<CapabilitiesView>;
|
||||
MCPServers(): Promise<ServerView[]>;
|
||||
SkillsSettings(): Promise<SkillsSettingsView>;
|
||||
Plugins(): Promise<PluginView[]>;
|
||||
PlanPluginInstall(source: string, options: PluginInstallOptions): Promise<string>;
|
||||
InstallPlugin(source: string, options: PluginInstallOptions): Promise<string>;
|
||||
RemovePlugin(name: string): Promise<void>;
|
||||
SetPluginEnabled(name: string, enabled: boolean): Promise<void>;
|
||||
UpdatePlugin(name: string): Promise<string>;
|
||||
PluginDoctor(name: string): Promise<PluginView>;
|
||||
AddMCPServer(input: MCPServerInput): Promise<number>;
|
||||
UpdateMCPServer(name: string, input: MCPServerInput): Promise<void>;
|
||||
RemoveMCPServer(name: string): Promise<void>;
|
||||
@@ -212,6 +228,7 @@ export interface AppBindings {
|
||||
TrustMCPServerTools(name: string, toolNames: string[]): Promise<void>;
|
||||
UntrustMCPServerTool(name: string, toolName: string): Promise<void>;
|
||||
PickSkillFolder(): Promise<string>;
|
||||
PickPluginFolder(): Promise<string>;
|
||||
AddSkillPath(path: string): Promise<void>;
|
||||
RemoveSkillPath(path: string): Promise<void>;
|
||||
RefreshSkills(): Promise<void>;
|
||||
@@ -273,10 +290,13 @@ export interface AppBindings {
|
||||
SetPlannerModel(ref: string): Promise<void>;
|
||||
SetSubagentModel(ref: string): Promise<void>;
|
||||
SetSubagentEffort(level: string): Promise<void>;
|
||||
SetMaxSubagentDepth(depth: number): Promise<void>;
|
||||
SetAutoPlan(mode: string): Promise<void>;
|
||||
SetDefaultToolApprovalMode(mode: string): Promise<void>;
|
||||
SaveProvider(p: ProviderView): Promise<void>;
|
||||
AddOfficialProviderAccess(kind: string, key: string): Promise<string>;
|
||||
AddProviderPresetAccess(id: string, key: string): Promise<string>;
|
||||
ResetProviderPresetAccess(id: string): Promise<void>;
|
||||
FetchProviderModels(p: ProviderView): Promise<string[]>;
|
||||
DeleteProvider(name: string): Promise<void>;
|
||||
RemoveProviderAccess(name: string): Promise<void>;
|
||||
@@ -285,6 +305,7 @@ export interface AppBindings {
|
||||
SetPermissionMode(mode: string): Promise<void>;
|
||||
AddPermissionRule(list: string, rule: string): Promise<void>;
|
||||
RemovePermissionRule(list: string, rule: string): Promise<void>;
|
||||
ReloadSettings(): Promise<void>;
|
||||
SetSandbox(bash: string, network: boolean, workspaceRoot: string, allowWrite: string[], shell: string): Promise<void>;
|
||||
SetNetwork(n: NetworkView): Promise<void>;
|
||||
SetBotSettings(b: BotSettingsView): Promise<void>;
|
||||
@@ -567,23 +588,38 @@ export function onProjectTreeChanged(cb: () => void): () => void {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
export function onSessionRecovered(cb: (payload: SessionRecoveryEvent) => void): () => void {
|
||||
if (realApp() && typeof window !== "undefined" && window.runtime) {
|
||||
return window.runtime.EventsOn("session:recovered", (payload?: unknown) => cb((payload ?? {}) as SessionRecoveryEvent));
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
|
||||
export function onSessionRecoveryFailed(cb: (payload: SessionRecoveryFailedEvent) => void): () => void {
|
||||
if (realApp() && typeof window !== "undefined" && window.runtime) {
|
||||
return window.runtime.EventsOn("session:recovery-failed", (payload?: unknown) => cb((payload ?? {}) as SessionRecoveryFailedEvent));
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
|
||||
// app proxies each call to the live binding (or the dev mock only when truly
|
||||
// outside the shell), so a late-injected window.go is picked up transparently.
|
||||
function bridgeBreadcrumb(method: string): string {
|
||||
if (method === "ReportCrash") return "";
|
||||
if (/^(Submit|SubmitDisplay|RunShell|Steer|Cancel|Approve|AnswerQuestion|ReplayPendingPrompts)/.test(method))
|
||||
return `turn ${method}`;
|
||||
if (/^(SetModel|SetEffort|SetTokenMode|SetDefaultModel|SetPlannerModel|SetSubagentModel|SetSubagentEffort)/.test(method))
|
||||
if (/^(SetModel|SetEffort|SetTokenMode|SetDefaultModel|SetPlannerModel|SetSubagentModel|SetSubagentEffort|SetMaxSubagentDepth)/.test(method))
|
||||
return `model ${method}`;
|
||||
if (/^(SetDesktop|SetCloseBehavior|SetDisplayMode|SetStatusBar|SetExpandThinking|SetAutoPlan|SetDefaultToolApprovalMode|SetMemoryCompilerEnabled|SetReasoningLanguage)/.test(method))
|
||||
return `settings ${method}`;
|
||||
if (/^(SaveProvider|AddOfficialProviderAccess|RemoveProviderAccess|DeleteProvider|SetProviderKey|ClearProviderKey|FetchProviderModels|ConnectKey)/.test(method))
|
||||
if (/^(SaveProvider|AddOfficialProviderAccess|AddProviderPresetAccess|ResetProviderPresetAccess|RemoveProviderAccess|DeleteProvider|SetProviderKey|ClearProviderKey|FetchProviderModels|ConnectKey)/.test(method))
|
||||
return `provider ${method}`;
|
||||
if (/^(CheckUpdate|DownloadUpdate|InstallUpdate|ApplyUpdate|OpenDownloadPage)/.test(method)) return `update ${method}`;
|
||||
if (/^(AddMCPServer|UpdateMCPServer|RemoveMCPServer|ReconnectMCPServer|ClearMCPServerAuthentication|TrustMCPServerTool|TrustMCPServerTools|UntrustMCPServerTool|SetMCPServer)/.test(method))
|
||||
return `mcp ${method}`;
|
||||
if (/^(AddSkillPath|RemoveSkillPath|RefreshSkills|SetSkillEnabled|AcceptSkillSuggestion)/.test(method))
|
||||
return `skill ${method}`;
|
||||
if (/^(MinimiseMainWindow|ToggleMaximiseMainWindow|IsMainWindowMaximised|CloseMainWindow)$/.test(method)) return `window ${method}`;
|
||||
if (/^(OpenProjectTab|OpenGlobalTab|OpenTopicSession|EnsureBlankTab|ActivateTopic|EnsureBlankSurface|SetActiveTab|CloseTab|ReorderTabs|CreateTopic|RenameTopic|DeleteTopic|TrashTopic|RenameProject|RemoveWorkspace|SwitchWorkspace|PickWorkspace)/.test(method))
|
||||
return `nav ${method}`;
|
||||
return "";
|
||||
@@ -681,18 +717,149 @@ function browserPlatformOverride(): "darwin" | "windows" | "linux" | "" {
|
||||
return value === "darwin" || value === "windows" || value === "linux" ? value : "";
|
||||
}
|
||||
|
||||
function mockScenario(): "demo" | "fresh" | "running" {
|
||||
function mockScenario(): "demo" | "fresh" | "running" | "guidance" {
|
||||
if (typeof window === "undefined") return "demo";
|
||||
const value = new URLSearchParams(window.location.search).get("mock")?.trim().toLowerCase();
|
||||
if (value === "fresh" || value === "empty" || value === "first-run") return "fresh";
|
||||
if (value === "guidance" || value === "guide" || value === "steer") return "guidance";
|
||||
if (value === "running" || value === "busy" || value === "streaming") return "running";
|
||||
return "demo";
|
||||
}
|
||||
|
||||
type MockProviderPresetTemplate = {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
keyEnv: string;
|
||||
provider: ProviderView;
|
||||
};
|
||||
|
||||
function mockProviderTemplate(p: Pick<ProviderView, "name" | "kind" | "baseUrl" | "models" | "default" | "apiKeyEnv"> & Partial<ProviderView>): ProviderView {
|
||||
return {
|
||||
name: p.name,
|
||||
builtIn: false,
|
||||
added: true,
|
||||
kind: p.kind,
|
||||
baseUrl: p.baseUrl,
|
||||
modelsUrl: p.modelsUrl ?? "",
|
||||
models: p.models,
|
||||
visionModels: p.visionModels ?? [],
|
||||
visionModelsConfigured: Boolean(p.visionModelsConfigured ?? ((p.visionModels ?? []).length > 0)),
|
||||
default: p.default,
|
||||
apiKeyEnv: p.apiKeyEnv,
|
||||
headers: p.headers,
|
||||
extraBody: p.extraBody,
|
||||
authHeader: p.authHeader,
|
||||
keySet: Boolean(p.keySet),
|
||||
balanceUrl: p.balanceUrl ?? "",
|
||||
contextWindow: p.contextWindow ?? 0,
|
||||
reasoningProtocol: p.reasoningProtocol ?? "",
|
||||
thinking: p.thinking ?? "",
|
||||
supportedEfforts: p.supportedEfforts ?? [],
|
||||
defaultEffort: p.defaultEffort ?? "",
|
||||
modelOverrides: p.modelOverrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mockPreset(id: string, label: string, description: string, keyEnv: string, provider: ProviderView): MockProviderPresetTemplate {
|
||||
return { id, label, description, keyEnv, provider };
|
||||
}
|
||||
|
||||
const mockKimiAPIModels = ["kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6", "kimi-k2.5"];
|
||||
const mockMiMoV25Models = ["mimo-v2.5-pro", "mimo-v2.5"];
|
||||
const mockMiniMaxModels = ["MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.7-highspeed"];
|
||||
const mockGLMAPIModels = ["glm-5.2", "glm-5.1", "glm-5", "glm-5-turbo", "glm-5v-turbo", "glm-4.7", "glm-4.7-flash", "glm-4.7-flashx", "glm-4.6", "glm-4.5", "glm-4.5-air", "glm-4.5-flash"];
|
||||
const mockGLMCodingModels = ["glm-5.2", "glm-5.1", "glm-5", "glm-4.7"];
|
||||
const mockGLMAnthropicModels = ["glm-5.2[1m]", "glm-5.2", "glm-5.1", "glm-5", "glm-4.7", "glm-4.5-air"];
|
||||
const mockQwenAPIModels = ["qwen3.7-plus", "qwen3.7-max", "qwen3.6-plus", "qwen3.5-plus", "qwen3-max-2026-01-23", "qwen3-coder-next", "qwen3-coder-plus", "MiniMax-M2.5", "glm-5", "glm-4.7", "kimi-k2.5"];
|
||||
const mockQwenPlanModels = ["qwen3.7-plus", "qwen3.6-plus", "kimi-k2.5", "glm-5", "MiniMax-M2.5", "qwen3.5-plus", "qwen3-max-2026-01-23", "qwen3-coder-next", "qwen3-coder-plus", "glm-4.7"];
|
||||
const mockQwenPlanVisionModels = ["qwen3.7-plus", "qwen3.6-plus", "qwen3.5-plus", "kimi-k2.5"];
|
||||
const mockStepFunModels = ["step-3.7-flash", "step-3.5-flash", "step-3.5-flash-2603"];
|
||||
const mockOpenCodeGoModels = ["glm-5.2", "glm-5.1", "kimi-k2.7-code", "kimi-k2.6", "deepseek-v4-pro", "deepseek-v4-flash", "mimo-v2.5-pro", "mimo-v2.5"];
|
||||
const mockOpenCodeGoAnthropicModels = ["qwen3.7-plus", "qwen3.7-max", "qwen3.6-plus", "minimax-m3", "minimax-m2.7", "minimax-m2.5"];
|
||||
const mockOpenCodeZenAnthropicModels = ["claude-sonnet-4-6", "claude-opus-4-8", "claude-haiku-4-5", "qwen3.6-plus", "qwen3.5-plus", "qwen3.6-plus-free"];
|
||||
const mockNovitaModels = ["zai-org/glm-5.2", "moonshotai/kimi-k2.7-code", "minimax/minimax-m3", "deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-flash", "qwen/qwen3.7-max", "qwen/qwen3.6-plus", "zai-org/glm-5v-turbo"];
|
||||
const mockGMIModels = ["zai-org/GLM-5.2-FP8", "deepseek-ai/DeepSeek-V4-Pro", "deepseek-ai/DeepSeek-V4-Flash", "moonshotai/Kimi-K2.7-Code", "anthropic/claude-sonnet-4.6", "openai/gpt-5.5"];
|
||||
const mockVercelModels = ["anthropic/claude-sonnet-4.6", "anthropic/claude-opus-4.8", "openai/gpt-5.4", "openai/gpt-5.4-pro", "moonshotai/kimi-k2.7-code", "zai/glm-5.2", "deepseek/deepseek-v4-pro"];
|
||||
const mockOllamaCloudModels = ["glm-5.2", "kimi-k2.7-code", "deepseek-v4-pro", "deepseek-v4-flash", "minimax-m3", "nemotron-3-nano:30b", "qwen3-coder-next"];
|
||||
|
||||
const mockProviderPresetTemplates: MockProviderPresetTemplate[] = [
|
||||
mockPreset("kimi-cn", "Kimi CN API", "Moonshot Kimi China OpenAI-compatible API.", "KIMI_API_KEY", mockProviderTemplate({ name: "kimi-cn", kind: "openai", baseUrl: "https://api.moonshot.cn/v1", models: mockKimiAPIModels, visionModels: mockKimiAPIModels, default: "kimi-k2.7-code", apiKeyEnv: "KIMI_API_KEY", balanceUrl: "https://api.moonshot.cn/v1/users/me/balance", contextWindow: 262144, reasoningProtocol: "none" })),
|
||||
mockPreset("kimi-global", "Kimi Global API", "Moonshot Kimi international OpenAI-compatible API.", "MOONSHOT_API_KEY", mockProviderTemplate({ name: "kimi-global", kind: "openai", baseUrl: "https://api.moonshot.ai/v1", models: mockKimiAPIModels, visionModels: mockKimiAPIModels, default: "kimi-k2.7-code", apiKeyEnv: "MOONSHOT_API_KEY", balanceUrl: "https://api.moonshot.ai/v1/users/me/balance", contextWindow: 262144, reasoningProtocol: "none" })),
|
||||
mockPreset("kimi-coding-plan", "Kimi Coding Plan", "Kimi Coding Plan via its dedicated Anthropic-compatible endpoint.", "KIMI_CODING_API_KEY", mockProviderTemplate({ name: "kimi-coding-plan", kind: "anthropic", baseUrl: "https://api.kimi.com/coding/", models: ["kimi-for-coding"], visionModels: ["kimi-for-coding"], default: "kimi-for-coding", apiKeyEnv: "KIMI_CODING_API_KEY", headers: { "User-Agent": "claude-code/0.1.0" }, thinking: "adaptive", contextWindow: 262144 })),
|
||||
mockPreset("mimo-api", "MiMo API", "Xiaomi MiMo direct API with text and vision-capable models.", "MIMO_API_KEY", mockProviderTemplate({ name: "mimo-api", kind: "openai", baseUrl: "https://api.xiaomimimo.com/v1", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_API_KEY", contextWindow: 1048576 })),
|
||||
mockPreset("mimo-anthropic", "MiMo Anthropic", "Xiaomi MiMo direct Anthropic-compatible endpoint.", "MIMO_API_KEY", mockProviderTemplate({ name: "mimo-anthropic", kind: "anthropic", baseUrl: "https://api.xiaomimimo.com/anthropic", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_API_KEY", thinking: "adaptive", contextWindow: 1048576 })),
|
||||
mockPreset("mimo-token-plan-cn", "MiMo Token Plan CN", "Xiaomi MiMo token-plan China endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ name: "mimo-token-plan-cn", kind: "openai", baseUrl: "https://token-plan-cn.xiaomimimo.com/v1", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", contextWindow: 1048576 })),
|
||||
mockPreset("mimo-token-plan-cn-anthropic", "MiMo Token Plan CN Anthropic", "Xiaomi MiMo token-plan China Anthropic-compatible endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ name: "mimo-token-plan-cn-anthropic", kind: "anthropic", baseUrl: "https://token-plan-cn.xiaomimimo.com/anthropic", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", thinking: "adaptive", contextWindow: 1048576 })),
|
||||
mockPreset("mimo-token-plan-sgp", "MiMo Token Plan SGP", "Xiaomi MiMo token-plan Singapore endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ name: "mimo-token-plan-sgp", kind: "openai", baseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", contextWindow: 1048576 })),
|
||||
mockPreset("mimo-token-plan-sgp-anthropic", "MiMo Token Plan SGP Anthropic", "Xiaomi MiMo token-plan Singapore Anthropic-compatible endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ name: "mimo-token-plan-sgp-anthropic", kind: "anthropic", baseUrl: "https://token-plan-sgp.xiaomimimo.com/anthropic", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", thinking: "adaptive", contextWindow: 1048576 })),
|
||||
mockPreset("mimo-token-plan-ams", "MiMo Token Plan AMS", "Xiaomi MiMo token-plan Amsterdam endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ name: "mimo-token-plan-ams", kind: "openai", baseUrl: "https://token-plan-ams.xiaomimimo.com/v1", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", contextWindow: 1048576 })),
|
||||
mockPreset("mimo-token-plan-ams-anthropic", "MiMo Token Plan AMS Anthropic", "Xiaomi MiMo token-plan Amsterdam Anthropic-compatible endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ name: "mimo-token-plan-ams-anthropic", kind: "anthropic", baseUrl: "https://token-plan-ams.xiaomimimo.com/anthropic", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", thinking: "adaptive", contextWindow: 1048576 })),
|
||||
mockPreset("minimax-cn-api", "MiniMax CN API", "MiniMax China OpenAI-compatible M-series API endpoint.", "MINIMAX_API_KEY", mockProviderTemplate({ name: "minimax-cn-api", kind: "openai", baseUrl: "https://api.minimaxi.com/v1", models: mockMiniMaxModels, visionModels: ["MiniMax-M3"], default: "MiniMax-M3", apiKeyEnv: "MINIMAX_API_KEY", extraBody: { reasoning_split: true }, contextWindow: 1048576, thinking: "adaptive", supportedEfforts: ["disabled", "adaptive"], defaultEffort: "adaptive" })),
|
||||
mockPreset("minimax-global-api", "MiniMax Global API", "MiniMax international OpenAI-compatible M-series API endpoint.", "MINIMAX_API_KEY", mockProviderTemplate({ name: "minimax-global-api", kind: "openai", baseUrl: "https://api.minimax.io/v1", models: mockMiniMaxModels, visionModels: ["MiniMax-M3"], default: "MiniMax-M3", apiKeyEnv: "MINIMAX_API_KEY", extraBody: { reasoning_split: true }, contextWindow: 1048576, thinking: "adaptive", supportedEfforts: ["disabled", "adaptive"], defaultEffort: "adaptive" })),
|
||||
mockPreset("minimax-cn-anthropic", "MiniMax CN Anthropic", "MiniMax China Anthropic-compatible M-series endpoint.", "MINIMAX_PLAN_API_KEY", mockProviderTemplate({ name: "minimax-cn-anthropic", kind: "anthropic", baseUrl: "https://api.minimaxi.com/anthropic", models: mockMiniMaxModels, visionModels: ["MiniMax-M3"], default: "MiniMax-M3", apiKeyEnv: "MINIMAX_PLAN_API_KEY", authHeader: true, contextWindow: 1048576, thinking: "adaptive", supportedEfforts: ["disabled", "adaptive"], defaultEffort: "adaptive" })),
|
||||
mockPreset("minimax-global-anthropic", "MiniMax Global Anthropic", "MiniMax international Anthropic-compatible endpoint with Bearer auth.", "MINIMAX_API_KEY", mockProviderTemplate({ name: "minimax-global-anthropic", kind: "anthropic", baseUrl: "https://api.minimax.io/anthropic", models: mockMiniMaxModels, visionModels: ["MiniMax-M3"], default: "MiniMax-M3", apiKeyEnv: "MINIMAX_API_KEY", authHeader: true, contextWindow: 1048576, thinking: "adaptive", supportedEfforts: ["disabled", "adaptive"], defaultEffort: "adaptive" })),
|
||||
mockPreset("glm-cn", "GLM CN API", "Zhipu GLM China OpenAI-compatible API with thinking controls.", "GLM_API_KEY", mockProviderTemplate({ name: "glm-cn", kind: "openai", baseUrl: "https://open.bigmodel.cn/api/paas/v4", models: mockGLMAPIModels, visionModels: ["glm-5v-turbo"], default: "glm-5.2", apiKeyEnv: "GLM_API_KEY", contextWindow: 1000000, thinking: "enabled", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" })),
|
||||
mockPreset("zai-global", "Z.AI Global API", "Z.AI international OpenAI-compatible GLM API.", "ZAI_API_KEY", mockProviderTemplate({ name: "zai-global", kind: "openai", baseUrl: "https://api.z.ai/api/paas/v4", models: mockGLMAPIModels, visionModels: ["glm-5v-turbo"], default: "glm-5.2", apiKeyEnv: "ZAI_API_KEY", contextWindow: 1000000, thinking: "enabled", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" })),
|
||||
mockPreset("glm-coding-plan-cn", "GLM Coding Plan CN", "Zhipu GLM China coding-plan endpoint.", "GLM_PLAN_API_KEY", mockProviderTemplate({ name: "glm-coding-plan-cn", kind: "openai", baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", models: mockGLMCodingModels, default: "glm-5.2", apiKeyEnv: "GLM_PLAN_API_KEY", contextWindow: 1000000, thinking: "enabled", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" })),
|
||||
mockPreset("glm-coding-plan-cn-anthropic", "GLM Coding Plan CN Anthropic", "Zhipu GLM China coding-plan Anthropic-compatible endpoint.", "GLM_PLAN_API_KEY", mockProviderTemplate({ name: "glm-coding-plan-cn-anthropic", kind: "anthropic", baseUrl: "https://open.bigmodel.cn/api/anthropic", models: mockGLMAnthropicModels, default: "glm-5.2[1m]", apiKeyEnv: "GLM_PLAN_API_KEY", authHeader: true, thinking: "adaptive", contextWindow: 1000000 })),
|
||||
mockPreset("zai-coding-plan-global", "Z.AI Coding Plan Global", "Z.AI international coding-plan endpoint.", "ZAI_CODING_API_KEY", mockProviderTemplate({ name: "zai-coding-plan-global", kind: "openai", baseUrl: "https://api.z.ai/api/coding/paas/v4", models: mockGLMCodingModels, default: "glm-5.2", apiKeyEnv: "ZAI_CODING_API_KEY", contextWindow: 1000000, thinking: "enabled", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" })),
|
||||
mockPreset("zai-coding-plan-global-anthropic", "Z.AI Coding Plan Global Anthropic", "Z.AI international coding-plan Anthropic-compatible endpoint.", "ZAI_CODING_API_KEY", mockProviderTemplate({ name: "zai-coding-plan-global-anthropic", kind: "anthropic", baseUrl: "https://api.z.ai/api/anthropic", models: mockGLMAnthropicModels, default: "glm-5.2[1m]", apiKeyEnv: "ZAI_CODING_API_KEY", authHeader: true, thinking: "adaptive", contextWindow: 1000000 })),
|
||||
mockPreset("opencode-go", "OpenCode Go", "OpenCode Go relay with per-model capability overrides.", "OPENCODE_GO_API_KEY", mockProviderTemplate({ name: "opencode-go", kind: "openai", baseUrl: "https://opencode.ai/zen/go/v1", models: mockOpenCodeGoModels, default: "glm-5.2", apiKeyEnv: "OPENCODE_GO_API_KEY", contextWindow: 128000 })),
|
||||
mockPreset("opencode-go-anthropic", "OpenCode Go Anthropic", "OpenCode Go subscription Anthropic-compatible route for Qwen and MiniMax models.", "OPENCODE_GO_API_KEY", mockProviderTemplate({ name: "opencode-go-anthropic", kind: "anthropic", baseUrl: "https://opencode.ai/zen/go", models: mockOpenCodeGoAnthropicModels, visionModels: ["qwen3.7-plus", "qwen3.6-plus"], default: "qwen3.7-plus", apiKeyEnv: "OPENCODE_GO_API_KEY", thinking: "adaptive", contextWindow: 262144 })),
|
||||
mockPreset("opencode-zen-anthropic", "OpenCode Zen Anthropic", "OpenCode Zen Anthropic-compatible route for Claude and Qwen models.", "OPENCODE_API_KEY", mockProviderTemplate({ name: "opencode-zen-anthropic", kind: "anthropic", baseUrl: "https://opencode.ai/zen", models: mockOpenCodeZenAnthropicModels, visionModels: ["claude-sonnet-4-6", "claude-opus-4-8", "claude-haiku-4-5"], default: "claude-sonnet-4-6", apiKeyEnv: "OPENCODE_API_KEY", contextWindow: 262144 })),
|
||||
mockPreset("qwen-cn", "Qwen CN API", "Alibaba DashScope China standard OpenAI-compatible endpoint.", "QWEN_API_KEY", mockProviderTemplate({ name: "qwen-cn", kind: "openai", baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", models: mockQwenAPIModels, visionModels: ["qwen3.7-plus", "qwen3.6-plus", "qwen3.5-plus", "kimi-k2.5"], default: "qwen3.7-plus", apiKeyEnv: "QWEN_API_KEY" })),
|
||||
mockPreset("qwen-global", "Qwen Global API", "Alibaba DashScope international standard OpenAI-compatible endpoint.", "QWEN_API_KEY", mockProviderTemplate({ name: "qwen-global", kind: "openai", baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", models: mockQwenAPIModels, visionModels: ["qwen3.7-plus", "qwen3.6-plus", "qwen3.5-plus", "kimi-k2.5"], default: "qwen3.7-plus", apiKeyEnv: "QWEN_API_KEY" })),
|
||||
mockPreset("qwen-coding-plan-cn", "Qwen Coding Plan CN", "Alibaba Cloud Qwen Coding Plan China endpoint.", "QWEN_CODING_API_KEY", mockProviderTemplate({ name: "qwen-coding-plan-cn", kind: "openai", baseUrl: "https://coding.dashscope.aliyuncs.com/v1", models: mockQwenPlanModels, visionModels: mockQwenPlanVisionModels, default: "qwen3.7-plus", apiKeyEnv: "QWEN_CODING_API_KEY" })),
|
||||
mockPreset("qwen-coding-plan-cn-anthropic", "Qwen Coding Plan CN Anthropic", "Alibaba Cloud Qwen Coding Plan China Anthropic-compatible endpoint.", "QWEN_CODING_API_KEY", mockProviderTemplate({ name: "qwen-coding-plan-cn-anthropic", kind: "anthropic", baseUrl: "https://coding.dashscope.aliyuncs.com/apps/anthropic", models: mockQwenPlanModels, visionModels: mockQwenPlanVisionModels, default: "qwen3.7-plus", apiKeyEnv: "QWEN_CODING_API_KEY", thinking: "adaptive" })),
|
||||
mockPreset("qwen-coding-plan-global", "Qwen Coding Plan Global", "Alibaba Cloud Qwen Coding Plan international endpoint.", "QWEN_CODING_API_KEY", mockProviderTemplate({ name: "qwen-coding-plan-global", kind: "openai", baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1", models: mockQwenPlanModels, visionModels: mockQwenPlanVisionModels, default: "qwen3.7-plus", apiKeyEnv: "QWEN_CODING_API_KEY" })),
|
||||
mockPreset("qwen-coding-plan-global-anthropic", "Qwen Coding Plan Global Anthropic", "Alibaba Cloud Qwen Coding Plan international Anthropic-compatible endpoint.", "QWEN_CODING_API_KEY", mockProviderTemplate({ name: "qwen-coding-plan-global-anthropic", kind: "anthropic", baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic", models: mockQwenPlanModels, visionModels: mockQwenPlanVisionModels, default: "qwen3.7-plus", apiKeyEnv: "QWEN_CODING_API_KEY", thinking: "adaptive" })),
|
||||
mockPreset("stepfun", "StepFun", "StepFun coding-plan OpenAI-compatible endpoint.", "STEPFUN_API_KEY", mockProviderTemplate({ name: "stepfun", kind: "openai", baseUrl: "https://api.stepfun.ai/step_plan/v1", models: mockStepFunModels, default: "step-3.7-flash", apiKeyEnv: "STEPFUN_API_KEY", supportedEfforts: ["low", "medium", "high"], defaultEffort: "medium" })),
|
||||
mockPreset("stepfun-anthropic", "StepFun Anthropic", "StepFun coding-plan Anthropic-compatible endpoint.", "STEPFUN_API_KEY", mockProviderTemplate({ name: "stepfun-anthropic", kind: "anthropic", baseUrl: "https://api.stepfun.ai/step_plan", models: mockStepFunModels, default: "step-3.7-flash", apiKeyEnv: "STEPFUN_API_KEY", thinking: "adaptive", supportedEfforts: ["low", "medium", "high"], defaultEffort: "medium" })),
|
||||
mockPreset("novita", "NovitaAI", "NovitaAI OpenAI-compatible multi-model gateway.", "NOVITA_API_KEY", mockProviderTemplate({ name: "novita", kind: "openai", baseUrl: "https://api.novita.ai/openai/v1", models: mockNovitaModels, default: "zai-org/glm-5.2", apiKeyEnv: "NOVITA_API_KEY" })),
|
||||
mockPreset("gmi", "GMI Cloud", "GMI Cloud direct multi-model OpenAI-compatible gateway.", "GMI_API_KEY", mockProviderTemplate({ name: "gmi", kind: "openai", baseUrl: "https://api.gmi-serving.com/v1", models: mockGMIModels, default: "zai-org/GLM-5.2-FP8", apiKeyEnv: "GMI_API_KEY", headers: { "User-Agent": "Reasonix" } })),
|
||||
mockPreset("vercel-ai-gateway", "Vercel AI Gateway", "Vercel AI Gateway via Anthropic-compatible Messages API.", "AI_GATEWAY_API_KEY", mockProviderTemplate({ name: "vercel-ai-gateway", kind: "anthropic", baseUrl: "https://ai-gateway.vercel.sh", models: mockVercelModels, visionModels: ["anthropic/claude-sonnet-4.6", "anthropic/claude-opus-4.8", "openai/gpt-5.4", "openai/gpt-5.4-pro", "moonshotai/kimi-k2.7-code"], default: "anthropic/claude-sonnet-4.6", apiKeyEnv: "AI_GATEWAY_API_KEY", authHeader: true, contextWindow: 1000000 })),
|
||||
mockPreset("huggingface", "HuggingFace Router", "HuggingFace Inference Router OpenAI-compatible endpoint.", "HF_TOKEN", mockProviderTemplate({ name: "huggingface", kind: "openai", baseUrl: "https://router.huggingface.co/v1", models: ["zai-org/GLM-5.2", "deepseek-ai/DeepSeek-V3.2", "Qwen/Qwen3.5-72B-Instruct"], default: "zai-org/GLM-5.2", apiKeyEnv: "HF_TOKEN" })),
|
||||
mockPreset("nvidia", "NVIDIA NIM", "NVIDIA NIM OpenAI-compatible accelerated inference endpoint.", "NVIDIA_API_KEY", mockProviderTemplate({ name: "nvidia", kind: "openai", baseUrl: "https://integrate.api.nvidia.com/v1", models: ["nvidia/nemotron-3-nano-30b-a3b", "nvidia/nemotron-3-super-120b-a12b", "nvidia/nemotron-3-ultra-550b-a55b", "deepseek-ai/deepseek-v4-pro", "qwen/qwen3.5-397b-a17b"], default: "nvidia/nemotron-3-nano-30b-a3b", apiKeyEnv: "NVIDIA_API_KEY" })),
|
||||
mockPreset("kilocode", "KiloCode", "Kilo Code gateway OpenAI-compatible endpoint.", "KILOCODE_API_KEY", mockProviderTemplate({ name: "kilocode", kind: "openai", baseUrl: "https://api.kilo.ai/api/gateway", models: ["kilo/auto"], default: "kilo/auto", apiKeyEnv: "KILOCODE_API_KEY" })),
|
||||
mockPreset("ollama-cloud", "Ollama Cloud", "Hosted Ollama Cloud OpenAI-compatible endpoint with max reasoning effort.", "OLLAMA_API_KEY", mockProviderTemplate({ name: "ollama-cloud", kind: "openai", baseUrl: "https://ollama.com/v1", models: mockOllamaCloudModels, default: "glm-5.2", apiKeyEnv: "OLLAMA_API_KEY" })),
|
||||
];
|
||||
|
||||
function mockProviderPresetViews(): ProviderPresetView[] {
|
||||
return mockProviderPresetTemplates.map((template) => ({
|
||||
id: template.id,
|
||||
label: template.label,
|
||||
description: template.description,
|
||||
keyEnv: template.keyEnv,
|
||||
providerNames: [template.provider.name],
|
||||
models: [...template.provider.models],
|
||||
added: false,
|
||||
status: "available",
|
||||
statusProviderNames: [],
|
||||
keySet: false,
|
||||
requiresKey: true,
|
||||
configured: false,
|
||||
}));
|
||||
}
|
||||
|
||||
function cloneMockProviderTemplate(id: string, key: string): ProviderView | undefined {
|
||||
const template = mockProviderPresetTemplates.find((candidate) => candidate.id === id);
|
||||
if (!template) return undefined;
|
||||
return {
|
||||
...JSON.parse(JSON.stringify(template.provider)) as ProviderView,
|
||||
keySet: Boolean(key.trim()),
|
||||
};
|
||||
}
|
||||
|
||||
const mockPreviewImageDataURL =
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='120' viewBox='0 0 160 120'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%23f97316'/%3E%3Cstop offset='1' stop-color='%232563eb'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='160' height='120' rx='14' fill='url(%23g)'/%3E%3Ccircle cx='44' cy='38' r='16' fill='%23fff7ed' opacity='.9'/%3E%3Cpath d='M18 96 62 58l24 22 18-16 38 32z' fill='%23ffffff' opacity='.9'/%3E%3C/svg%3E";
|
||||
|
||||
function makeMockApp(): AppBindings {
|
||||
const scenario = mockScenario();
|
||||
const freshMock = scenario === "fresh";
|
||||
const runningMock = scenario === "running";
|
||||
const guidanceMock = scenario === "guidance";
|
||||
const runningMock = scenario === "running" || guidanceMock;
|
||||
const mockAttachmentDataURLs = new Map<string, string>();
|
||||
let cancelled = false;
|
||||
let pendingAskPreview = false;
|
||||
let pendingApprovalPreview = false;
|
||||
@@ -781,6 +948,7 @@ function makeMockApp(): AppBindings {
|
||||
],
|
||||
},
|
||||
];
|
||||
let capPlugins: PluginView[] = [];
|
||||
const mockSwitchWorkspace = async (path: string) => {
|
||||
cwd = path || "~";
|
||||
workspaces = [cwd, ...workspaces.filter((p) => p !== cwd)].slice(0, 12);
|
||||
@@ -863,37 +1031,64 @@ function makeMockApp(): AppBindings {
|
||||
subagentEffort: "",
|
||||
autoPlan: "off",
|
||||
providers: [
|
||||
{ name: "deepseek", builtIn: true, added: false, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: true, balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", supportedEfforts: [], defaultEffort: "" },
|
||||
{ name: "deepseek", builtIn: true, added: false, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: true, balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", thinking: "", supportedEfforts: [], defaultEffort: "" },
|
||||
],
|
||||
officialProviders: [
|
||||
{ name: "deepseek", builtIn: true, added: false, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash", "deepseek-v4-pro"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: true, balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", supportedEfforts: [], defaultEffort: "" },
|
||||
{ name: "deepseek", builtIn: true, added: false, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash", "deepseek-v4-pro"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: true, balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", thinking: "", supportedEfforts: [], defaultEffort: "" },
|
||||
],
|
||||
providerPresets: mockProviderPresetViews(),
|
||||
permissions: { mode: "ask", allow: ["ls", "read_file"], ask: [], deny: ["Bash(rm:*)"] },
|
||||
sandbox: { bash: "enforce", network: true, workspaceRoot: "", allowWrite: [], shell: "auto" },
|
||||
sandbox: { bash: "enforce", network: true, workspaceRoot: "", allowWrite: [], effectiveWorkspaceRoot: cwd, effectiveWriteRoots: [cwd], shell: "auto" },
|
||||
network: {
|
||||
proxyMode: "auto",
|
||||
proxyUrl: "",
|
||||
noProxy: "",
|
||||
proxy: { type: "socks5", server: "127.0.0.1", port: 7890, username: "", password: "" },
|
||||
},
|
||||
agent: { temperature: 0.2, maxSteps: 0, plannerMaxSteps: 0, systemPrompt: "You are Reasonix, a coding agent.", coldResumePrune: true, reasoningLanguage: "auto" },
|
||||
agent: { temperature: 0.2, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, systemPrompt: "You are Reasonix, a coding agent.", coldResumePrune: true, reasoningLanguage: "auto" },
|
||||
bot: {
|
||||
enabled: !freshMock,
|
||||
model: "",
|
||||
toolApprovalMode: "ask",
|
||||
maxSteps: 25,
|
||||
debounceMs: 1500,
|
||||
queueMode: "steer",
|
||||
queueCap: 20,
|
||||
queueDrop: "summarize",
|
||||
ignoreSelfMessages: true,
|
||||
selfUserIds: {
|
||||
qq: [],
|
||||
feishu: [],
|
||||
weixin: [],
|
||||
},
|
||||
control: {
|
||||
enabled: false,
|
||||
addr: "127.0.0.1:37913",
|
||||
tokenEnv: "REASONIX_BOT_CONTROL_TOKEN",
|
||||
},
|
||||
pairing: {
|
||||
enabled: true,
|
||||
requestTtlMinutes: 60,
|
||||
maxPendingPerPlatform: 3,
|
||||
},
|
||||
routes: [],
|
||||
allowlist: {
|
||||
enabled: true,
|
||||
allowAll: false,
|
||||
qqUsers: [],
|
||||
feishuUsers: freshMock ? [] : ["ou_mock_user_001"],
|
||||
weixinUsers: freshMock ? [] : ["wxid_mock_user_001"],
|
||||
qqApprovers: [],
|
||||
feishuApprovers: [],
|
||||
weixinApprovers: [],
|
||||
qqAdmins: [],
|
||||
feishuAdmins: [],
|
||||
weixinAdmins: [],
|
||||
qqGroups: [],
|
||||
feishuGroups: [],
|
||||
weixinGroups: [],
|
||||
},
|
||||
qq: { enabled: false, appId: "", appSecretEnv: "QQ_BOT_APP_SECRET", secretSet: false, sandbox: false },
|
||||
qq: { enabled: false, appId: "", appSecretEnv: "QQ_BOT_APP_SECRET", secretSet: false, sandbox: false, model: "", toolApprovalMode: "ask", workspaceRoot: "", access: { enabled: true, allowAll: false, pairingEnabled: true, users: [], groups: [], approvers: [], admins: [] } },
|
||||
feishu: {
|
||||
enabled: false,
|
||||
domain: "feishu",
|
||||
@@ -920,10 +1115,11 @@ function makeMockApp(): AppBindings {
|
||||
label: "kun",
|
||||
enabled: true,
|
||||
status: "connected",
|
||||
model: "",
|
||||
toolApprovalMode: "",
|
||||
workspaceRoot: "",
|
||||
credential: {
|
||||
model: "",
|
||||
toolApprovalMode: "",
|
||||
workspaceRoot: "",
|
||||
access: { enabled: true, allowAll: false, pairingEnabled: true, users: ["ou_mock_user_001"], groups: [], approvers: [], admins: [] },
|
||||
credential: {
|
||||
appId: "cli_mock_lark",
|
||||
appSecretEnv: "FEISHU_BOT_APP_SECRET",
|
||||
accountId: "",
|
||||
@@ -954,10 +1150,11 @@ function makeMockApp(): AppBindings {
|
||||
label: "kun",
|
||||
enabled: true,
|
||||
status: "connected",
|
||||
model: "",
|
||||
toolApprovalMode: "",
|
||||
workspaceRoot: "",
|
||||
credential: {
|
||||
model: "",
|
||||
toolApprovalMode: "",
|
||||
workspaceRoot: "",
|
||||
access: { enabled: true, allowAll: false, pairingEnabled: true, users: ["wxid_mock_user_001"], groups: [], approvers: [], admins: [] },
|
||||
credential: {
|
||||
appId: "",
|
||||
appSecretEnv: "",
|
||||
accountId: "default",
|
||||
@@ -997,7 +1194,7 @@ function makeMockApp(): AppBindings {
|
||||
metrics: true,
|
||||
memoryCompilerEnabled: true,
|
||||
configPath: "~/projects/reasonix/reasonix.toml",
|
||||
providerKinds: ["openai"],
|
||||
providerKinds: ["openai", "anthropic"],
|
||||
autoApproveTools: false,
|
||||
bypass: false,
|
||||
};
|
||||
@@ -1381,7 +1578,7 @@ function makeMockApp(): AppBindings {
|
||||
collaborationMode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
active: true,
|
||||
active: !guidanceMock,
|
||||
cwd: "~/projects/joyquant-db",
|
||||
},
|
||||
{
|
||||
@@ -1401,7 +1598,7 @@ function makeMockApp(): AppBindings {
|
||||
collaborationMode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
active: false,
|
||||
active: guidanceMock,
|
||||
cwd: "~/projects/joyquant-sys",
|
||||
},
|
||||
{
|
||||
@@ -1453,6 +1650,18 @@ function makeMockApp(): AppBindings {
|
||||
}
|
||||
};
|
||||
return {
|
||||
async MinimiseMainWindow() {
|
||||
console.info("mock MinimiseMainWindow");
|
||||
},
|
||||
async ToggleMaximiseMainWindow() {
|
||||
console.info("mock ToggleMaximiseMainWindow");
|
||||
},
|
||||
async IsMainWindowMaximised() {
|
||||
return false;
|
||||
},
|
||||
async CloseMainWindow() {
|
||||
console.info("mock CloseMainWindow");
|
||||
},
|
||||
async Platform() {
|
||||
const override = browserPlatformOverride();
|
||||
if (override) return override;
|
||||
@@ -1824,7 +2033,7 @@ function makeMockApp(): AppBindings {
|
||||
async ClearSession() {},
|
||||
async Checkpoints() {
|
||||
return [
|
||||
{ turn: 0, prompt: "你好呀", files: ["src/App.tsx"], turnFileCount: 1, time: Date.now() - 30_000, canCode: true, canConversation: true },
|
||||
{ turn: 0, prompt: "你好呀", files: ["src/App.tsx"], fileCount: 1, turnFileCount: 1, time: Date.now() - 30_000, canCode: true, canConversation: true },
|
||||
];
|
||||
},
|
||||
async CheckpointsForTab() {
|
||||
@@ -2136,6 +2345,7 @@ function makeMockApp(): AppBindings {
|
||||
{ name: "model", description: "Switch model", kind: "builtin" as const },
|
||||
{ name: "effort", description: "Set reasoning effort", kind: "builtin" as const },
|
||||
{ name: "skill", description: "List skills", kind: "builtin" as const },
|
||||
{ name: "plugins", description: "Manage plugin packages", kind: "builtin" as const },
|
||||
{ name: "explore", description: "Investigate the codebase in an isolated subagent", kind: "skill" as const },
|
||||
{ name: "review", description: "Review the staged diff", hint: "[focus]", kind: "custom" as const },
|
||||
];
|
||||
@@ -2145,6 +2355,7 @@ function makeMockApp(): AppBindings {
|
||||
servers: capServers.map((s) => ({ ...s })),
|
||||
skills: capSkills.map((s) => ({ ...s })),
|
||||
skillRoots: capSkillRoots.map((s) => ({ ...s })),
|
||||
plugins: capPlugins.map((p) => ({ ...p })),
|
||||
};
|
||||
},
|
||||
async MCPServers() {
|
||||
@@ -2156,6 +2367,59 @@ function makeMockApp(): AppBindings {
|
||||
skillRoots: capSkillRoots.map((s) => ({ ...s })),
|
||||
};
|
||||
},
|
||||
async Plugins() {
|
||||
return capPlugins.map((p) => ({ ...p }));
|
||||
},
|
||||
async PlanPluginInstall(source: string, options: PluginInstallOptions) {
|
||||
const name = options.name || source.split("/").filter(Boolean).pop()?.replace(/\.git$/, "") || "plugin";
|
||||
return JSON.stringify({
|
||||
ok: true,
|
||||
status: "planned",
|
||||
kind: "plugin",
|
||||
actions: [{ kind: "plugin", action: "install_plugin_package", name, source, status: "planned" }],
|
||||
});
|
||||
},
|
||||
async InstallPlugin(source: string, options: PluginInstallOptions) {
|
||||
const name = options.name || source.split("/").filter(Boolean).pop()?.replace(/\.git$/, "") || "plugin";
|
||||
const existing = capPlugins.findIndex((p) => p.name === name);
|
||||
const view: PluginView = {
|
||||
name,
|
||||
version: "dev",
|
||||
description: "Mock plugin",
|
||||
source,
|
||||
root: `~/.reasonix/plugins/${name}`,
|
||||
manifestKind: "reasonix",
|
||||
enabled: true,
|
||||
skills: 1,
|
||||
hooks: 0,
|
||||
mcpServers: 0,
|
||||
skillDetails: [{ name: "plan", description: "Plan work before implementation", invocation: "/plan", runAs: "inline" }],
|
||||
};
|
||||
if (existing >= 0) capPlugins[existing] = view;
|
||||
else capPlugins.push(view);
|
||||
return JSON.stringify({ ok: true, status: "done", kind: "plugin", actions: [{ kind: "plugin", name }] });
|
||||
},
|
||||
async RemovePlugin(name: string) {
|
||||
capPlugins = capPlugins.filter((p) => p.name !== name);
|
||||
},
|
||||
async SetPluginEnabled(name: string, enabled: boolean) {
|
||||
capPlugins = capPlugins.map((p) => p.name === name ? { ...p, enabled } : p);
|
||||
},
|
||||
async UpdatePlugin(name: string) {
|
||||
capPlugins = capPlugins.map((p) => p.name === name ? { ...p, version: p.version || "dev" } : p);
|
||||
return JSON.stringify({ ok: true, status: "done", kind: "plugin", name });
|
||||
},
|
||||
async PluginDoctor(name: string) {
|
||||
return capPlugins.find((p) => p.name === name) || {
|
||||
name,
|
||||
root: "",
|
||||
enabled: false,
|
||||
skills: 0,
|
||||
hooks: 0,
|
||||
mcpServers: 0,
|
||||
error: "plugin is not installed",
|
||||
};
|
||||
},
|
||||
async AddMCPServer(input: MCPServerInput) {
|
||||
const tools = input.transport === "stdio" ? 3 : 5;
|
||||
capServers.push({
|
||||
@@ -2262,6 +2526,9 @@ function makeMockApp(): AppBindings {
|
||||
async PickSkillFolder() {
|
||||
return "~/my-skills";
|
||||
},
|
||||
async PickPluginFolder() {
|
||||
return "~/plugins/superpowers";
|
||||
},
|
||||
async AddSkillPath(path: string) {
|
||||
const dir = path.trim() || "~/my-skills";
|
||||
if (!capSkillRoots.some((r) => r.scope === "custom" && r.dir === dir)) {
|
||||
@@ -2432,14 +2699,20 @@ function makeMockApp(): AppBindings {
|
||||
async RevealPath(path: string) {
|
||||
console.info("mock RevealPath", path);
|
||||
},
|
||||
async SavePastedImage(_dataUrl: string) {
|
||||
return ".reasonix/attachments/mock.png";
|
||||
async SavePastedImage(dataUrl: string) {
|
||||
const path = `.reasonix/attachments/mock-${mockAttachmentDataURLs.size + 1}.png`;
|
||||
mockAttachmentDataURLs.set(path, dataUrl);
|
||||
return path;
|
||||
},
|
||||
async SaveClipboardImage() {
|
||||
return ".reasonix/attachments/mock-clipboard.png";
|
||||
const path = `.reasonix/attachments/mock-clipboard-${mockAttachmentDataURLs.size + 1}.png`;
|
||||
mockAttachmentDataURLs.set(path, mockPreviewImageDataURL);
|
||||
return path;
|
||||
},
|
||||
async SavePastedFile(name: string, _dataUrl: string) {
|
||||
return `.reasonix/attachments/mock-${name}`;
|
||||
async SavePastedFile(name: string, dataUrl: string) {
|
||||
const path = `.reasonix/attachments/mock-${name}`;
|
||||
mockAttachmentDataURLs.set(path, dataUrl);
|
||||
return path;
|
||||
},
|
||||
async PickExportFile(defaultFilename: string, _mimeType: string) {
|
||||
return defaultFilename;
|
||||
@@ -2466,10 +2739,12 @@ function makeMockApp(): AppBindings {
|
||||
const tokenName = name.replace(/[^\w.-]+/g, "-") || "folder";
|
||||
return { kind: "workspace" as const, path: `__reasonix_external_folder/mock/${tokenName}`, isDir: true, displayPath: path };
|
||||
}
|
||||
return { kind: "attachment" as const, path: `.reasonix/attachments/mock-${name}` };
|
||||
const attachmentPath = `.reasonix/attachments/mock-${name}`;
|
||||
mockAttachmentDataURLs.set(attachmentPath, mockPreviewImageDataURL);
|
||||
return { kind: "attachment" as const, path: attachmentPath };
|
||||
},
|
||||
async AttachmentDataURL(_path: string) {
|
||||
return "data:image/png;base64,iVBORw0KGgo=";
|
||||
async AttachmentDataURL(path: string) {
|
||||
return mockAttachmentDataURLs.get(path) ?? mockPreviewImageDataURL;
|
||||
},
|
||||
async Models() {
|
||||
const active = mockTabs.find((tab) => tab.active) ?? mockTabs[0];
|
||||
@@ -2668,6 +2943,9 @@ function makeMockApp(): AppBindings {
|
||||
async SetSubagentEffort(level: string) {
|
||||
settings.subagentEffort = level;
|
||||
},
|
||||
async SetMaxSubagentDepth(depth: number) {
|
||||
settings.agent = { ...settings.agent, maxSubagentDepth: depth <= 1 ? 1 : 2 };
|
||||
},
|
||||
async SetAutoPlan(mode: string) {
|
||||
settings.autoPlan = mode;
|
||||
},
|
||||
@@ -2682,7 +2960,7 @@ function makeMockApp(): AppBindings {
|
||||
},
|
||||
async AddOfficialProviderAccess(kind: string, key: string) {
|
||||
const templates: Record<string, ProviderView> = {
|
||||
deepseek: { name: "deepseek", builtIn: true, added: true, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash", "deepseek-v4-pro"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: !!key.trim(), balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", supportedEfforts: [], defaultEffort: "" },
|
||||
deepseek: { name: "deepseek", builtIn: true, added: true, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash", "deepseek-v4-pro"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: !!key.trim(), balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", thinking: "", supportedEfforts: [], defaultEffort: "" },
|
||||
};
|
||||
const next = templates[kind];
|
||||
if (!next) throw new Error(`unknown official provider template ${kind}`);
|
||||
@@ -2691,12 +2969,47 @@ function makeMockApp(): AppBindings {
|
||||
else settings.providers.push(next);
|
||||
return "";
|
||||
},
|
||||
async AddProviderPresetAccess(id: string, key: string) {
|
||||
const preset = settings.providerPresets.find((p) => p.id === id);
|
||||
if (!preset) throw new Error(`unknown provider preset ${id}`);
|
||||
const next = cloneMockProviderTemplate(id, key);
|
||||
if (!next) throw new Error(`unknown provider preset ${id}`);
|
||||
const i = settings.providers.findIndex((x) => x.name === next.name);
|
||||
if (i >= 0) settings.providers[i] = { ...settings.providers[i], ...next, keySet: next.keySet || settings.providers[i].keySet };
|
||||
else settings.providers.push(next);
|
||||
preset.added = true;
|
||||
preset.status = "installed";
|
||||
preset.statusProviderNames = [...preset.providerNames];
|
||||
preset.keySet = preset.keySet || !!key.trim();
|
||||
preset.configured = !preset.requiresKey || preset.keySet;
|
||||
return "";
|
||||
},
|
||||
async ResetProviderPresetAccess(id: string) {
|
||||
const preset = settings.providerPresets.find((p) => p.id === id);
|
||||
if (!preset) throw new Error(`unknown provider preset ${id}`);
|
||||
const next = cloneMockProviderTemplate(id, "");
|
||||
if (!next) throw new Error(`unknown provider preset ${id}`);
|
||||
const i = settings.providers.findIndex((x) => x.name === next.name);
|
||||
if (i < 0) throw new Error(`provider preset ${id} cannot be reset because no same-name provider exists`);
|
||||
const existing = settings.providers[i];
|
||||
settings.providers[i] = {
|
||||
...next,
|
||||
added: true,
|
||||
keySet: existing.apiKeyEnv === next.apiKeyEnv ? existing.keySet : next.keySet,
|
||||
};
|
||||
preset.added = true;
|
||||
preset.status = "installed";
|
||||
preset.statusProviderNames = [...preset.providerNames];
|
||||
preset.keySet = preset.keySet || settings.providers[i].keySet;
|
||||
preset.configured = !preset.requiresKey || preset.keySet;
|
||||
},
|
||||
async FetchProviderModels(p: ProviderView) {
|
||||
if (!p.baseUrl.trim()) throw new Error(t("settings.fetchModelsMissingBaseUrl"));
|
||||
if (providerRequiresKey(p) && !p.apiKeyEnv.trim()) throw new Error(t("settings.fetchModelsMissingKeyEnv"));
|
||||
await delay(350);
|
||||
if (p.baseUrl.includes("deepseek")) return ["deepseek-v4-flash", "deepseek-v4-pro"];
|
||||
if (p.baseUrl.includes("mimo") || p.baseUrl.includes("xiaomimimo")) return ["mimo-v2.5", "mimo-v2.5-pro"];
|
||||
if (p.baseUrl.includes("token-plan")) return ["mimo-v2.5", "mimo-v2.5-pro"];
|
||||
if (p.baseUrl.includes("xiaomimimo")) return ["mimo-v2.5-pro", "mimo-v2.5"];
|
||||
return ["gpt-5", "gpt-5-mini", "qwen3-coder"];
|
||||
},
|
||||
async DeleteProvider(name: string) {
|
||||
@@ -2729,8 +3042,10 @@ function makeMockApp(): AppBindings {
|
||||
const k = list as "allow" | "ask" | "deny";
|
||||
settings.permissions[k] = settings.permissions[k].filter((r) => r !== rule);
|
||||
},
|
||||
async ReloadSettings() {},
|
||||
async SetSandbox(bash: string, network: boolean, workspaceRoot: string, allowWrite: string[], shell: string) {
|
||||
settings.sandbox = { bash, network, workspaceRoot, allowWrite, shell };
|
||||
const effectiveWorkspaceRoot = workspaceRoot.trim() || cwd;
|
||||
settings.sandbox = { bash, network, workspaceRoot, allowWrite, effectiveWorkspaceRoot, effectiveWriteRoots: [effectiveWorkspaceRoot, ...allowWrite], shell };
|
||||
},
|
||||
async SetNetwork(n: NetworkView) {
|
||||
settings.network = n;
|
||||
@@ -2804,10 +3119,11 @@ function makeMockApp(): AppBindings {
|
||||
label: domain === "lark" ? "Lark" : domain === "weixin" ? "微信" : "飞书",
|
||||
enabled: true,
|
||||
status: "connected",
|
||||
model: "",
|
||||
toolApprovalMode: "",
|
||||
workspaceRoot: "",
|
||||
credential: {
|
||||
model: "",
|
||||
toolApprovalMode: "",
|
||||
workspaceRoot: "",
|
||||
access: { enabled: true, allowAll: false, pairingEnabled: true, users: [provider === "weixin" ? "wxid_mock_user_001" : "ou_mock_user_001"], groups: [], approvers: [], admins: [] },
|
||||
credential: {
|
||||
appId: provider === "feishu" ? "cli_mock" : "",
|
||||
appSecretEnv: provider === "feishu" ? (domain === "lark" ? "LARK_BOT_APP_SECRET" : "FEISHU_BOT_APP_SECRET") : "",
|
||||
accountId: provider === "weixin" ? "mock-account" : "",
|
||||
@@ -2952,7 +3268,7 @@ function makeMockApp(): AppBindings {
|
||||
},
|
||||
async OpenDownloadPage() {
|
||||
if (typeof window !== "undefined") {
|
||||
window.open("https://github.com/esengine/reasonix/releases/latest", "_blank", "noopener");
|
||||
window.open("https://reasonix.io/#start", "_blank", "noopener");
|
||||
}
|
||||
},
|
||||
// Dev seam: drives the overlay flow in the browser until ConnectKey sets the
|
||||
|
||||
@@ -4,11 +4,19 @@ export interface Toast {
|
||||
id: number;
|
||||
text: string;
|
||||
level: "info" | "warn" | "error";
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
}
|
||||
|
||||
export interface ToastOptions {
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
export interface ToastContextValue {
|
||||
toasts: Toast[];
|
||||
showToast: (text: string, level?: Toast["level"]) => void;
|
||||
showToast: (text: string, level?: Toast["level"], options?: ToastOptions) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue>({ toasts: [], showToast: () => {} });
|
||||
@@ -23,13 +31,13 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const timers = useRef(new Map<number, ReturnType<typeof setTimeout>>());
|
||||
|
||||
const showToast = useCallback((text: string, level: Toast["level"] = "info") => {
|
||||
const showToast = useCallback((text: string, level: Toast["level"] = "info", options: ToastOptions = {}) => {
|
||||
const id = nextId++;
|
||||
setToasts((prev) => [...prev, { id, text, level }]);
|
||||
setToasts((prev) => [...prev, { id, text, level, actionLabel: options.actionLabel, onAction: options.onAction }]);
|
||||
const timer = setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
timers.current.delete(id);
|
||||
}, 2500);
|
||||
}, options.durationMs ?? (options.actionLabel ? 8000 : 2500));
|
||||
timers.current.set(id, timer);
|
||||
}, []);
|
||||
|
||||
@@ -49,6 +57,19 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
{t.level === "warn" && <span className="toast__icon">⚠️</span>}
|
||||
{t.level === "error" && <span className="toast__icon">❌</span>}
|
||||
<span className="toast__text">{t.text}</span>
|
||||
{t.actionLabel && t.onAction && (
|
||||
<button
|
||||
type="button"
|
||||
className="toast__action"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
dismissToast(t.id);
|
||||
t.onAction?.();
|
||||
}}
|
||||
>
|
||||
{t.actionLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import type { Todo } from "./tools";
|
||||
|
||||
export interface TodoPanelScopeInput {
|
||||
activeTabId?: string | null;
|
||||
activeTab?: {
|
||||
id?: string | null;
|
||||
scope?: string | null;
|
||||
workspaceRoot?: string | null;
|
||||
topicId?: string | null;
|
||||
sessionPath?: string | null;
|
||||
} | null;
|
||||
eventChannel?: string | null;
|
||||
}
|
||||
|
||||
export function todoDismissalKey(todos: Todo[]): string {
|
||||
if (todos.length === 0) return "";
|
||||
return JSON.stringify(todos.map((todo) => ({
|
||||
@@ -10,6 +22,50 @@ export function todoDismissalKey(todos: Todo[]): string {
|
||||
})));
|
||||
}
|
||||
|
||||
export function todoPanelScope({ activeTab, activeTabId, eventChannel }: TodoPanelScopeInput): string {
|
||||
const tabId = String(activeTabId ?? "").trim();
|
||||
const tab = !tabId || activeTab?.id === tabId ? activeTab : null;
|
||||
const sessionPath = tab?.sessionPath?.trim();
|
||||
if (sessionPath) return `session:${sessionPath}`;
|
||||
if (tabId) return `tab:${tabId}`;
|
||||
const topicId = tab?.topicId?.trim();
|
||||
if (tab && topicId) return `topic:${tab.scope ?? ""}:${tab.workspaceRoot ?? ""}:${topicId}`;
|
||||
const channel = String(eventChannel ?? "").trim();
|
||||
return channel ? `event:${channel}` : "";
|
||||
}
|
||||
|
||||
export function scopedTodoDismissalKey(scope: string | null | undefined, todoKey: string | null | undefined): string {
|
||||
const key = String(todoKey ?? "").trim();
|
||||
if (!key) return "";
|
||||
const prefix = String(scope ?? "").trim();
|
||||
return prefix ? `${prefix}\0${key}` : key;
|
||||
}
|
||||
|
||||
export function dismissedTodoKeyForScope(
|
||||
scope: string | null | undefined,
|
||||
dismissedKeys: ReadonlySet<string> | null | undefined,
|
||||
todoKey: string | null | undefined,
|
||||
): string | null {
|
||||
const scopedKey = scopedTodoDismissalKey(scope, todoKey);
|
||||
if (!scopedKey || !dismissedKeys?.has(scopedKey)) return null;
|
||||
return todoKey ?? null;
|
||||
}
|
||||
|
||||
export function todoBatchKey(todos: Todo[]): string {
|
||||
if (todos.length === 0) return "";
|
||||
return JSON.stringify(todos.map((todo) => ({
|
||||
content: String(todo.content ?? ""),
|
||||
level: typeof todo.level === "number" ? todo.level : 0,
|
||||
})));
|
||||
}
|
||||
|
||||
export function scopedTodoBatchKey(scope: string | null | undefined, batchKey: string | null | undefined): string {
|
||||
const key = String(batchKey ?? "").trim();
|
||||
if (!key) return "";
|
||||
const prefix = String(scope ?? "").trim();
|
||||
return prefix ? `${prefix}\0${key}` : key;
|
||||
}
|
||||
|
||||
export function shouldShowTodoPanel(
|
||||
todoKey: string | null | undefined,
|
||||
dismissedTodoKey: string | null,
|
||||
@@ -20,6 +76,10 @@ export function shouldShowTodoPanel(
|
||||
return todoKey !== dismissedTodoKey;
|
||||
}
|
||||
|
||||
export function shouldOpenTodoPanelByDefault(todos: Todo[]): boolean {
|
||||
return hasIncompleteTodos(todos);
|
||||
}
|
||||
|
||||
function todoStatus(status: unknown): string {
|
||||
const normalized = String(status ?? "").trim();
|
||||
return normalized || "pending";
|
||||
|
||||
@@ -208,6 +208,10 @@ export interface TabMeta {
|
||||
goal?: string;
|
||||
goalStatus?: GoalStatus;
|
||||
autoResearch?: AutoResearchCompactView;
|
||||
recovered?: boolean;
|
||||
recoveryReason?: string;
|
||||
recoveryDigest?: string;
|
||||
recoveryParentId?: string;
|
||||
startupErr?: string;
|
||||
active: boolean;
|
||||
cwd: string;
|
||||
@@ -228,6 +232,10 @@ export interface ProjectNode {
|
||||
running?: boolean;
|
||||
status?: ProjectTopicStatus;
|
||||
pinned?: boolean;
|
||||
recovered?: boolean;
|
||||
recoveryReason?: string;
|
||||
recoveryDigest?: string;
|
||||
recoveryParentId?: string;
|
||||
children?: ProjectNode[];
|
||||
}
|
||||
|
||||
@@ -239,6 +247,23 @@ export interface TopicMeta {
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface SessionRecoveryEvent {
|
||||
originalPath?: string;
|
||||
recoveryPath: string;
|
||||
scope?: string;
|
||||
workspaceRoot?: string;
|
||||
topicId?: string;
|
||||
topicTitle?: string;
|
||||
recoveryReason?: string;
|
||||
recoveryDigest?: string;
|
||||
recoveryParentId?: string;
|
||||
existing?: boolean;
|
||||
}
|
||||
|
||||
export interface SessionRecoveryFailedEvent {
|
||||
reason?: "lease_held" | "lease_unavailable" | string;
|
||||
}
|
||||
|
||||
export interface ContextPanelInfo {
|
||||
usedTokens: number;
|
||||
windowTokens: number;
|
||||
@@ -356,6 +381,8 @@ export interface CheckpointMeta {
|
||||
turn: number;
|
||||
prompt: string;
|
||||
files: string[];
|
||||
fileCount?: number;
|
||||
filesTruncated?: boolean;
|
||||
turnFileCount?: number;
|
||||
time: number; // unix ms
|
||||
canCode?: boolean;
|
||||
@@ -681,11 +708,55 @@ export interface CapabilitiesView {
|
||||
servers: ServerView[];
|
||||
skills: SkillView[];
|
||||
skillRoots: SkillRootView[];
|
||||
plugins: PluginView[];
|
||||
}
|
||||
export interface SkillsSettingsView {
|
||||
skills: SkillView[];
|
||||
skillRoots: SkillRootView[];
|
||||
}
|
||||
export interface PluginView {
|
||||
name: string;
|
||||
version?: string;
|
||||
description?: string;
|
||||
source?: string;
|
||||
root: string;
|
||||
manifestKind?: string;
|
||||
enabled: boolean;
|
||||
skills: number;
|
||||
hooks: number;
|
||||
mcpServers: number;
|
||||
skillDetails?: PluginSkillView[];
|
||||
hookDetails?: PluginHookView[];
|
||||
mcpServerDetails?: PluginMCPServerView[];
|
||||
warnings?: string[];
|
||||
error?: string;
|
||||
}
|
||||
export interface PluginSkillView {
|
||||
name: string;
|
||||
description?: string;
|
||||
path?: string;
|
||||
invocation?: string;
|
||||
runAs?: string;
|
||||
}
|
||||
export interface PluginHookView {
|
||||
event: string;
|
||||
match?: string;
|
||||
command?: string;
|
||||
contextFile?: string;
|
||||
description?: string;
|
||||
}
|
||||
export interface PluginMCPServerView {
|
||||
name: string;
|
||||
transport?: string;
|
||||
command?: string;
|
||||
url?: string;
|
||||
}
|
||||
export interface PluginInstallOptions {
|
||||
dryRun?: boolean;
|
||||
link?: boolean;
|
||||
replace?: boolean;
|
||||
name?: string;
|
||||
}
|
||||
export interface MCPServerInput {
|
||||
name: string;
|
||||
transport: string; // stdio | http | sse
|
||||
@@ -789,7 +860,7 @@ export interface MemoryView {
|
||||
}
|
||||
|
||||
// SettingsTab is the top-level navigation item in the Settings Centre modal.
|
||||
export type SettingsTab = "general" | "models" | "providers" | "bots" | "mcp" | "skills" | "memory" | "hooks" | "shortcuts" | "permissions" | "sandbox" | "network" | "appearance" | "updates";
|
||||
export type SettingsTab = "general" | "models" | "providers" | "bots" | "mcp" | "skills" | "plugins" | "memory" | "hooks" | "shortcuts" | "permissions" | "sandbox" | "network" | "appearance" | "updates";
|
||||
|
||||
// Settings panel payloads (desktop/settings_app.go).
|
||||
export interface ProviderView {
|
||||
@@ -805,6 +876,9 @@ export interface ProviderView {
|
||||
modelsUrl: string; // optional override for model discovery; empty derives from baseUrl
|
||||
default: string;
|
||||
apiKeyEnv: string;
|
||||
headers?: Record<string, string> | null; // optional extra request headers for compatible gateways
|
||||
extraBody?: Record<string, unknown> | null; // optional extra top-level request body fields for compatible gateways
|
||||
authHeader?: boolean; // Anthropic-compatible: send Authorization: Bearer instead of x-api-key
|
||||
keySet: boolean; // the env var currently resolves to a value
|
||||
requiresKey?: boolean; // false for explicit no-auth providers
|
||||
configured?: boolean; // selectable: key is set or no key is required
|
||||
@@ -813,8 +887,35 @@ export interface ProviderView {
|
||||
balanceUrl: string; // optional wallet-balance endpoint; "" disables the readout
|
||||
contextWindow: number;
|
||||
reasoningProtocol: string; // auto|deepseek|openai|none; empty = auto/model registry
|
||||
thinking: string; // provider-specific thinking override: ""|enabled|disabled|adaptive
|
||||
supportedEfforts: string[]; // custom /effort levels; empty = use built-in Kind/BaseURL default
|
||||
defaultEffort: string; // /effort level when user picks "auto" or unset; "" = supportedEfforts[0]
|
||||
modelOverrides?: ProviderModelOverrideView[] | null;
|
||||
}
|
||||
|
||||
export interface ProviderPresetView {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
keyEnv: string;
|
||||
providerNames: string[];
|
||||
models: string[];
|
||||
added: boolean;
|
||||
status?: "available" | "installed" | "installed_modified" | "name_conflict" | "similar_existing";
|
||||
statusProviderNames?: string[];
|
||||
keySet: boolean;
|
||||
requiresKey?: boolean;
|
||||
configured?: boolean;
|
||||
keySource?: string;
|
||||
keySourcePath?: string;
|
||||
}
|
||||
|
||||
export interface ProviderModelOverrideView {
|
||||
model: string;
|
||||
reasoningProtocol: string;
|
||||
supportedEfforts: string[];
|
||||
defaultEffort: string;
|
||||
vision?: boolean | null;
|
||||
}
|
||||
|
||||
// BalanceInfo is the wallet-balance readout (desktop/app.go Balance). available
|
||||
@@ -847,6 +948,8 @@ export interface SandboxView {
|
||||
network: boolean;
|
||||
workspaceRoot: string;
|
||||
allowWrite: string[];
|
||||
effectiveWorkspaceRoot: string;
|
||||
effectiveWriteRoots: string[];
|
||||
shell: string; // "auto" | "bash" | "powershell" | "pwsh"
|
||||
}
|
||||
|
||||
@@ -869,6 +972,7 @@ export interface AgentView {
|
||||
temperature: number;
|
||||
maxSteps: number;
|
||||
plannerMaxSteps: number;
|
||||
maxSubagentDepth: number;
|
||||
systemPrompt: string;
|
||||
coldResumePrune: boolean;
|
||||
reasoningLanguage: string; // "auto" | "zh" | "en"
|
||||
@@ -880,17 +984,67 @@ export interface BotAllowlistView {
|
||||
qqUsers: string[];
|
||||
feishuUsers: string[];
|
||||
weixinUsers: string[];
|
||||
qqApprovers: string[];
|
||||
feishuApprovers: string[];
|
||||
weixinApprovers: string[];
|
||||
qqAdmins: string[];
|
||||
feishuAdmins: string[];
|
||||
weixinAdmins: string[];
|
||||
qqGroups: string[];
|
||||
feishuGroups: string[];
|
||||
weixinGroups: string[];
|
||||
}
|
||||
|
||||
export interface BotAccessView {
|
||||
enabled: boolean;
|
||||
allowAll: boolean;
|
||||
pairingEnabled: boolean;
|
||||
users: string[];
|
||||
groups: string[];
|
||||
approvers: string[];
|
||||
admins: string[];
|
||||
}
|
||||
|
||||
export interface BotSelfUserIDsView {
|
||||
qq: string[];
|
||||
feishu: string[];
|
||||
weixin: string[];
|
||||
}
|
||||
|
||||
export interface BotPairingView {
|
||||
enabled: boolean;
|
||||
requestTtlMinutes: number;
|
||||
maxPendingPerPlatform: number;
|
||||
}
|
||||
|
||||
export interface BotControlView {
|
||||
enabled: boolean;
|
||||
addr: string;
|
||||
tokenEnv: string;
|
||||
}
|
||||
|
||||
export interface BotRouteView {
|
||||
connectionId: string;
|
||||
platform: string;
|
||||
chatType: string;
|
||||
chatId: string;
|
||||
userId: string;
|
||||
threadId: string;
|
||||
model: string;
|
||||
toolApprovalMode: ToolApprovalMode | "" | string;
|
||||
workspaceRoot: string;
|
||||
}
|
||||
|
||||
export interface QQBotView {
|
||||
enabled: boolean;
|
||||
appId: string;
|
||||
appSecretEnv: string;
|
||||
secretSet: boolean;
|
||||
sandbox: boolean;
|
||||
model: string;
|
||||
toolApprovalMode: ToolApprovalMode | "" | string;
|
||||
workspaceRoot: string;
|
||||
access: BotAccessView;
|
||||
}
|
||||
|
||||
export interface FeishuBotView {
|
||||
@@ -943,6 +1097,7 @@ export interface BotConnectionView {
|
||||
model: string;
|
||||
toolApprovalMode: ToolApprovalMode | "" | string;
|
||||
workspaceRoot: string;
|
||||
access: BotAccessView;
|
||||
credential: BotConnectionCredentialView;
|
||||
sessionMappings: BotConnectionSessionMappingView[];
|
||||
lastError: string;
|
||||
@@ -956,6 +1111,14 @@ export interface BotSettingsView {
|
||||
toolApprovalMode: ToolApprovalMode | "" | string;
|
||||
maxSteps: number;
|
||||
debounceMs: number;
|
||||
queueMode: string;
|
||||
queueCap: number;
|
||||
queueDrop: string;
|
||||
ignoreSelfMessages: boolean;
|
||||
selfUserIds: BotSelfUserIDsView;
|
||||
control: BotControlView;
|
||||
pairing: BotPairingView;
|
||||
routes: BotRouteView[];
|
||||
allowlist: BotAllowlistView;
|
||||
qq: QQBotView;
|
||||
feishu: FeishuBotView;
|
||||
@@ -1031,6 +1194,7 @@ export interface SettingsView {
|
||||
autoPlan: string;
|
||||
providers: ProviderView[];
|
||||
officialProviders: ProviderView[];
|
||||
providerPresets: ProviderPresetView[];
|
||||
permissions: PermissionsView;
|
||||
sandbox: SandboxView;
|
||||
network: NetworkView;
|
||||
|
||||
@@ -43,6 +43,9 @@ export type LiveStream = { id: string; text: string; reasoning: string; reasonin
|
||||
export type MessageActionScope = "fork" | "summ-from" | "summ-upto" | "conversation" | "code" | "both";
|
||||
export type MessageActionState = { turn: number; scope: MessageActionScope };
|
||||
export type HydrateReason = "switch-tab" | "new-session" | "resume-session" | "open-topic" | "startup";
|
||||
type SyncActiveTabOptions = {
|
||||
preserveCachedHistory?: boolean;
|
||||
};
|
||||
|
||||
const HISTORY_PAGE_TURNS = 60;
|
||||
|
||||
@@ -165,6 +168,7 @@ type RuntimeMetaSnapshot = {
|
||||
running: boolean;
|
||||
pendingPrompt?: boolean;
|
||||
backgroundJobs?: number;
|
||||
cancelRequested?: boolean;
|
||||
cancellable?: boolean;
|
||||
};
|
||||
|
||||
@@ -351,6 +355,18 @@ type Action =
|
||||
| { type: "clearAsk" }
|
||||
| { type: "reset" };
|
||||
|
||||
function backendStatusFromRuntimeMeta(meta: RuntimeMetaSnapshot): Extract<Action, { type: "backend_status" }> {
|
||||
const foregroundRunning = foregroundRunningFromRuntimeMeta(meta);
|
||||
return {
|
||||
type: "backend_status",
|
||||
running: foregroundRunning,
|
||||
pendingPrompt: Boolean(meta.pendingPrompt),
|
||||
backgroundJobs: meta.backgroundJobs ?? 0,
|
||||
cancelRequested: Boolean(meta.cancelRequested),
|
||||
cancellable: foregroundRunning,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- reducer helpers (unchanged logic) ----
|
||||
|
||||
export function historyMessagesToItems(messages: HistoryMessage[], idPrefix: string, startSeq = 0): { items: Item[]; seq: number } {
|
||||
@@ -1109,17 +1125,35 @@ export function useController() {
|
||||
return;
|
||||
}
|
||||
const ancillaryStartedAt = Date.now();
|
||||
const [effort, jobs, checkpoints, context] = await Promise.all([
|
||||
app.EffortForTab(tabId).catch((err) => { noteFailure("effort", err); return undefined; }),
|
||||
app.JobsForTab(tabId).catch((err) => { noteFailure("jobs", err); return undefined; }),
|
||||
app.CheckpointsForTab(tabId).catch((err) => { noteFailure("checkpoints", err); return undefined; }),
|
||||
app.ContextUsageForTab(tabId).catch((err) => { noteFailure("context", err); return undefined; }),
|
||||
const loadAncillary = async <T,>(label: string, load: () => Promise<T>): Promise<T | undefined> => {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const value = await load();
|
||||
addBreadcrumb("tab.hydrate", `ancillary ${label} ${reason} ${tabId} ms=${Date.now() - startedAt}`);
|
||||
return value;
|
||||
} catch (err) {
|
||||
noteFailure(label, err);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
const [effort, jobs, context] = await Promise.all([
|
||||
loadAncillary("effort", () => app.EffortForTab(tabId)),
|
||||
loadAncillary("jobs", () => app.JobsForTab(tabId)),
|
||||
loadAncillary("context", () => app.ContextUsageForTab(tabId)),
|
||||
]);
|
||||
if (!stillCurrent()) return;
|
||||
if (effort !== undefined) dispatchTo(tabId, { type: "effort", effort });
|
||||
if (jobs !== undefined) dispatchTo(tabId, { type: "jobs", jobs: asArray(jobs) });
|
||||
if (checkpoints !== undefined) dispatchTo(tabId, { type: "checkpoints", checkpoints: asArray(checkpoints) });
|
||||
if (context !== undefined) dispatchTo(tabId, { type: "context", context });
|
||||
await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
|
||||
if (!stillCurrent()) return;
|
||||
if (activeTabIdRef.current !== tabId && (reason === "startup" || reason === "switch-tab" || reason === "open-topic")) {
|
||||
addBreadcrumb("tab.hydrate", `checkpoints skipped inactive ${reason} ${tabId}`);
|
||||
return;
|
||||
}
|
||||
const checkpoints = await loadAncillary("checkpoints", () => app.CheckpointsForTab(tabId));
|
||||
if (!stillCurrent()) return;
|
||||
if (checkpoints !== undefined) dispatchTo(tabId, { type: "checkpoints", checkpoints: asArray(checkpoints) });
|
||||
addBreadcrumb("tab.hydrate", `ancillary ${reason} ${tabId} ms=${Date.now() - ancillaryStartedAt}`);
|
||||
app.BalanceForTab(tabId)
|
||||
.then((balance) => {
|
||||
@@ -1171,6 +1205,22 @@ export function useController() {
|
||||
return tabs.find((tab) => tab.active) ?? tabs[0];
|
||||
}, []);
|
||||
|
||||
const dispatchRuntimeStatusForTab = useCallback((tabId: string, tab: RuntimeMetaSnapshot) => {
|
||||
const foregroundRunning = foregroundRunningFromRuntimeMeta(tab);
|
||||
dispatchTo(tabId, {
|
||||
type: "backend_status",
|
||||
running: foregroundRunning,
|
||||
pendingPrompt: Boolean(tab.pendingPrompt),
|
||||
backgroundJobs: tab.backgroundJobs ?? 0,
|
||||
cancelRequested: Boolean(tab.cancelRequested),
|
||||
cancellable: foregroundRunning,
|
||||
});
|
||||
// backend_status reconciliation can clear a live prompt from frontend state.
|
||||
// If the backend is still blocked, ask it to replay the approval/ask event.
|
||||
if (tab.pendingPrompt) replayPendingPromptsForActiveTab(tabId);
|
||||
return foregroundRunning;
|
||||
}, [dispatchTo]);
|
||||
|
||||
const waitForTabReady = useCallback(async (tabId: string): Promise<void> => {
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
const tabs = asArray(await app.ListTabs().catch(() => [] as TabMeta[]));
|
||||
@@ -1180,7 +1230,7 @@ export function useController() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const syncActiveTabFromBackend = useCallback(async (reset = false, guard = false): Promise<string | undefined> => {
|
||||
const syncActiveTabFromBackend = useCallback(async (reset = false, guard = false, options: SyncActiveTabOptions = {}): Promise<string | undefined> => {
|
||||
const active = await activeTabFromBackend();
|
||||
if (!active) return undefined;
|
||||
// When guard is true, skip if the frontend already settled on a
|
||||
@@ -1194,9 +1244,15 @@ export function useController() {
|
||||
activeTabIdRef.current = active.id;
|
||||
confirmBackendActiveTab(active.id);
|
||||
dispatchTo(active.id, { type: "optimistic_meta", meta: metaFromTab(active, statesRef.current.get(active.id)?.meta) });
|
||||
await loadSessionDataForTab(active.id, reset, "startup");
|
||||
const preserveCachedHistory = options.preserveCachedHistory ?? !reset;
|
||||
if (!reset) dispatchRuntimeStatusForTab(active.id, active);
|
||||
await loadSessionDataForTab(active.id, reset, "startup", {
|
||||
preserveCachedHistory,
|
||||
sessionPath: active.sessionPath,
|
||||
});
|
||||
if (reset) dispatchRuntimeStatusForTab(active.id, active);
|
||||
return active.id;
|
||||
}, [activeTabFromBackend, confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
|
||||
}, [activeTabFromBackend, confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab]);
|
||||
|
||||
const reconcileTabRuntime = useCallback(async (
|
||||
tabId: string,
|
||||
@@ -1208,19 +1264,8 @@ export function useController() {
|
||||
if (!tab) return undefined;
|
||||
const local = statesRef.current.get(tabId);
|
||||
const needsInitialLoad = !local?.meta;
|
||||
const foregroundRunning = foregroundRunningFromRuntimeMeta(tab);
|
||||
const foregroundRunning = dispatchRuntimeStatusForTab(tabId, tab);
|
||||
const missedTurnDone = Boolean(local?.running && !foregroundRunning);
|
||||
dispatchTo(tabId, {
|
||||
type: "backend_status",
|
||||
running: foregroundRunning,
|
||||
pendingPrompt: Boolean(tab.pendingPrompt),
|
||||
backgroundJobs: tab.backgroundJobs ?? 0,
|
||||
cancelRequested: Boolean(tab.cancelRequested),
|
||||
cancellable: foregroundRunning,
|
||||
});
|
||||
// backend_status reconciliation can clear a live prompt from frontend state.
|
||||
// If the backend is still blocked, ask it to replay the approval/ask event.
|
||||
if (tab.pendingPrompt) replayPendingPromptsForActiveTab(tabId);
|
||||
if (hydrateSessionData && (needsInitialLoad || missedTurnDone)) {
|
||||
await loadSessionDataForTab(tabId, missedTurnDone, "startup");
|
||||
return tabs;
|
||||
@@ -1234,7 +1279,7 @@ export function useController() {
|
||||
if (effort) dispatchTo(tabId, { type: "effort", effort });
|
||||
if (balance) dispatchTo(tabId, { type: "balance", balance });
|
||||
return tabs;
|
||||
}, [dispatchTo, loadSessionDataForTab]);
|
||||
}, [dispatchRuntimeStatusForTab, loadSessionDataForTab]);
|
||||
|
||||
const clearCancelReconcileTimer = useCallback((tabId: string) => {
|
||||
const timer = cancelReconcileTimers.current.get(tabId);
|
||||
@@ -1375,9 +1420,12 @@ export function useController() {
|
||||
dispatchTo(tabId, { type: "user", text: displayText, submitText: display !== submit ? submit : undefined, seq });
|
||||
invalidateCache();
|
||||
try {
|
||||
await (original
|
||||
const submitPromise = original
|
||||
? app.SubmitEditedDisplayToTab(tabId, display, submit, original)
|
||||
: display !== submit ? app.SubmitDisplayToTab(tabId, display, submit) : app.SubmitToTab(tabId, submit));
|
||||
: display !== submit ? app.SubmitDisplayToTab(tabId, display, submit) : app.SubmitToTab(tabId, submit);
|
||||
void submitPromise.catch((error) => {
|
||||
dispatchTo(tabId, { type: "send_failed", error: `Send failed: ${error instanceof Error ? error.message : String(error)}` });
|
||||
});
|
||||
} catch (error) {
|
||||
dispatchTo(tabId, { type: "send_failed", error: `Send failed: ${error instanceof Error ? error.message : String(error)}` });
|
||||
throw error;
|
||||
@@ -1394,9 +1442,10 @@ export function useController() {
|
||||
setActiveTabId(active.id);
|
||||
activeTabIdRef.current = active.id;
|
||||
confirmBackendActiveTab(active.id);
|
||||
dispatchRuntimeStatusForTab(active.id, active);
|
||||
return sendToTab(active.id, displayText, submitText);
|
||||
});
|
||||
}, [activeTabFromBackend, activeTabId, confirmBackendActiveTab, sendToTab]);
|
||||
}, [activeTabFromBackend, activeTabId, confirmBackendActiveTab, dispatchRuntimeStatusForTab, sendToTab]);
|
||||
|
||||
const runShell = useCallback(async (command: string) => {
|
||||
if (!activeTabId) throw new Error("workspace is still starting");
|
||||
@@ -1703,11 +1752,13 @@ export function useController() {
|
||||
setActiveTabId(tab.id);
|
||||
activeTabIdRef.current = tab.id;
|
||||
confirmBackendActiveTab(tab.id);
|
||||
dispatchRuntimeStatusForTab(tab.id, tab);
|
||||
// The fork's controller builds in a background goroutine: an immediate
|
||||
// load reads empty history, and the ready-event fallback can still
|
||||
// target the source tab, leaving the fork blank (#3742).
|
||||
await waitForTabReady(tab.id);
|
||||
await loadSessionDataForTab(tab.id, true);
|
||||
await reconcileTabRuntime(tab.id, { hydrateSessionData: false });
|
||||
} else {
|
||||
await syncActiveTabFromBackend(true);
|
||||
}
|
||||
@@ -1730,11 +1781,12 @@ export function useController() {
|
||||
} finally {
|
||||
dispatchTo(sourceTabId, { type: "message_action_done" });
|
||||
}
|
||||
}, [activeTabId, confirmBackendActiveTab, dispatchTo, loadSessionDataForTab, syncActiveTabFromBackend, waitForBackendActiveTab, waitForTabReady]);
|
||||
}, [activeTabId, confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reconcileTabRuntime, syncActiveTabFromBackend, waitForBackendActiveTab, waitForTabReady]);
|
||||
|
||||
// Tab management: switch preserves per-tab state; open creates it.
|
||||
const switchTab = useCallback(async (tabId: string, optimisticTab?: TabMeta): Promise<TabMeta[] | undefined> => {
|
||||
const startedAt = Date.now();
|
||||
const previousTabId = activeTabIdRef.current;
|
||||
const targetSessionPath = optimisticTab?.sessionPath ?? statesRef.current.get(tabId)?.meta?.sessionPath;
|
||||
const preserveCachedHistory = hasReusableCachedTranscript(statesRef.current.get(tabId), targetSessionPath);
|
||||
addBreadcrumb("tab.switch", `click ${tabId}`);
|
||||
@@ -1743,11 +1795,25 @@ export function useController() {
|
||||
dispatchTo(tabId, { type: "backend_activation_start" });
|
||||
if (optimisticTab) {
|
||||
dispatchTo(tabId, { type: "optimistic_meta", meta: metaFromTab(optimisticTab, statesRef.current.get(tabId)?.meta) });
|
||||
const optimisticStatus = backendStatusFromRuntimeMeta(optimisticTab);
|
||||
if (optimisticStatus.running) dispatchTo(tabId, optimisticStatus);
|
||||
}
|
||||
dispatchTo(tabId, { type: "hydrate_start", reason: "switch-tab" });
|
||||
addBreadcrumb("tab.switch", `active-rendered ${tabId} ms=${Date.now() - startedAt}`);
|
||||
const backendActivation = app.SetActiveTab(tabId)
|
||||
.then(() => {
|
||||
.then(async () => {
|
||||
if (activeTabIdRef.current !== tabId) {
|
||||
const currentTabId = activeTabIdRef.current;
|
||||
if (currentTabId) {
|
||||
await app.SetActiveTab(currentTabId).then(() => {
|
||||
if (activeTabIdRef.current === currentTabId) confirmBackendActiveTab(currentTabId);
|
||||
}).catch((err) => {
|
||||
addBreadcrumb("tab.switch", `set-active-stale-reassert-failed ${currentTabId}: ${errorMessage(err)}`);
|
||||
});
|
||||
}
|
||||
addBreadcrumb("tab.switch", `set-active-stale ${tabId} current=${currentTabId ?? ""} ms=${Date.now() - startedAt}`);
|
||||
return false;
|
||||
}
|
||||
confirmBackendActiveTab(tabId);
|
||||
addBreadcrumb("tab.switch", `set-active-done ${tabId} ms=${Date.now() - startedAt}`);
|
||||
return true;
|
||||
@@ -1755,6 +1821,11 @@ export function useController() {
|
||||
.catch((err) => {
|
||||
dispatchTo(tabId, { type: "backend_activation_done" });
|
||||
dispatchTo(tabId, { type: "hydrate_error", reason: "switch-tab", error: errorMessage(err) });
|
||||
if (previousTabId && activeTabIdRef.current === tabId) {
|
||||
setActiveTabId(previousTabId);
|
||||
activeTabIdRef.current = previousTabId;
|
||||
addBreadcrumb("tab.switch", `set-active-failed-reverted ${tabId} -> ${previousTabId} ms=${Date.now() - startedAt}`);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
trackBackendActivation(tabId, backendActivation);
|
||||
@@ -1786,13 +1857,16 @@ export function useController() {
|
||||
activeTabIdRef.current = meta.id;
|
||||
confirmBackendActiveTab(meta.id);
|
||||
dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
|
||||
void loadSessionDataForTab(meta.id, isNewTab, "open-topic", {
|
||||
dispatchRuntimeStatusForTab(meta.id, meta);
|
||||
const load = loadSessionDataForTab(meta.id, isNewTab, "open-topic", {
|
||||
placeholderItems: isNewTab ? prevItems : undefined,
|
||||
preserveCachedHistory,
|
||||
sessionPath: meta.sessionPath,
|
||||
});
|
||||
if (isNewTab) void load.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false })).catch(() => {});
|
||||
else void load;
|
||||
return meta;
|
||||
}, [confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
|
||||
}, [confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reconcileTabRuntime]);
|
||||
|
||||
const openGlobalTab = useCallback(async (topicId: string): Promise<TabMeta> => {
|
||||
const meta = await app.OpenGlobalTab(topicId);
|
||||
@@ -1804,13 +1878,16 @@ export function useController() {
|
||||
activeTabIdRef.current = meta.id;
|
||||
confirmBackendActiveTab(meta.id);
|
||||
dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
|
||||
void loadSessionDataForTab(meta.id, isNewTab, "open-topic", {
|
||||
dispatchRuntimeStatusForTab(meta.id, meta);
|
||||
const load = loadSessionDataForTab(meta.id, isNewTab, "open-topic", {
|
||||
placeholderItems: isNewTab ? prevItems : undefined,
|
||||
preserveCachedHistory,
|
||||
sessionPath: meta.sessionPath,
|
||||
});
|
||||
if (isNewTab) void load.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false })).catch(() => {});
|
||||
else void load;
|
||||
return meta;
|
||||
}, [confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
|
||||
}, [confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reconcileTabRuntime]);
|
||||
|
||||
const openTopicSession = useCallback(async (scope: string, workspaceRoot: string, topicId: string, sessionPath: string): Promise<TabMeta> => {
|
||||
const meta = await app.OpenTopicSession(scope, workspaceRoot, topicId, sessionPath);
|
||||
@@ -1822,13 +1899,16 @@ export function useController() {
|
||||
activeTabIdRef.current = meta.id;
|
||||
confirmBackendActiveTab(meta.id);
|
||||
dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
|
||||
void loadSessionDataForTab(meta.id, isNewTab, "open-topic", {
|
||||
dispatchRuntimeStatusForTab(meta.id, meta);
|
||||
const load = loadSessionDataForTab(meta.id, isNewTab, "open-topic", {
|
||||
placeholderItems: isNewTab ? prevItems : undefined,
|
||||
preserveCachedHistory,
|
||||
sessionPath: meta.sessionPath,
|
||||
});
|
||||
if (isNewTab) void load.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false })).catch(() => {});
|
||||
else void load;
|
||||
return meta;
|
||||
}, [confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
|
||||
}, [confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reconcileTabRuntime]);
|
||||
|
||||
const activateTopic = useCallback(async (scope: string, workspaceRoot: string, topicId: string, sessionPath = ""): Promise<TabMeta> => {
|
||||
const meta = await app.ActivateTopic(scope, workspaceRoot, topicId, sessionPath);
|
||||
@@ -1842,21 +1922,28 @@ export function useController() {
|
||||
activeTabIdRef.current = meta.id;
|
||||
confirmBackendActiveTab(meta.id);
|
||||
dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
|
||||
void loadSessionDataForTab(meta.id, true, "open-topic", { placeholderItems: prevItems });
|
||||
dispatchRuntimeStatusForTab(meta.id, meta);
|
||||
void loadSessionDataForTab(meta.id, true, "open-topic", { placeholderItems: prevItems })
|
||||
.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false }))
|
||||
.catch(() => {});
|
||||
return meta;
|
||||
}, [confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
|
||||
}, [confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reconcileTabRuntime]);
|
||||
|
||||
// Ensure a blank tab exists for the given scope — reuses an existing one
|
||||
// or creates a new tab, then loads its session data.
|
||||
const ensureBlankTab = useCallback(async (scope: string, workspaceRoot: string): Promise<TabMeta> => {
|
||||
const meta = await app.EnsureBlankTab(scope, workspaceRoot);
|
||||
const isNewTab = !statesRef.current.has(meta.id);
|
||||
setActiveTabId(meta.id);
|
||||
activeTabIdRef.current = meta.id;
|
||||
confirmBackendActiveTab(meta.id);
|
||||
dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
|
||||
void loadSessionDataForTab(meta.id, !statesRef.current.has(meta.id), "open-topic");
|
||||
dispatchRuntimeStatusForTab(meta.id, meta);
|
||||
const load = loadSessionDataForTab(meta.id, isNewTab, "open-topic");
|
||||
if (isNewTab) void load.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false })).catch(() => {});
|
||||
else void load;
|
||||
return meta;
|
||||
}, [confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
|
||||
}, [confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reconcileTabRuntime]);
|
||||
|
||||
const ensureBlankSurface = useCallback(async (scope: string, workspaceRoot: string): Promise<TabMeta> => {
|
||||
const meta = await app.EnsureBlankSurface(scope, workspaceRoot);
|
||||
@@ -1867,16 +1954,19 @@ export function useController() {
|
||||
activeTabIdRef.current = meta.id;
|
||||
confirmBackendActiveTab(meta.id);
|
||||
dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
|
||||
void loadSessionDataForTab(meta.id, true, "open-topic");
|
||||
dispatchRuntimeStatusForTab(meta.id, meta);
|
||||
void loadSessionDataForTab(meta.id, true, "open-topic")
|
||||
.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false }))
|
||||
.catch(() => {});
|
||||
return meta;
|
||||
}, [confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
|
||||
}, [confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reconcileTabRuntime]);
|
||||
|
||||
const closeTab = useCallback(async (tabId: string) => {
|
||||
try {
|
||||
await app.CloseTab(tabId);
|
||||
statesRef.current.delete(tabId);
|
||||
bump();
|
||||
if (tabId === activeTabId) await syncActiveTabFromBackend(true);
|
||||
if (tabId === activeTabId) await syncActiveTabFromBackend(false);
|
||||
} catch { /* ignore */ }
|
||||
}, [activeTabId, bump, syncActiveTabFromBackend]);
|
||||
|
||||
|
||||
@@ -205,6 +205,7 @@ export const en = {
|
||||
"caps.mcpSummary": "{connected} connected · {failed} issues · {tools} tools",
|
||||
"caps.connectorsTab": "MCP servers",
|
||||
"caps.skillsTab": "Agent skills",
|
||||
"caps.pluginsTab": "Plugins",
|
||||
"caps.servers": "MCP servers",
|
||||
"caps.noServers": "No MCP servers configured. Add one with /mcp add.",
|
||||
"caps.availableServers": "Available MCPs",
|
||||
@@ -311,6 +312,62 @@ export const en = {
|
||||
"caps.trustedReadOnlyTitle": "Remembered as read-only for future planning and research",
|
||||
"caps.searchSkills": "Search skills…",
|
||||
"caps.noSkillMatches": "No matching skills.",
|
||||
"caps.pluginRefresh": "Refresh plugins",
|
||||
"caps.pluginInstallTitle": "Install plugin",
|
||||
"caps.pluginInstallHint": "Add a local plugin folder or install from a Git repository.",
|
||||
"caps.pluginInstallMethod": "Plugin install method",
|
||||
"caps.pluginInstallLocal": "Local folder",
|
||||
"caps.pluginInstallGit": "Git repository",
|
||||
"caps.pluginSource": "Plugin source",
|
||||
"caps.pluginGitSource": "Git repository URL",
|
||||
"caps.pluginSourcePlaceholder": "git:github.com/obra/superpowers",
|
||||
"caps.pluginChooseLocalFolder": "Choose plugin folder",
|
||||
"caps.pluginLocalFolder": "Local plugin folder",
|
||||
"caps.pluginNoLocalFolder": "No folder selected",
|
||||
"caps.pluginInstallName": "Install name (optional)",
|
||||
"caps.pluginInstallNamePlaceholder": "install name (optional)",
|
||||
"caps.pluginLink": "Developer mode: link source folder",
|
||||
"caps.pluginLinkHint": "Do not copy the plugin; use the selected folder directly. Useful for plugin debugging. Moving or deleting the folder breaks the plugin.",
|
||||
"caps.pluginReplace": "Overwrite same-name plugin",
|
||||
"caps.pluginReplaceHint": "When a plugin with the same name already exists, allow this source to replace it.",
|
||||
"caps.pluginPreview": "Preview",
|
||||
"caps.pluginInstall": "Install plugin",
|
||||
"caps.noPluginsTitle": "No plugins installed",
|
||||
"caps.noPluginsHint": "Install a plugin package from Git or a local path to add skills, hooks, and MCP servers through one managed unit.",
|
||||
"caps.installedPlugins": "Installed plugins",
|
||||
"caps.pluginsSummary": "{enabled} enabled · {total} installed · {issues} issues",
|
||||
"caps.pluginCounts": "{skills} skills · {hooks} hooks · {mcps} MCPs",
|
||||
"caps.pluginNoCapabilities": "No exported capabilities",
|
||||
"caps.pluginWarnings": "{count} warnings",
|
||||
"caps.pluginEnable": "Enable plugin",
|
||||
"caps.pluginDisable": "Disable plugin",
|
||||
"caps.pluginEnabled": "Enabled",
|
||||
"caps.pluginDisabled": "Disabled",
|
||||
"caps.pluginVersion": "Version",
|
||||
"caps.pluginRoot": "Install path",
|
||||
"caps.pluginUpdate": "Update",
|
||||
"caps.pluginDoctor": "Doctor",
|
||||
"caps.pluginRemove": "Remove plugin",
|
||||
"caps.pluginConfirmRemove": "Confirm remove",
|
||||
"caps.pluginPlanError": "Install check failed",
|
||||
"caps.pluginPlanReady": "Install plan",
|
||||
"caps.pluginAction": "Action",
|
||||
"caps.pluginPlanStatus": "Plugin operation: {status}",
|
||||
"caps.pluginPlanComplete": "Plugin operation complete",
|
||||
"caps.pluginPlanInstalled": "Plugin installed. Expand it below to see how to use the exported skills, hooks, and MCP servers.",
|
||||
"caps.pluginUsageTitle": "How to use",
|
||||
"caps.pluginUsageEnabledHint": "Enabled plugins load into new sessions. Use /skills to browse skills, invoke /<skill>, or ask naturally; hooks and MCP servers run through the normal session lifecycle.",
|
||||
"caps.pluginUsageDisabledHint": "Enable this plugin before its skills, hooks, or MCP servers can participate in sessions.",
|
||||
"caps.pluginNoCapabilityDetails": "No detailed capability inventory is available for this plugin.",
|
||||
"caps.pluginSkillsTitle": "Skills",
|
||||
"caps.pluginSkillsHint": "Browse with /skills, invoke directly, or ask for the workflow by name.",
|
||||
"caps.pluginHooksTitle": "Hooks",
|
||||
"caps.pluginHooksHint": "Hooks trigger automatically on matching lifecycle events.",
|
||||
"caps.pluginMCPTitle": "MCP servers",
|
||||
"caps.pluginMCPHint": "Tools from these servers are available through the normal MCP/tool flow.",
|
||||
"caps.pluginNoDescription": "No description",
|
||||
"caps.pluginHookNoTarget": "No hook target",
|
||||
"caps.pluginMCPNoTarget": "No launch target",
|
||||
"caps.sources": "Sources",
|
||||
"caps.sourcesSummaryActive": "{active} active",
|
||||
"caps.sourcesSummaryMissing": "{missing} missing",
|
||||
@@ -411,6 +468,17 @@ export const en = {
|
||||
"composer.removeCurrentProjectDisabled": "Current project cannot be removed",
|
||||
"composer.resize": "Drag to resize composer, double-click to reset",
|
||||
"composer.send": "Send (Enter)",
|
||||
"composer.queueGuidance": "Add to guidance queue (Enter)",
|
||||
"composer.steer": "Add guidance to current turn (Enter)",
|
||||
"composer.steerPlaceholder": "Add guidance to the queue...",
|
||||
"composer.guidanceQueue": "Queued guidance",
|
||||
"composer.guidanceCount": "Queued guidance {n}",
|
||||
"composer.guidanceRemaining": "{n} more queued",
|
||||
"composer.guidanceCollapse": "Collapse",
|
||||
"composer.guidanceMode": "Guide",
|
||||
"composer.guidanceSend": "Send this guidance to the transcript",
|
||||
"composer.guidanceDismiss": "Dismiss queued guidance",
|
||||
"composer.guidanceMore": "More guidance options",
|
||||
"composer.shellMode": "Shell mode: prefix input with !",
|
||||
"composer.shellModeOn": "Shell mode on: click to remove !",
|
||||
"composer.stop": "Stop (Esc)",
|
||||
@@ -421,6 +489,9 @@ export const en = {
|
||||
"composer.pastedExpand": "Expand",
|
||||
"composer.pastedRemove": "Remove pasted text",
|
||||
"composer.removeImage": "Remove image",
|
||||
"imageViewer.clickToPreview": "Click to preview",
|
||||
"imageViewer.closePreview": "Close preview",
|
||||
"imageViewer.title": "Image preview",
|
||||
"composer.attachImageFailed": "Image paste failed",
|
||||
"composer.attachFileFailed": "File attach failed",
|
||||
"composer.attachDropFailed": "Dropped file attach failed",
|
||||
@@ -545,6 +616,11 @@ export const en = {
|
||||
"heartbeat.approvalModeAskHint": "Each tool call needs manual confirmation",
|
||||
"heartbeat.approvalModeAutoHint": "Auto-approve safe tools, keep deny rules",
|
||||
"heartbeat.approvalModeYoloHint": "Skip all permission prompts, tasks run automatically",
|
||||
"heartbeat.notifyChannels": "Push to bot channels",
|
||||
"heartbeat.notifyChannelsOn": "On",
|
||||
"heartbeat.notifyChannelsOff": "Off",
|
||||
"heartbeat.notifyChannelsOnHint": "AI output is streamed to connected Feishu/QQ bots",
|
||||
"heartbeat.notifyChannelsOffHint": "Only create a desktop conversation; do not push to bots",
|
||||
"heartbeat.fieldNewConversation": "Conversation mode",
|
||||
"heartbeat.newConversationEachRunOn": "New conversation each run",
|
||||
"heartbeat.newConversationEachRunOff": "Reuse same conversation",
|
||||
@@ -589,6 +665,7 @@ export const en = {
|
||||
"ask.hideDetails": "Hide",
|
||||
"ask.back": "Back",
|
||||
"ask.next": "Next",
|
||||
"ask.customAnswer": "Other answer",
|
||||
"ask.customPlaceholder": "Type your own answer…",
|
||||
"ask.justChat": "Just chat",
|
||||
|
||||
@@ -654,6 +731,18 @@ export const en = {
|
||||
"history.historySessionActions": "History session actions",
|
||||
"history.trashActions": "Trash actions",
|
||||
|
||||
// session recovery
|
||||
"recovery.branch": "Recovery branch",
|
||||
"recovery.badge": "Recovery",
|
||||
"recovery.open": "Open recovery branch",
|
||||
"recovery.toast": "Unsaved local changes were saved as {title}.",
|
||||
"recovery.failedLease": "The recovery branch is in use by another runtime, so this tab did not switch to it.",
|
||||
"recovery.failedUnavailable": "The recovery branch could not be opened, so this tab stayed on the original session.",
|
||||
"recovery.banner": "This session was recovered from a save conflict. Compare it with the original before continuing.",
|
||||
"recovery.reason": "Reason: {reason}",
|
||||
"recovery.digest": "Digest: {digest}",
|
||||
"recovery.parent": "Parent: {parent}",
|
||||
|
||||
// project tree
|
||||
"projectTree.workspaceTitle": "Projects",
|
||||
"projectTree.pinnedTitle": "Pinned",
|
||||
@@ -827,6 +916,7 @@ export const en = {
|
||||
"settings.tab.capabilities": "MCP & Skills",
|
||||
"settings.tab.mcp": "MCP & Tools",
|
||||
"settings.tab.skills": "Skills",
|
||||
"settings.tab.plugins": "Plugins",
|
||||
"settings.tab.memory": "Memory",
|
||||
"settings.tab.hooks": "Hooks",
|
||||
"settings.tab.shortcuts": "Shortcuts",
|
||||
@@ -842,6 +932,7 @@ export const en = {
|
||||
"settings.tabSub.capabilities": "Servers, tools & skills",
|
||||
"settings.tabSub.mcp": "Servers & tools",
|
||||
"settings.tabSub.skills": "Sources & enablement",
|
||||
"settings.tabSub.plugins": "Packages & diagnostics",
|
||||
"settings.tabSub.memory": "Project & global",
|
||||
"settings.tabSub.hooks": "Shell automation",
|
||||
"settings.tabSub.shortcuts": "Keys & help",
|
||||
@@ -923,6 +1014,10 @@ export const en = {
|
||||
"settings.subagentEffort": "Subagent effort",
|
||||
"settings.subagentEffortDefault": "auto (provider default)",
|
||||
"settings.subagentHint": "Applies to task and runAs=subagent skills unless a tool call or skill-specific config overrides it.",
|
||||
"settings.subagentDepth": "Subagent nesting depth",
|
||||
"settings.subagentDepthHint": "Controls whether a first-layer subagent may dispatch one more reviewer or worker subagent.",
|
||||
"settings.subagentDepthOne": "1 layer",
|
||||
"settings.subagentDepthTwo": "2 layers",
|
||||
"settings.defaultToolApprovalMode": "New session approval",
|
||||
"settings.defaultToolApprovalModeHint": "Saved to your global settings file and used only for new sessions; switching or restoring an existing session will not overwrite its Ask / Auto / Yolo state.",
|
||||
"settings.defaultToolApprovalMode.ask": "Ask",
|
||||
@@ -945,7 +1040,7 @@ export const en = {
|
||||
"settings.coldResumePrune.on": "On",
|
||||
"settings.coldResumePrune.off": "Off",
|
||||
"settings.reasoningLanguage": "Thinking language",
|
||||
"settings.reasoningLanguageHint": "Affects visible reasoning only. Auto follows the conversation language without injecting a language instruction.",
|
||||
"settings.reasoningLanguageHint": "Affects visible reasoning only. Auto anchors clear Chinese prompts; referenced context is ignored.",
|
||||
"settings.reasoningLanguage.auto": "Auto",
|
||||
"settings.reasoningLanguage.zh": "中文",
|
||||
"settings.reasoningLanguage.en": "English",
|
||||
@@ -1006,7 +1101,7 @@ export const en = {
|
||||
"settings.providerModelsUrlPlaceholder": "e.g. https://api.openai.com/v1/models",
|
||||
"settings.providerModelsUrlHint": "Optional. Use when model discovery needs a different URL than the chat endpoint.",
|
||||
"settings.providerModels": "models (comma-separated)",
|
||||
"settings.providerApiKeyEnv": "api_key_env (e.g. DEEPSEEK_API_KEY)",
|
||||
"settings.providerApiKeyEnv": "api_key_env (optional)",
|
||||
"settings.providerBalanceUrl": "Balance URL (optional)",
|
||||
"settings.balanceUrlPlaceholder": "balance endpoint (e.g. https://api.deepseek.com/user/balance)",
|
||||
"settings.balanceUrlHint": "Optional. If set, the wallet balance is queried and shown in the status bar.",
|
||||
@@ -1016,12 +1111,22 @@ export const en = {
|
||||
"settings.providerReasoningProtocol": "reasoning protocol",
|
||||
"settings.providerReasoningProtocolAuto": "auto (model and endpoint detection)",
|
||||
"settings.providerReasoningProtocolHint": "For OpenAI-compatible reasoning models. deepseek uses DeepSeek reasoning fields, openai uses ordinary reasoning_effort, and none disables it.",
|
||||
"settings.effortBehavior": "Effort behavior",
|
||||
"settings.effortUseProtocolDefaults": "Protocol defaults",
|
||||
"settings.effortUseCustom": "Customize levels",
|
||||
"settings.effortProtocolDefault.auto": "Protocol defaults stay stored as empty supported_efforts. Reasonix will infer known DeepSeek models/endpoints as high|max, OpenAI reasoning as low|medium|high, and unsupported providers as no /effort.",
|
||||
"settings.effortProtocolDefault.deepseek": "DeepSeek defaults expose auto|high|max. Reasonix keeps thinking enabled and uses reasoning_effort for depth.",
|
||||
"settings.effortProtocolDefault.openai": "OpenAI reasoning defaults expose auto|low|medium|high and send ordinary reasoning_effort.",
|
||||
"settings.effortProtocolDefault.minimax": "MiniMax-style endpoints use provider-specific thinking levels such as adaptive|disabled. Add those as custom levels only when the endpoint supports them.",
|
||||
"settings.effortProtocolDefault.none": "Effort is disabled for this provider; Reasonix will not send reasoning_effort or thinking controls.",
|
||||
"settings.supportedEfforts": "Supported efforts",
|
||||
"settings.supportedEffortsCustomPlaceholder": "Custom level (press Enter)",
|
||||
"settings.supportedEffortsHint": "Toggle the /effort levels this provider supports, or add a custom name. Empty uses the built-in default (DeepSeek: high|max, Anthropic: low|medium|high|xhigh|max).",
|
||||
"settings.supportedEffortsNoPresets": "No standard presets",
|
||||
"settings.supportedEffortsNoPresetsHint": "Use custom levels only for endpoints that document their own effort names.",
|
||||
"settings.supportedEffortsHint": "Custom levels override protocol defaults. Pick only levels the endpoint documents; unsupported combinations can make the model request fail.",
|
||||
"settings.defaultEffort": "Default effort",
|
||||
"settings.defaultEffortAuto": "auto (use built-in default)",
|
||||
"settings.defaultEffortHint": "Used when /effort is \"auto\" or unset. Must be one of the levels above. Empty falls back to the first level (or the built-in default when none are selected).",
|
||||
"settings.defaultEffortAuto": "unset (use protocol/custom default)",
|
||||
"settings.defaultEffortHint": "Used when /effort is \"auto\" or unset. Leave unset to use the protocol default, or the first custom level when levels are customized.",
|
||||
"settings.proxyMode": "Proxy mode",
|
||||
"settings.proxyMode.auto": "auto",
|
||||
"settings.proxyMode.env": "env",
|
||||
@@ -1042,11 +1147,76 @@ export const en = {
|
||||
"settings.toggleOff": "Off",
|
||||
"settings.botSetup": "Bot entry",
|
||||
"settings.botSetupHint": "Enable the entry, then connect the chat channels you need.",
|
||||
"settings.botConnectPhoneTitle": "Add IM bot",
|
||||
"settings.botConnectPhoneSubtitle": "Manual setup for QQ, QR connection for Feishu, Lark, and WeChat.",
|
||||
"settings.botGateway": "Bot gateway",
|
||||
"settings.botGatewayHint": "Manage the IM bot gateway, model, and runtime parameters from the desktop app.",
|
||||
"settings.botGatewayHint": "Manage the IM bot gateway master switch and connection status from the desktop app.",
|
||||
"settings.botBrowserPreviewWarning": "This is a browser preview. It saves mock settings only and does not write local config or start the QQ/IM bot gateway. Configure it in the desktop app or CLI before testing from QQ.",
|
||||
"settings.botSimpleEyebrow": "Guided setup",
|
||||
"settings.botSimpleTitle": "Mobile access and chat bots",
|
||||
"settings.botSimpleHint": "Scan from a phone to control the current workspace. For longer-lived access from chat apps, pick a Bot Channel and configure it.",
|
||||
"settings.botMobileRemoteEyebrow": "Separate entry",
|
||||
"settings.botMobileRemoteTitle": "Mobile remote control",
|
||||
"settings.botMobileRemoteHint": "Scan or copy a link to open the current workspace control entry from a mobile device. This is configured separately from IM bot channels.",
|
||||
"settings.botMobileRemoteControlApi": "Control link",
|
||||
"settings.botMobileRemoteDisabled": "Enable it to generate the QR code and link",
|
||||
"settings.botMobileRemoteEnableFirst": "Enable to generate QR",
|
||||
"settings.botMobileRemoteReady": "Waiting for phone connection",
|
||||
"settings.botMobileRemoteWaiting": "Connect by phone scan",
|
||||
"settings.botMobileRemoteLoopbackNotice": "The control API is still bound to local loopback, so this is for local preview for now. Cross-device relay can reuse this entry later.",
|
||||
"settings.botMobileRemoteCopyLink": "Copy link",
|
||||
"settings.botMobileRemoteCopied": "Copied",
|
||||
"settings.botMobileRemoteCopyFailed": "Copy failed",
|
||||
"settings.botChannelEntryTitle": "Use Bot Channel",
|
||||
"settings.botChannelEntryHint": "Connect chat bots for longer mobile sessions.",
|
||||
"settings.botManageBots": "Bot management",
|
||||
"settings.botManageBotsHint": "Choose a channel on the left. If it is not configured, scan or add keys; once configured, manage that bot's runtime settings on the right.",
|
||||
"settings.botBackToOverview": "Back to entry",
|
||||
"settings.botChannelOpenConfigured": "Configured. You can continue editing settings.",
|
||||
"settings.botChannelNeedsSetup": "Not configured",
|
||||
"settings.botChannelTabsLabel": "Bot channels",
|
||||
"settings.botChannelSetupTitle": "Configure {provider}",
|
||||
"settings.botChannelSetupHint": "Before setup, only credentials or scan binding are needed. After it succeeds, this becomes the bot's settings page.",
|
||||
"settings.botSetupStatus": "Bot setup status",
|
||||
"settings.botSetupStatusConnected": "{online}/{total} online",
|
||||
"settings.botSetupStatusAccessTrusted": "Trusted list {count}",
|
||||
"settings.botSetupStatusAccessEveryone": "Everyone allowed",
|
||||
"settings.botSetupStatusAccessPerBot": "{configured}/{total} bots configured",
|
||||
"settings.botMode": "Bot settings mode",
|
||||
"settings.botModeSimple": "Simple",
|
||||
"settings.botModeAdvanced": "Advanced",
|
||||
"settings.botShowAdvancedSettings": "Advanced settings",
|
||||
"settings.botHideAdvancedSettings": "Hide advanced settings",
|
||||
"settings.botAdvancedSettingsHint": "Lower-frequency controls: routes, queue details, and bot echo filtering.",
|
||||
"settings.botShowAddBot": "Add channel",
|
||||
"settings.botStepConnectTitle": "Connect bots",
|
||||
"settings.botStepConnectHint": "Manual keys for QQ; scan a QR code for Feishu, Lark, and WeChat.",
|
||||
"settings.botStepConnectEmpty": "Not connected yet",
|
||||
"settings.botSharedAllowlistHint": "Every channel shares the trusted list and pairing settings from step 2. Edit once and it applies everywhere.",
|
||||
"settings.botSharedAllowlistGo": "Edit in step 2",
|
||||
"settings.botGlobalAllowlist": "Legacy global allowlist",
|
||||
"settings.botGlobalAllowlistHint": "Compatibility for older configs; new bots should set access in their own detail panel.",
|
||||
"settings.botAccessShowAllPlatforms": "Show all platforms",
|
||||
"settings.botAccessShowConnectedOnly": "Show connected platforms only",
|
||||
"settings.botListUsers": "Users",
|
||||
"settings.botListGroups": "Groups",
|
||||
"settings.botListApprovers": "Approvers",
|
||||
"settings.botListAdmins": "Admins",
|
||||
"settings.botPlatformFeishuLark": "Feishu / Lark",
|
||||
"settings.botQueueDropLabel": "When the queue is full",
|
||||
"settings.botQueueDropHint": "How overflow messages are handled once the queue hits its cap.",
|
||||
"settings.botPairingDetailHint": "The pairing switch lives in step 2 (Who can use it). Tune only the code lifetime and pending cap here.",
|
||||
"settings.botSimpleAccessTitle": "Who can use it",
|
||||
"settings.botSimpleAccessTrustedSummary": "Trusted list is active with {count} member or group entries.",
|
||||
"settings.botSimpleAccessEveryoneSummary": "Everyone can use it right now. Best for short tests only.",
|
||||
"settings.botAccessTrusted": "Trusted list",
|
||||
"settings.botAccessTrustedHint": "Only listed users or groups can call the bot.",
|
||||
"settings.botAccessEveryone": "Everyone",
|
||||
"settings.botAccessEveryoneHint": "Convenient for quick testing, but not recommended long term.",
|
||||
"settings.botAccessPairing": "Pair private chats",
|
||||
"settings.botAccessPairingHint": "Unknown direct messages get a code and wait for local approval.",
|
||||
"settings.botRoleAccess": "Admins and approvers",
|
||||
"settings.botRoleAccessHint": "Optional. Leave this empty unless remote approval or management is needed.",
|
||||
"settings.botQueueModeSimple": "When a new message arrives",
|
||||
"settings.botQueueModeSimpleHint": "Choose what happens when the bot is already handling a previous message.",
|
||||
"settings.saveBotSettings": "Save bot settings",
|
||||
"settings.botEnableBot": "Enable bot",
|
||||
"settings.botGatewayMasterSwitch": "Master switch",
|
||||
@@ -1057,8 +1227,48 @@ export const en = {
|
||||
"settings.botModelHint": "Leave empty to use the default model.",
|
||||
"settings.botModelDefault": "Use default model",
|
||||
"settings.botRuntime": "Runtime limits",
|
||||
"settings.botRuntimeHint": "Gateway-level limits that control how far each bot task can run.",
|
||||
"settings.botMaxSteps": "Max steps",
|
||||
"settings.botDebounceMs": "Debounce ms",
|
||||
"settings.botQueueCap": "Queue cap",
|
||||
"settings.botQueueMode.steer": "Steer running turn",
|
||||
"settings.botQueueMode.followup": "Follow up",
|
||||
"settings.botQueueMode.collect": "Collect",
|
||||
"settings.botQueueMode.interrupt": "Interrupt",
|
||||
"settings.botQueueDrop.summarize": "Summarize overflow",
|
||||
"settings.botQueueDrop.old": "Drop oldest",
|
||||
"settings.botQueueDrop.new": "Drop newest",
|
||||
"settings.botIgnoreSelfMessages": "Ignore bot echoes",
|
||||
"settings.botIgnoreSelfMessagesHint": "Prevents the gateway from reacting to messages sent by configured bot identities.",
|
||||
"settings.botSelfUserIds": "Bot self IDs",
|
||||
"settings.botSelfUserIdsHint": "Optional user/open IDs for each platform, used to filter bot echo messages.",
|
||||
"settings.botPairing": "Private chat pairing",
|
||||
"settings.botPairingTTL": "TTL minutes",
|
||||
"settings.botPairingMaxPending": "Max pending",
|
||||
"settings.botControlApi": "Local control API",
|
||||
"settings.botControlApiHint": "Loopback HTTP API for status and controlled outbound sends; requires a Bearer token from the named env var.",
|
||||
"settings.botControlAddr": "Control address",
|
||||
"settings.botControlTokenEnv": "Token env",
|
||||
"settings.botGatewayDefaults": "Gateway runtime settings",
|
||||
"settings.botGatewayDefaultsHint": "Tune queueing, pairing, and self-message filtering without editing TOML.",
|
||||
"settings.botRoutes": "Bind chats to projects",
|
||||
"settings.botRoutesHint": "Pin a group, contact, or thread to a workspace, model, or approval mode.",
|
||||
"settings.botRoutesEmpty": "No bindings yet. Add one to bind a chat, user, or thread to a project or model.",
|
||||
"settings.botRouteTitle": "Route {n}",
|
||||
"settings.botAddRoute": "Add route",
|
||||
"settings.botRouteConnection": "Bot source",
|
||||
"settings.botRouteAny": "Any",
|
||||
"settings.botRoutePlatform": "Platform",
|
||||
"settings.botRouteChatType": "Chat type",
|
||||
"settings.botRouteChatType.any": "Any",
|
||||
"settings.botRouteChatType.dm": "DM",
|
||||
"settings.botRouteChatType.group": "Group",
|
||||
"settings.botRouteChatType.guild": "Guild",
|
||||
"settings.botRouteChatType.direct": "Direct",
|
||||
"settings.botRouteChatType.thread": "Thread",
|
||||
"settings.botRouteChatId": "Group/chat ID",
|
||||
"settings.botRouteUserId": "Contact ID",
|
||||
"settings.botRouteThreadId": "Thread ID",
|
||||
"settings.botChannels": "Connect channels",
|
||||
"settings.botChannelsHint": "Pick a channel; QQ uses manual setup, while Feishu, Lark, and WeChat connect by QR.",
|
||||
"settings.botQQHint": "QQ official Bot API v2.",
|
||||
@@ -1113,8 +1323,7 @@ export const en = {
|
||||
"settings.botInstallLarkHint": "Scan to create a Lark PersonalAgent.",
|
||||
"settings.botInstallWeixinHint": "Scan WeChat and save a local token.",
|
||||
"settings.botConnectedBots": "Connected bots",
|
||||
"settings.botConnectedBotsSummary": "{online}/{total} online",
|
||||
"settings.botConnectionsEmpty": "No bot channel is connected yet.",
|
||||
"settings.botSelectBotHint": "Select a bot on the left to configure it.",
|
||||
"settings.botConnectionColumnChannel": "Channel",
|
||||
"settings.botConnectionColumnName": "Bot",
|
||||
"settings.botConnectionColumnRemote": "Remote ID",
|
||||
@@ -1132,16 +1341,17 @@ export const en = {
|
||||
"settings.botConnectionConnected": "Connected",
|
||||
"settings.botConnectionConfigured": "Configured",
|
||||
"settings.botConnectionDisconnected": "Disconnected",
|
||||
"settings.botManage": "Advanced settings",
|
||||
"settings.botManage": "Edit config",
|
||||
"settings.deleteBot": "Delete bot",
|
||||
"settings.deleteBotShort": "Delete",
|
||||
"settings.confirmDeleteBot": "Confirm delete",
|
||||
"settings.deleteBotHint": "Removes this bot from connected bots; saved secrets are not cleared automatically.",
|
||||
"settings.botChannelModel": "Model",
|
||||
"settings.botChannelModelHint": "Leave empty to use the bot default model.",
|
||||
"settings.botChannelModelAuto": "Auto",
|
||||
"settings.botChannelModelHint": "The model this bot uses when handling messages.",
|
||||
"settings.botChannelModelAuto": "Use current desktop model",
|
||||
"settings.botToolApprovalMode": "Tool approval",
|
||||
"settings.botToolApprovalModeHint": "Controls whether this channel asks before running tools; plan confirmation and ask questions still wait for a reply.",
|
||||
"settings.botToolApprovalMode.inherit": "Inherit",
|
||||
"settings.botToolApprovalMode.inherit": "Follow bot",
|
||||
"settings.botToolApprovalMode.ask": "Ask",
|
||||
"settings.botToolApprovalMode.auto": "Auto",
|
||||
"settings.botToolApprovalMode.yolo": "YOLO",
|
||||
@@ -1164,26 +1374,17 @@ export const en = {
|
||||
"settings.botDiagnosticReportFailed": "Failed to send diagnostic report.",
|
||||
"settings.botDiagnosticPrivacy": "Only sanitized connection metadata is included.",
|
||||
"settings.botAccessControl": "Access control",
|
||||
"settings.botAccessControlHint": "Whitelist is the default; switch to everyone for quick testing.",
|
||||
"settings.botAccessMode": "Who can use it",
|
||||
"settings.botAccessAll": "Everyone",
|
||||
"settings.botAccessWhitelist": "Whitelist only",
|
||||
"settings.botEditAllowlist": "Edit allowlist",
|
||||
"settings.botAllowlist": "Allowlist",
|
||||
"settings.botAllowlistHint": "Restrict which users or groups can invoke the bot.",
|
||||
"settings.botAllowlistEnabled": "Enable allowlist",
|
||||
"settings.botAllowAll": "Allow everyone",
|
||||
"settings.botAllowAllWarn": "When enabled, every incoming channel message can invoke the bot.",
|
||||
"settings.botQQAccessRequired": "Add a QQ user/group allowlist, or temporarily allow everyone in Advanced settings, before enabling QQ Bot.",
|
||||
"settings.botAllowlistEntries": "Users and groups",
|
||||
"settings.botQQAccessRequired": "Add QQ users/groups in step 2 (Who can use it), or temporarily allow everyone, before enabling QQ Bot.",
|
||||
"settings.botQQUsers": "QQ users",
|
||||
"settings.botQQGroups": "QQ groups",
|
||||
"settings.botFeishuUsers": "Feishu users",
|
||||
"settings.botFeishuGroups": "Feishu groups",
|
||||
"settings.botFeishuLarkUsers": "Feishu / Lark users",
|
||||
"settings.botFeishuLarkGroups": "Feishu / Lark groups",
|
||||
"settings.botWeixinUsers": "WeChat users",
|
||||
"settings.botWeixinGroups": "WeChat groups",
|
||||
"settings.botListPlaceholder": "One ID per line, or comma-separated",
|
||||
"settings.permissions": "Permissions",
|
||||
"settings.writerMode": "Writer mode",
|
||||
@@ -1195,8 +1396,11 @@ export const en = {
|
||||
"settings.yoloHint": "YOLO-approves approval-gated tool calls (writers, bash). Deny rules still block, and ask questions plus plan approvals still wait for you. Runtime-only — resets on relaunch.",
|
||||
"settings.addRule": "add {list} rule…",
|
||||
"settings.sandboxTitle": "Sandbox & workspace",
|
||||
"settings.sandboxBoundaryHint": "Sandbox write roots limit where approved Bash and file tools can write; they do not auto-approve commands. Use Permissions or approval mode for deploy-style commands.",
|
||||
"settings.reloadSessionConfig": "Apply manual config changes",
|
||||
"settings.reloadSessionConfigHint": "Changes made in Settings apply automatically; use this only after manually editing config.toml or reasonix.toml.",
|
||||
"settings.shellInterpreter": "Shell interpreter",
|
||||
"settings.shellAuto": "auto (bash, else PowerShell on Windows)",
|
||||
"settings.shellAuto": "auto (bash first)",
|
||||
"settings.shellBash": "bash",
|
||||
"settings.shellPowershell": "PowerShell (Windows PowerShell)",
|
||||
"settings.shellPwsh": "pwsh (PowerShell 7+)",
|
||||
@@ -1206,6 +1410,9 @@ export const en = {
|
||||
"settings.allowNetwork": "Allow network egress from sandboxed bash",
|
||||
"settings.workspaceRoot": "Workspace root",
|
||||
"settings.workspaceDefault": "(default: cwd)",
|
||||
"settings.effectiveWriteRoots": "Effective write roots",
|
||||
"settings.effectiveWriteRootsHint": "Expanded for the active workspace. Settings changes apply automatically; use the button above after manually editing config files.",
|
||||
"settings.noEffectiveWriteRoots": "No active write roots",
|
||||
"settings.appearance": "Appearance",
|
||||
"settings.theme": "Theme",
|
||||
"settings.themeStyle": "Visual style",
|
||||
@@ -1313,6 +1520,7 @@ export const en = {
|
||||
"settings.pageDesc.bots": "Configure QQ, Feishu, Lark, and WeChat bot channels, including each bot's model and runtime parameters.",
|
||||
"settings.pageDesc.mcp": "Manage MCP server connections and tool discovery.",
|
||||
"settings.pageDesc.skills": "Browse, enable, and manage agent skills and skill sources.",
|
||||
"settings.pageDesc.plugins": "Install and manage plugin packages that can provide skills, hooks, and MCP servers.",
|
||||
"settings.pageDesc.memory": "Manage preferences, project conventions, and instruction files Reasonix keeps referencing.",
|
||||
"settings.pageDesc.hooks": "Configure shell hooks around turns, tool calls, compaction, and session lifecycle.",
|
||||
"settings.pageDesc.shortcuts": "Customize keyboard shortcuts and keep the help sheet in sync.",
|
||||
@@ -1327,7 +1535,7 @@ export const en = {
|
||||
"settings.customProviders": "Custom providers",
|
||||
"settings.customProvidersHint": "OpenAI-compatible or proxy providers you add yourself. These can be edited, deleted, and updated with fetched model lists.",
|
||||
"settings.customProvidersEmpty": "No custom providers yet. Prefer the built-in access above for common services.",
|
||||
"settings.providerAccessEmptyHint": "Click “Add provider” to choose DeepSeek or a custom OpenAI-compatible access.",
|
||||
"settings.providerAccessEmptyHint": "Use Recommended preset to quickly add common providers, usually with only an API key. Use Custom provider for OpenAI-compatible or Anthropic-compatible endpoints.",
|
||||
"settings.builtinProviderBadge": "built-in",
|
||||
"settings.customProviderBadge": "custom",
|
||||
"settings.configureProvider": "Configure",
|
||||
@@ -1339,24 +1547,75 @@ export const en = {
|
||||
"settings.searchModels": "Search models...",
|
||||
"settings.noMatchingModels": "No matching models",
|
||||
"settings.addProvider.chooseTitle": "Add provider",
|
||||
"settings.addProvider.chooseHint": "Choose an official preset, or add a custom OpenAI-compatible access.",
|
||||
"settings.addProvider.officialChoice": "Official provider",
|
||||
"settings.addProvider.officialChoiceHint": "Choose from Reasonix presets for DeepSeek.",
|
||||
"settings.addProvider.chooseHint": "Choose a recommended preset, or add a custom provider manually.",
|
||||
"settings.addProvider.officialChoice": "Recommended preset",
|
||||
"settings.addProvider.officialChoiceHint": "Choose DeepSeek or curated custom-provider presets.",
|
||||
"settings.addProvider.customChoice": "Custom provider",
|
||||
"settings.addProvider.customChoiceHint": "Configure base_url, models, key env, and related parameters manually.",
|
||||
"settings.addProvider.officialTitle": "Add official provider",
|
||||
"settings.addProvider.officialHint": "Official presets use fixed endpoints and defaults; choose the provider and set a key.",
|
||||
"settings.addProvider.officialHint": "Recommended presets fill endpoints, models, vision, and reasoning defaults; usually only the key is needed.",
|
||||
"settings.addProvider.customTitle": "Add custom provider",
|
||||
"settings.addProvider.customHint": "Use this for proxies, aggregators, or other OpenAI-compatible services.",
|
||||
"settings.addProvider.official.deepseek": "DeepSeek Official",
|
||||
"settings.addProvider.official.deepseekDesc": "Official API key access with DeepSeek V4 Flash / Pro.",
|
||||
"settings.addProvider.preset.kimiCnDesc": "Kimi China API via Moonshot CN endpoint with K2 vision models.",
|
||||
"settings.addProvider.preset.kimiGlobalDesc": "Kimi international API via Moonshot global endpoint with K2 vision models.",
|
||||
"settings.addProvider.preset.kimiCodingPlanDesc": "Kimi Coding Plan via the dedicated Anthropic-compatible endpoint; not a CN/Global variant.",
|
||||
"settings.addProvider.preset.mimoApiDesc": "Xiaomi MiMo direct API for v2.5 pay-as-you-go models.",
|
||||
"settings.addProvider.preset.mimoAnthropicDesc": "Xiaomi MiMo direct Anthropic-compatible endpoint with MiMo v2.5 models.",
|
||||
"settings.addProvider.preset.mimoTokenPlanCnDesc": "Xiaomi MiMo token-plan China endpoint.",
|
||||
"settings.addProvider.preset.mimoTokenPlanCnAnthropicDesc": "Xiaomi MiMo token-plan China Anthropic-compatible endpoint.",
|
||||
"settings.addProvider.preset.mimoTokenPlanSgpDesc": "Xiaomi MiMo token-plan Singapore endpoint.",
|
||||
"settings.addProvider.preset.mimoTokenPlanSgpAnthropicDesc": "Xiaomi MiMo token-plan Singapore Anthropic-compatible endpoint.",
|
||||
"settings.addProvider.preset.mimoTokenPlanAmsDesc": "Xiaomi MiMo token-plan Amsterdam endpoint.",
|
||||
"settings.addProvider.preset.mimoTokenPlanAmsAnthropicDesc": "Xiaomi MiMo token-plan Amsterdam Anthropic-compatible endpoint.",
|
||||
"settings.addProvider.preset.minimaxCnApiDesc": "MiniMax China OpenAI-compatible M-series API endpoint.",
|
||||
"settings.addProvider.preset.minimaxGlobalApiDesc": "MiniMax international OpenAI-compatible M-series API endpoint.",
|
||||
"settings.addProvider.preset.minimaxCnAnthropicDesc": "MiniMax China Anthropic-compatible M-series endpoint.",
|
||||
"settings.addProvider.preset.minimaxGlobalAnthropicDesc": "MiniMax international Anthropic-compatible endpoint using Bearer auth.",
|
||||
"settings.addProvider.preset.glmCnDesc": "Zhipu GLM China API with GLM thinking controls.",
|
||||
"settings.addProvider.preset.zaiGlobalDesc": "Z.AI international GLM API.",
|
||||
"settings.addProvider.preset.glmCodingPlanCnDesc": "Zhipu GLM China coding-plan endpoint with GLM-5.2 default.",
|
||||
"settings.addProvider.preset.glmCodingPlanCnAnthropicDesc": "Zhipu GLM China Anthropic-compatible coding-plan endpoint with GLM-5.2[1m] default.",
|
||||
"settings.addProvider.preset.zaiCodingPlanGlobalDesc": "Z.AI international coding-plan endpoint with GLM-5.2 default.",
|
||||
"settings.addProvider.preset.zaiCodingPlanGlobalAnthropicDesc": "Z.AI international Anthropic-compatible coding-plan endpoint with GLM-5.2[1m] default.",
|
||||
"settings.addProvider.preset.opencodeGoDesc": "OpenCode Go relay with mixed-model capability overrides.",
|
||||
"settings.addProvider.preset.opencodeGoAnthropicDesc": "OpenCode Go Anthropic route for Qwen and MiniMax models.",
|
||||
"settings.addProvider.preset.opencodeZenAnthropicDesc": "OpenCode Zen Anthropic route for Claude and Qwen models.",
|
||||
"settings.addProvider.preset.qwenCnDesc": "Qwen/DashScope China standard API endpoint.",
|
||||
"settings.addProvider.preset.qwenGlobalDesc": "Qwen/DashScope international standard API endpoint.",
|
||||
"settings.addProvider.preset.qwenCodingPlanCnDesc": "Qwen Coding Plan China OpenAI-compatible endpoint with qwen3.7-plus default.",
|
||||
"settings.addProvider.preset.qwenCodingPlanCnAnthropicDesc": "Qwen Coding Plan China Anthropic-compatible endpoint with qwen3.7-plus default.",
|
||||
"settings.addProvider.preset.qwenCodingPlanGlobalDesc": "Qwen Coding Plan international OpenAI-compatible endpoint with qwen3.7-plus default.",
|
||||
"settings.addProvider.preset.qwenCodingPlanGlobalAnthropicDesc": "Qwen Coding Plan international Anthropic-compatible endpoint with qwen3.7-plus default.",
|
||||
"settings.addProvider.preset.stepfunDesc": "StepFun coding-plan OpenAI-compatible endpoint with step-3.7-flash default.",
|
||||
"settings.addProvider.preset.stepfunAnthropicDesc": "StepFun coding-plan Anthropic-compatible endpoint with step-3.7-flash default.",
|
||||
"settings.addProvider.preset.novitaDesc": "NovitaAI multi-model gateway with Kimi, MiniMax, GLM, DeepSeek, and Qwen.",
|
||||
"settings.addProvider.preset.gmiDesc": "GMI Cloud direct multi-model gateway with GLM, DeepSeek, Kimi, Gemini, Claude, and GPT.",
|
||||
"settings.addProvider.preset.vercelAiGatewayDesc": "Vercel AI Gateway through Anthropic-compatible Messages API.",
|
||||
"settings.addProvider.preset.huggingfaceDesc": "HuggingFace Inference Router with Qwen and DeepSeek defaults.",
|
||||
"settings.addProvider.preset.nvidiaDesc": "NVIDIA NIM accelerated inference endpoint with Nemotron defaults.",
|
||||
"settings.addProvider.preset.kilocodeDesc": "Kilo Code gateway with kilo/auto routing defaults.",
|
||||
"settings.addProvider.preset.ollamaCloudDesc": "Hosted Ollama Cloud endpoint with none-to-max reasoning effort.",
|
||||
"settings.addProvider.addedBadge": "Added",
|
||||
"settings.addProvider.modifiedBadge": "Modified",
|
||||
"settings.addProvider.nameConflictBadge": "Name taken",
|
||||
"settings.addProvider.similarExistingBadge": "Similar setup",
|
||||
"settings.addProvider.confirm": "Add provider",
|
||||
"settings.addProvider.alreadyAddedAction": "Already added",
|
||||
"settings.addProvider.nameConflictAction": "Resolve name first",
|
||||
"settings.addProvider.viewPresetProvider": "View config",
|
||||
"settings.addProvider.viewConflictProvider": "View same-name config",
|
||||
"settings.addProvider.resetPreset": "Reset to preset",
|
||||
"settings.addProvider.confirmResetPreset": "Confirm reset",
|
||||
"settings.customProviderName": "Custom provider name",
|
||||
"settings.customProviderNamePlaceholder": "",
|
||||
"settings.providerNamePlaceholder": "Provider name (e.g. deepseek-proxy)",
|
||||
"settings.providerProtocol": "Connection protocol",
|
||||
"settings.providerProtocolOpenAI": "OpenAI-compatible",
|
||||
"settings.providerProtocolOpenAIHint": "For proxies, aggregators, and self-hosted services compatible with the OpenAI API.",
|
||||
"settings.providerProtocolAnthropic": "Anthropic-compatible",
|
||||
"settings.providerProtocolAnthropicHint": "For Claude-style Messages API services such as some coding-plan endpoints.",
|
||||
"settings.providerBaseUrlLabel": "API address",
|
||||
"settings.providerChatUrlLabel": "Chat request URL",
|
||||
"settings.providerChatUrlPlaceholder": "e.g. https://api.openai.com/v1/chat/completions",
|
||||
@@ -1370,12 +1629,29 @@ export const en = {
|
||||
"settings.testFetchModels": "Test and fetch models",
|
||||
"settings.testFetchModelsHint": "Uses the API address and key above to verify the connection and fill the model list.",
|
||||
"settings.manualModels": "Model list",
|
||||
"settings.manualModelsHint": "If the endpoint cannot list models, enter model names manually, separated by commas.",
|
||||
"settings.manualModelsHint": "If the endpoint cannot list models, enter model names manually; mark image-capable models below.",
|
||||
"settings.visionModels": "Image-capable models",
|
||||
"settings.visionModelsHint": "Subset of enabled models that accept image input, separated by commas.",
|
||||
"settings.providerAdvancedSettings": "Advanced settings",
|
||||
"settings.providerAdvancedSettings": "Compatibility settings (usually leave unchanged)",
|
||||
"settings.providerAdvancedExpandHint": "Click to open details",
|
||||
"settings.providerAdvancedCollapseHint": "Click to collapse",
|
||||
"settings.providerHeaders": "Extra request headers (optional)",
|
||||
"settings.providerHeadersPlaceholder": "HTTP-Referer: https://example.com\nX-Title: Reasonix",
|
||||
"settings.providerHeadersHint": "For gateways such as OpenRouter that require site headers. Use one Header: value per line; the API key still belongs in the key field above.",
|
||||
"settings.providerExtraBody": "Extra request body (optional)",
|
||||
"settings.providerExtraBodyPlaceholder": "{\n \"enable_thinking\": true\n}",
|
||||
"settings.providerExtraBodyHint": "Top-level JSON fields merged into the chat request body. Core fields such as model, messages, tools, stream, and thinking stay controlled by Reasonix.",
|
||||
"settings.providerExtraBodyError": "Enter a JSON object without null values, for example { \"enable_thinking\": true }.",
|
||||
"settings.providerAuthHeader": "Use Authorization: Bearer for this key",
|
||||
"settings.providerAuthHeaderHint": "For Anthropic-compatible gateways such as MiniMax Global or Vercel AI Gateway that do not use x-api-key.",
|
||||
"settings.fetchingModels": "Refreshing…",
|
||||
"settings.fetchModelsSuccess": "Fetched {n} models",
|
||||
"settings.fetchModelsManualFallbackAuth": "The model discovery endpoint rejected the key or permission. Check the API key; if this service does not expose model discovery, enter model IDs manually below.",
|
||||
"settings.fetchModelsManualFallbackUnsupported": "This service may not support automatic model discovery. Enter model IDs manually below.",
|
||||
"settings.fetchModelsManualFallbackNetwork": "Could not reach the model discovery endpoint. Check the API address or proxy; if you know the model IDs, enter them manually below.",
|
||||
"settings.fetchModelsManualFallbackDecode": "The model discovery endpoint returned an unknown format. Enter model IDs manually below.",
|
||||
"settings.fetchModelsManualFallbackEmpty": "No models were discovered automatically. Enter model IDs manually below.",
|
||||
"settings.fetchModelsManualFallbackGeneric": "Could not fetch models automatically: {err}. Enter model IDs manually below.",
|
||||
"settings.fetchModelsUpdatedForProvider": "Updated available models for {provider}, {n} total",
|
||||
"settings.fetchModelsReadyForProvider": "Found {n} models for {provider}. Review and save the enabled list.",
|
||||
"settings.enabledModelsSavedForProvider": "Saved {n} enabled models for {provider}",
|
||||
@@ -1385,12 +1661,18 @@ export const en = {
|
||||
"settings.fetchModelsAfterKeyFailed": "Key saved, but available models could not be refreshed automatically: {err}",
|
||||
"settings.fetchModelsAfterKeyFailedForProvider": "Key saved, but available models for {provider} could not be refreshed: {err}",
|
||||
"settings.newProviderDraftName": "new provider",
|
||||
"settings.reasoningProtocol": "Reasoning protocol",
|
||||
"settings.reasoningProtocol.auto": "Auto (detect from model)",
|
||||
"settings.reasoningProtocol.deepseek": "DeepSeek",
|
||||
"settings.reasoningProtocol.openai": "OpenAI",
|
||||
"settings.reasoningProtocol.none": "None (off)",
|
||||
"settings.reasoningProtocolHint": "Controls the request shape behind effort. Auto uses the model capability registry and official endpoints; switch to OpenAI or None if a proxy is incompatible.",
|
||||
"settings.reasoningProtocol": "Model capability mode",
|
||||
"settings.reasoningProtocol.auto": "Auto-detect (recommended)",
|
||||
"settings.reasoningProtocol.deepseek": "DeepSeek thinking (thinking + depth)",
|
||||
"settings.reasoningProtocol.openai": "OpenAI reasoning (reasoning_effort)",
|
||||
"settings.reasoningProtocol.none": "Plain chat (no reasoning fields)",
|
||||
"settings.reasoningProtocolHint": "Auto-detect is best for most gateways. Switch only when a proxy is incompatible or the model documentation requires a specific reasoning shape.",
|
||||
"settings.thinkingMode": "Thinking override",
|
||||
"settings.thinkingMode.auto": "Auto (provider default)",
|
||||
"settings.thinkingMode.enabled": "Enabled",
|
||||
"settings.thinkingMode.disabled": "Disabled",
|
||||
"settings.thinkingMode.adaptive": "Adaptive (self-adjusting)",
|
||||
"settings.thinkingModeHint": "Provider-specific override. Use enabled/disabled for compatible gateways, adaptive only for providers that document it.",
|
||||
"settings.updateKey": "update {env} (saved globally)",
|
||||
"settings.updateKeyAction": "Update key",
|
||||
"settings.keyStatus": "Key",
|
||||
@@ -1505,6 +1787,8 @@ export const en = {
|
||||
"msg.imageAttachment": "Image",
|
||||
"msg.workspaceReference": "Workspace reference",
|
||||
"msg.folderReference": "Folder · Workspace reference",
|
||||
"msg.pastedExpandTooltip": "Expand pasted text",
|
||||
"msg.pastedCollapseTooltip": "Collapse pasted text",
|
||||
"turnActions.summary": "Summary",
|
||||
"turnActions.rewind": "Rewind",
|
||||
"transcript.jumpToBottom": "Jump to bottom",
|
||||
@@ -1586,7 +1870,7 @@ export const en = {
|
||||
"settings.telemetryLabel": "Anonymous usage ping",
|
||||
"settings.telemetryHint": "On launch, send a random install id plus version and OS to count active installs, and — if the app crashed on a previous run — resend that crash report. Never includes conversations, keys, or file contents.",
|
||||
"settings.metricsLabel": "Share aggregate quality metrics",
|
||||
"settings.metricsHint": "On by default. Sends anonymous turn-end counts, settings preference snapshots, and Memory v5 aggregate buckets such as injection on/off, size buckets, IR count buckets, and memory graph size buckets. Includes the random install id used to de-duplicate DAU. Buckets may include normalized custom provider and model names — never conversations, prompts, keys, paths, base URLs, memory text, tool outputs, or file contents.",
|
||||
"settings.metricsHint": "On by default. Sends anonymous turn-end counts, updater error categories, settings preference snapshots, and Memory v5 aggregate buckets such as injection on/off, size buckets, IR count buckets, and memory graph size buckets. Includes the random install id used to de-duplicate DAU. Buckets may include normalized custom provider and model names — never conversations, prompts, keys, paths, base URLs, memory text, tool outputs, or file contents.",
|
||||
"updater.currentVersion": "Current version: {v}",
|
||||
"updater.channelLabel": "Update channel: {channel}",
|
||||
"updater.checkButton": "Check for updates",
|
||||
@@ -1629,10 +1913,20 @@ export const en = {
|
||||
"context.scopeGlobal": "Scope: Global",
|
||||
"context.scopeProject": "Project scope · {name}",
|
||||
"context.windowTitle": "Context window",
|
||||
"context.windowSubtitle": "Current context window usage",
|
||||
"context.windowStatusHealthy": "Context healthy",
|
||||
"context.windowStatusWatch": "Compression soon",
|
||||
"context.windowStatusPastCompact": "Compression threshold reached",
|
||||
"context.windowStatusNearLimit": "Near context limit",
|
||||
"context.windowUsageSummary": "{pct}% used",
|
||||
"context.windowCompactRemaining": "{used} / {window} · {tokens} until compression",
|
||||
"context.windowCompactDistance": "To compact",
|
||||
"context.windowUsedLabel": "Used",
|
||||
"context.windowUsed": "Used context",
|
||||
"context.windowCompactThreshold": "Compression threshold",
|
||||
"context.windowCompactHeadroom": "Before compression",
|
||||
"context.sessionMetrics": "Session metrics",
|
||||
"context.runtimeMetrics": "Runtime",
|
||||
"context.costMetrics": "Cost",
|
||||
"context.sessionStatus": "Session status",
|
||||
"context.sessionChanges": "Session changes",
|
||||
"context.referencedFiles": "Dependency files",
|
||||
"context.noChanges": "No changed files in this session yet",
|
||||
@@ -1640,34 +1934,28 @@ export const en = {
|
||||
"context.changedMeta": "{count} change records",
|
||||
"context.readMeta": "{count} read records",
|
||||
"context.viewAll": "View all",
|
||||
"context.health": "Context health",
|
||||
"context.healthGood": "Context healthy",
|
||||
"context.healthGoodShort": "Healthy",
|
||||
"context.healthGoodBody": "Usage, source files, and session changes are traceable.",
|
||||
"context.healthNearLimit": "Context near limit",
|
||||
"context.healthNearLimitShort": "Near limit",
|
||||
"context.healthNearLimitBody": "{pct}% used, keep critical sources and consider compaction.",
|
||||
"context.healthManyFiles": "Many source files",
|
||||
"context.healthManyFilesShort": "Many files",
|
||||
"context.healthManyFilesBody": "{count} file records read, verify sources are still relevant.",
|
||||
"context.healthLowCache": "Low cache hit",
|
||||
"context.healthLowCacheShort": "Low cache",
|
||||
"context.healthLowCacheBody": "Cache hit at {pct}%, watch context stability on long tasks.",
|
||||
"context.prompt": "Prompt",
|
||||
"context.completion": "Completion",
|
||||
"context.reasoning": "Reasoning",
|
||||
"context.other": "Other",
|
||||
"context.total": "Total",
|
||||
"context.usageAnalysis": "Usage analysis",
|
||||
"context.usageAnalysisView": "Usage analysis view",
|
||||
"context.usageAnalysisSource": "By source",
|
||||
"context.usageAnalysisType": "By type",
|
||||
"context.tokenBreakdown": "Token composition",
|
||||
"context.cacheHit": "Cache hit",
|
||||
"context.inputCache": "Input cache",
|
||||
"context.inputCacheHit": "Input (cache hit)",
|
||||
"context.inputCacheMiss": "Input (cache miss)",
|
||||
"context.outputTokens": "Output tokens",
|
||||
"context.sessionTokens": "Session tokens",
|
||||
"context.sessionTokens": "Session total tokens",
|
||||
"context.sessionTokensShort": "Total tokens",
|
||||
"context.tokensValue": "{value} tokens",
|
||||
"context.sessionCost": "Session cost",
|
||||
"context.costBreakdown": "Cost breakdown",
|
||||
"context.sourceBreakdown": "Model usage breakdown",
|
||||
"context.sourceShareTokens": "By tokens",
|
||||
"context.sourceBreakdown": "Usage source",
|
||||
"context.sourceShareTitle": "Source share",
|
||||
"context.sourceInput": "Input",
|
||||
"context.sourceOutput": "Output",
|
||||
"context.sourceCacheHit": "Hit",
|
||||
@@ -1683,9 +1971,9 @@ export const en = {
|
||||
"context.sourceClassifier": "Classifier",
|
||||
"context.sourceTitle": "Title",
|
||||
"context.sourceRequests": "{count} req",
|
||||
"context.moreSources": "{count} more sources",
|
||||
"context.requests": "Requests",
|
||||
"context.time": "Time",
|
||||
"context.compaction": "Compaction",
|
||||
"context.time": "Runtime",
|
||||
"context.durationSeconds": "{seconds}s",
|
||||
"context.durationMinutesSeconds": "{minutes}m {seconds}s",
|
||||
|
||||
|
||||
@@ -134,6 +134,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"caps.summary": "{connected} 個已連線 · {failed} 個異常 · {skills} 個技能",
|
||||
"caps.connectorsTab": "MCP 伺服器",
|
||||
"caps.skillsTab": "Agent 技能",
|
||||
"caps.pluginsTab": "插件",
|
||||
"caps.servers": "MCP 伺服器",
|
||||
"caps.noServers": "未設定 MCP 伺服器。用 /mcp add 新增。",
|
||||
"caps.failureTitle": "{failed} 個 MCP 啟動異常",
|
||||
@@ -227,6 +228,62 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"caps.trustedReadOnlyTitle": "已記住為之後規劃和唯讀 research 可用的唯讀工具",
|
||||
"caps.searchSkills": "搜尋技能…",
|
||||
"caps.noSkillMatches": "沒有匹配的技能。",
|
||||
"caps.pluginRefresh": "重新整理插件",
|
||||
"caps.pluginInstallTitle": "安裝插件",
|
||||
"caps.pluginInstallHint": "選擇本機插件目錄,或從 Git 倉庫安裝。",
|
||||
"caps.pluginInstallMethod": "插件安裝方式",
|
||||
"caps.pluginInstallLocal": "本機目錄",
|
||||
"caps.pluginInstallGit": "Git 倉庫",
|
||||
"caps.pluginSource": "插件來源",
|
||||
"caps.pluginGitSource": "Git 倉庫地址",
|
||||
"caps.pluginSourcePlaceholder": "git:github.com/obra/superpowers",
|
||||
"caps.pluginChooseLocalFolder": "選擇插件目錄",
|
||||
"caps.pluginLocalFolder": "本機插件目錄",
|
||||
"caps.pluginNoLocalFolder": "尚未選擇目錄",
|
||||
"caps.pluginInstallName": "安裝名稱(可選)",
|
||||
"caps.pluginInstallNamePlaceholder": "安裝名稱(可選)",
|
||||
"caps.pluginLink": "開發模式:連結來源目錄",
|
||||
"caps.pluginLinkHint": "不複製插件,直接使用所選目錄;適合調試插件。移動或刪除該目錄會導致插件失效。",
|
||||
"caps.pluginReplace": "覆蓋同名插件",
|
||||
"caps.pluginReplaceHint": "插件名已存在時,允許用目前來源替換它。",
|
||||
"caps.pluginPreview": "預檢",
|
||||
"caps.pluginInstall": "安裝插件",
|
||||
"caps.noPluginsTitle": "尚未安裝插件",
|
||||
"caps.noPluginsHint": "從 Git 或本機路徑安裝插件包,把技能、hooks 和 MCP 伺服器作為一個統一單元管理。",
|
||||
"caps.installedPlugins": "已安裝插件",
|
||||
"caps.pluginsSummary": "{enabled} 個啟用 · 共 {total} 個 · {issues} 個異常",
|
||||
"caps.pluginCounts": "{skills} 技能 · {hooks} hooks · {mcps} MCP",
|
||||
"caps.pluginNoCapabilities": "未匯出能力",
|
||||
"caps.pluginWarnings": "{count} 個警告",
|
||||
"caps.pluginEnable": "啟用插件",
|
||||
"caps.pluginDisable": "停用插件",
|
||||
"caps.pluginEnabled": "已啟用",
|
||||
"caps.pluginDisabled": "已停用",
|
||||
"caps.pluginVersion": "版本",
|
||||
"caps.pluginRoot": "安裝路徑",
|
||||
"caps.pluginUpdate": "更新",
|
||||
"caps.pluginDoctor": "診斷",
|
||||
"caps.pluginRemove": "移除插件",
|
||||
"caps.pluginConfirmRemove": "確認移除",
|
||||
"caps.pluginPlanError": "安裝檢查失敗",
|
||||
"caps.pluginPlanReady": "安裝計畫",
|
||||
"caps.pluginAction": "操作",
|
||||
"caps.pluginPlanStatus": "插件操作:{status}",
|
||||
"caps.pluginPlanComplete": "插件操作完成",
|
||||
"caps.pluginPlanInstalled": "插件已安裝。展開下方插件,可查看它匯出的 skills、hooks 和 MCP 伺服器的使用方式。",
|
||||
"caps.pluginUsageTitle": "使用方法",
|
||||
"caps.pluginUsageEnabledHint": "啟用的插件會載入到新會話中。可用 /skills 查看技能、用 /<skill> 直接呼叫,或自然描述任務;hooks 和 MCP 伺服器會按正常會話流程自動參與。",
|
||||
"caps.pluginUsageDisabledHint": "啟用此插件後,它的 skills、hooks 或 MCP 伺服器才會參與會話。",
|
||||
"caps.pluginNoCapabilityDetails": "此插件暫無可展示的能力明細。",
|
||||
"caps.pluginSkillsTitle": "Skills",
|
||||
"caps.pluginSkillsHint": "用 /skills 瀏覽,直接呼叫,或在對話中說出工作流名稱。",
|
||||
"caps.pluginHooksTitle": "Hooks",
|
||||
"caps.pluginHooksHint": "Hooks 會在匹配的生命週期事件中自動觸發。",
|
||||
"caps.pluginMCPTitle": "MCP 伺服器",
|
||||
"caps.pluginMCPHint": "這些伺服器提供的工具會透過正常 MCP/工具流程可用。",
|
||||
"caps.pluginNoDescription": "無描述",
|
||||
"caps.pluginHookNoTarget": "未指定 hook 目標",
|
||||
"caps.pluginMCPNoTarget": "未指定啟動目標",
|
||||
"caps.sources": "來源",
|
||||
"caps.sourcesSummaryActive": "{active} 個有效",
|
||||
"caps.sourcesSummaryMissing": "{missing} 個缺失",
|
||||
@@ -290,6 +347,17 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"composer.removeCurrentProjectDisabled": "不能移除當前專案",
|
||||
"composer.resize": "拖動調整輸入區高度,雙擊重置",
|
||||
"composer.send": "傳送(Enter)",
|
||||
"composer.queueGuidance": "加入引導佇列(Enter)",
|
||||
"composer.steer": "追加到目前任務(Enter)",
|
||||
"composer.steerPlaceholder": "輸入要加入佇列的引導…",
|
||||
"composer.guidanceQueue": "待處理引導",
|
||||
"composer.guidanceCount": "待處理引導 {n}",
|
||||
"composer.guidanceRemaining": "還有 {n} 條",
|
||||
"composer.guidanceCollapse": "收起",
|
||||
"composer.guidanceMode": "引導",
|
||||
"composer.guidanceSend": "將這條引導加入資訊流",
|
||||
"composer.guidanceDismiss": "移除這條引導提示",
|
||||
"composer.guidanceMore": "更多引導選項",
|
||||
"composer.stop": "停止(Esc)",
|
||||
"composer.stopShort": "停止",
|
||||
"composer.pastedLabel": "[已貼上文字 #{id} · {lines} 行]",
|
||||
@@ -298,6 +366,9 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"composer.pastedExpand": "展開",
|
||||
"composer.pastedRemove": "刪除貼上文字",
|
||||
"composer.removeImage": "移除圖片",
|
||||
"imageViewer.clickToPreview": "點擊查看大圖",
|
||||
"imageViewer.closePreview": "關閉預覽",
|
||||
"imageViewer.title": "圖片預覽",
|
||||
"composer.imageInputUnsupported": "目前模型不會直接接收圖片。可切換到支援圖片的模型,或讓 OCR/識圖 MCP 工具讀取附件路徑。",
|
||||
"composer.contextItems": "上下文項目",
|
||||
"composer.workspaceReferences": "工作區引用",
|
||||
@@ -398,6 +469,11 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"heartbeat.approvalModeAskHint": "每次工具調用都需要手動確認",
|
||||
"heartbeat.approvalModeAutoHint": "自動批准安全工具,保留拒絕規則",
|
||||
"heartbeat.approvalModeYoloHint": "跳過所有工具權限提示,任務自動運行",
|
||||
"heartbeat.notifyChannels": "推送到機器人",
|
||||
"heartbeat.notifyChannelsOn": "開啟",
|
||||
"heartbeat.notifyChannelsOff": "關閉",
|
||||
"heartbeat.notifyChannelsOnHint": "AI 輸出會即時推送到已連線的飛書/QQ 機器人",
|
||||
"heartbeat.notifyChannelsOffHint": "只建立桌面對話,不推送到機器人",
|
||||
"heartbeat.fieldNewConversation": "對話模式",
|
||||
"heartbeat.newConversationEachRunOn": "每次執行新建對話",
|
||||
"heartbeat.newConversationEachRunOff": "複用同一對話",
|
||||
@@ -441,6 +517,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"ask.hideDetails": "收起",
|
||||
"ask.back": "返回",
|
||||
"ask.next": "下一步",
|
||||
"ask.customAnswer": "其他答案",
|
||||
"ask.customPlaceholder": "輸入你自己的答案…",
|
||||
"ask.justChat": "只是聊聊",
|
||||
|
||||
@@ -497,6 +574,18 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"history.historySessionActions": "歷史會話操作",
|
||||
"history.trashActions": "回收站操作",
|
||||
|
||||
// 會話恢復
|
||||
"recovery.branch": "恢復分支",
|
||||
"recovery.badge": "恢復",
|
||||
"recovery.open": "開啟恢復分支",
|
||||
"recovery.toast": "未儲存的本機內容已儲存為 {title}。",
|
||||
"recovery.failedLease": "恢復分支被另一個執行階段占用,未切換過去。",
|
||||
"recovery.failedUnavailable": "無法開啟恢復分支,目前標籤仍停留在原會話。",
|
||||
"recovery.banner": "目前會話來自儲存衝突恢復。繼續前建議與原會話對比。",
|
||||
"recovery.reason": "原因:{reason}",
|
||||
"recovery.digest": "摘要:{digest}",
|
||||
"recovery.parent": "父會話:{parent}",
|
||||
|
||||
// 專案樹
|
||||
"projectTree.workspaceTitle": "專案",
|
||||
"projectTree.pinnedTitle": "置頂",
|
||||
@@ -612,6 +701,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.tab.capabilities": "MCP 與技能",
|
||||
"settings.tab.mcp": "MCP 與工具",
|
||||
"settings.tab.skills": "技能",
|
||||
"settings.tab.plugins": "插件",
|
||||
"settings.tab.memory": "記憶",
|
||||
"settings.tab.shortcuts": "快捷鍵",
|
||||
"settings.tab.network": "網路",
|
||||
@@ -625,6 +715,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.tabSub.capabilities": "伺服器、工具與技能",
|
||||
"settings.tabSub.mcp": "伺服器與工具",
|
||||
"settings.tabSub.skills": "來源與啟用",
|
||||
"settings.tabSub.plugins": "包與診斷",
|
||||
"settings.tabSub.memory": "專案與全域",
|
||||
"settings.tabSub.shortcuts": "按鍵與幫助",
|
||||
"settings.tabSub.network": "代理與連線",
|
||||
@@ -681,6 +772,10 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.subagentEffort": "子代理 effort",
|
||||
"settings.subagentEffortDefault": "auto(模型服務預設)",
|
||||
"settings.subagentHint": "作用於 task 和 runAs=subagent skills;工具呼叫參數或單個 skill 設定會覆蓋它。",
|
||||
"settings.subagentDepth": "子代理巢狀深度",
|
||||
"settings.subagentDepthHint": "控制第一層子代理能否再派發一層 reviewer 或 worker 子代理。",
|
||||
"settings.subagentDepthOne": "1 層",
|
||||
"settings.subagentDepthTwo": "2 層",
|
||||
"settings.defaultToolApprovalMode": "新會話預設審批",
|
||||
"settings.defaultToolApprovalModeHint": "保存在全域設定中,僅用於新建會話;切換或恢復已有會話時不會覆蓋其 Ask / Auto / Yolo 狀態。",
|
||||
"settings.defaultToolApprovalMode.ask": "詢問",
|
||||
@@ -703,7 +798,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.coldResumePrune.on": "開",
|
||||
"settings.coldResumePrune.off": "關",
|
||||
"settings.reasoningLanguage": "思考語言",
|
||||
"settings.reasoningLanguageHint": "只影響可見思考過程。自動跟隨對話語言,不額外注入語言指令。",
|
||||
"settings.reasoningLanguageHint": "只影響可見思考過程。自動在原始提問明顯為中文時錨定中文,引用內容不參與判斷。",
|
||||
"settings.reasoningLanguage.auto": "自動",
|
||||
"settings.reasoningLanguage.zh": "中文",
|
||||
"settings.reasoningLanguage.en": "English",
|
||||
@@ -738,7 +833,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.providerModelsUrlPlaceholder": "例如 https://api.openai.com/v1/models",
|
||||
"settings.providerModelsUrlHint": "可選。模型發現需要使用不同於聊天請求的位址時填寫。",
|
||||
"settings.providerModels": "模型(逗號分隔)",
|
||||
"settings.providerApiKeyEnv": "api_key_env(如 DEEPSEEK_API_KEY)",
|
||||
"settings.providerApiKeyEnv": "api_key_env(可以不設定)",
|
||||
"settings.providerBalanceUrl": "餘額查詢 URL(可選)",
|
||||
"settings.balanceUrlPlaceholder": "餘額查詢介面(如 https://api.deepseek.com/user/balance)",
|
||||
"settings.balanceUrlHint": "可選。填了就會查詢並在狀態欄顯示錢包餘額。",
|
||||
@@ -748,12 +843,22 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.providerReasoningProtocol": "reasoning protocol",
|
||||
"settings.providerReasoningProtocolAuto": "auto(按模型和端點自動判斷)",
|
||||
"settings.providerReasoningProtocolHint": "用於 OpenAI-compatible 推理模型。deepseek 使用 DeepSeek reasoning 參數,openai 使用普通 reasoning_effort,none 表示停用。",
|
||||
"settings.effortBehavior": "effort 行為",
|
||||
"settings.effortUseProtocolDefaults": "協定預設",
|
||||
"settings.effortUseCustom": "自訂覆蓋",
|
||||
"settings.effortProtocolDefault.auto": "協定預設會把 supported_efforts 儲存為空。Reasonix 會把已知 DeepSeek 模型/端點識別為 high|max,把 OpenAI reasoning 識別為 low|medium|high,不支援的服務不暴露 /effort。",
|
||||
"settings.effortProtocolDefault.deepseek": "DeepSeek 預設暴露 auto|high|max。Reasonix 會保持 thinking 啟用,並用 reasoning_effort 控制深度。",
|
||||
"settings.effortProtocolDefault.openai": "OpenAI reasoning 預設暴露 auto|low|medium|high,並傳送普通 reasoning_effort。",
|
||||
"settings.effortProtocolDefault.minimax": "MiniMax 風格端點使用 adaptive|disabled 這類服務商自訂 thinking 等級。只有端點文件明確支援時才新增自訂等級。",
|
||||
"settings.effortProtocolDefault.none": "該供應商已停用 effort;Reasonix 不會傳送 reasoning_effort 或 thinking 控制參數。",
|
||||
"settings.supportedEfforts": "支援的 effort 等級",
|
||||
"settings.supportedEffortsCustomPlaceholder": "自訂等級(按 Enter)",
|
||||
"settings.supportedEffortsHint": "勾選該模型服務在 /effort 命令中暴露的等級,或新增自訂名稱。留空則使用內建預設(DeepSeek:high|max,Anthropic:low|medium|high|xhigh|max)。",
|
||||
"settings.supportedEffortsNoPresets": "沒有標準預設",
|
||||
"settings.supportedEffortsNoPresetsHint": "僅在端點文件明確給出 effort 名稱時新增自訂等級。",
|
||||
"settings.supportedEffortsHint": "自訂等級會覆蓋協定預設。只選擇端點文件支援的等級;不相容組合可能導致模型請求失敗。",
|
||||
"settings.defaultEffort": "預設 effort",
|
||||
"settings.defaultEffortAuto": "auto(使用內建預設)",
|
||||
"settings.defaultEffortHint": "在 /effort 為 auto 或未設定時使用。必須是上方等級之一;為空時回退到第一個等級(無選中時回退到內建預設)。",
|
||||
"settings.defaultEffortAuto": "未設定(使用協定/自訂預設)",
|
||||
"settings.defaultEffortHint": "在 /effort 為 auto 或未設定時使用。留空則使用協定預設;自訂等級時回退到第一個自訂等級。",
|
||||
"settings.proxyMode": "代理模式",
|
||||
"settings.proxyMode.auto": "自動",
|
||||
"settings.proxyMode.env": "環境變數",
|
||||
@@ -780,12 +885,18 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.yoloHint": "所有工具呼叫(寫檔案、bash)自動執行、不再詢問。deny 規則仍會攔截。僅執行時生效,重啟後失效。",
|
||||
"settings.addRule": "新增 {list} 規則…",
|
||||
"settings.sandboxTitle": "沙箱與工作區",
|
||||
"settings.sandboxBoundaryHint": "沙箱可寫根只限制已核准的 Bash 和檔案工具能寫到哪裡;它不會自動核准命令。mvn deploy 這類命令還需要權限規則或核准模式放行。",
|
||||
"settings.reloadSessionConfig": "套用手動設定修改",
|
||||
"settings.reloadSessionConfigHint": "設定頁內修改會自動生效;此按鈕用於套用手動編輯 config.toml / reasonix.toml 後的變更。",
|
||||
"settings.bashSandbox": "Bash 沙箱",
|
||||
"settings.bashEnforce": "enforce(隔離 bash)",
|
||||
"settings.bashOff": "off(不受限執行)",
|
||||
"settings.allowNetwork": "允許沙箱內 bash 存取網路",
|
||||
"settings.workspaceRoot": "工作區根目錄",
|
||||
"settings.workspaceDefault": "(預設:當前目錄)",
|
||||
"settings.effectiveWriteRoots": "實際可寫根",
|
||||
"settings.effectiveWriteRootsHint": "按目前工作區展開。設定頁內修改會自動生效;手動編輯設定檔後可用上方按鈕刷新目前會話。",
|
||||
"settings.noEffectiveWriteRoots": "暫無可寫根",
|
||||
"settings.appearance": "外觀",
|
||||
"settings.theme": "主題",
|
||||
"settings.themeStyle": "強調色",
|
||||
@@ -820,6 +931,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.pageDesc.providers": "管理模型供應商、API 位址、金鑰與可用模型。",
|
||||
"settings.pageDesc.mcp": "管理 MCP 伺服器連線和工具發現。",
|
||||
"settings.pageDesc.skills": "瀏覽、啟用和管理智慧體技能與來源。",
|
||||
"settings.pageDesc.plugins": "安裝和管理插件包,用統一單元提供技能、hooks 與 MCP 伺服器。",
|
||||
"settings.pageDesc.memory": "管理 Reasonix 會長期參考的偏好、專案約定和指令檔案。",
|
||||
"settings.pageDesc.network": "代理與網路設定。",
|
||||
"settings.pageDesc.permissions": "寫操作模式與細粒度工具權限規則。",
|
||||
@@ -832,7 +944,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.customProviders": "自訂供應商",
|
||||
"settings.customProvidersHint": "你自己新增的 OpenAI-compatible 或代理接入,可以編輯、刪除並持久化模型列表。",
|
||||
"settings.customProvidersEmpty": "還沒有自訂供應商。常用服務優先使用上方內建接入。",
|
||||
"settings.providerAccessEmptyHint": "點擊「新增供應商」,選擇 DeepSeek 或自訂 OpenAI-compatible 接入。",
|
||||
"settings.providerAccessEmptyHint": "使用「推薦預設」可快速接入常用供應商,通常只需填寫 API Key;使用「自訂供應商」可手動設定 OpenAI-compatible 或 Anthropic-compatible 端點。",
|
||||
"settings.builtinProviderBadge": "內建",
|
||||
"settings.customProviderBadge": "自訂",
|
||||
"settings.configureProvider": "設定",
|
||||
@@ -844,24 +956,75 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.searchModels": "搜尋模型...",
|
||||
"settings.noMatchingModels": "沒有匹配的模型",
|
||||
"settings.addProvider.chooseTitle": "新增供應商",
|
||||
"settings.addProvider.chooseHint": "選擇官方預設,或新增一個自訂 OpenAI-compatible 接入。",
|
||||
"settings.addProvider.officialChoice": "官方供應商",
|
||||
"settings.addProvider.officialChoiceHint": "從 Reasonix 預設的 DeepSeek 接入中選擇。",
|
||||
"settings.addProvider.chooseHint": "選擇推薦預設,或手動新增一個自訂供應商。",
|
||||
"settings.addProvider.officialChoice": "推薦預設",
|
||||
"settings.addProvider.officialChoiceHint": "選擇 DeepSeek 或已適配的自訂供應商預設。",
|
||||
"settings.addProvider.customChoice": "自訂供應商",
|
||||
"settings.addProvider.customChoiceHint": "按 base_url、模型、金鑰環境變數等參數手動設定。",
|
||||
"settings.addProvider.officialTitle": "新增官方供應商",
|
||||
"settings.addProvider.officialHint": "官方預設會寫入固定端點和預設參數,只需要選擇供應商並設定金鑰。",
|
||||
"settings.addProvider.officialHint": "推薦預設會填好端點、模型、視覺和推理預設值,通常只需要填 API Key。",
|
||||
"settings.addProvider.customTitle": "新增自訂供應商",
|
||||
"settings.addProvider.customHint": "適合代理、聚合平台或其它 OpenAI-compatible 服務。",
|
||||
"settings.addProvider.official.deepseek": "DeepSeek 官方",
|
||||
"settings.addProvider.official.deepseekDesc": "官方 API Key 接入,可用 DeepSeek V4 Flash / Pro。",
|
||||
"settings.addProvider.preset.kimiCnDesc": "Kimi 中國 API,走 Moonshot CN 端點,預置 K2 視覺模型。",
|
||||
"settings.addProvider.preset.kimiGlobalDesc": "Kimi 國際 API,走 Moonshot global 端點,預置 K2 視覺模型。",
|
||||
"settings.addProvider.preset.kimiCodingPlanDesc": "Kimi Coding Plan,走獨立 Anthropic-compatible 端點,不歸屬 CN/Global。",
|
||||
"settings.addProvider.preset.mimoApiDesc": "小米 MiMo 直連 API,適合 v2.5 按量付費模型。",
|
||||
"settings.addProvider.preset.mimoAnthropicDesc": "小米 MiMo 直連 Anthropic-compatible 端點,自動使用 MiMo v2.5 模型。",
|
||||
"settings.addProvider.preset.mimoTokenPlanCnDesc": "小米 MiMo token plan 中國區端點。",
|
||||
"settings.addProvider.preset.mimoTokenPlanCnAnthropicDesc": "小米 MiMo token plan 中國區 Anthropic-compatible 端點。",
|
||||
"settings.addProvider.preset.mimoTokenPlanSgpDesc": "小米 MiMo token plan 新加坡端點。",
|
||||
"settings.addProvider.preset.mimoTokenPlanSgpAnthropicDesc": "小米 MiMo token plan 新加坡 Anthropic-compatible 端點。",
|
||||
"settings.addProvider.preset.mimoTokenPlanAmsDesc": "小米 MiMo token plan 阿姆斯特丹端點。",
|
||||
"settings.addProvider.preset.mimoTokenPlanAmsAnthropicDesc": "小米 MiMo token plan 阿姆斯特丹 Anthropic-compatible 端點。",
|
||||
"settings.addProvider.preset.minimaxCnApiDesc": "MiniMax 中國 OpenAI-compatible M 系列 API 端點。",
|
||||
"settings.addProvider.preset.minimaxGlobalApiDesc": "MiniMax 國際 OpenAI-compatible M 系列 API 端點。",
|
||||
"settings.addProvider.preset.minimaxCnAnthropicDesc": "MiniMax 中國 Anthropic-compatible M 系列端點。",
|
||||
"settings.addProvider.preset.minimaxGlobalAnthropicDesc": "MiniMax 國際 Anthropic-compatible 端點,自動使用 Bearer 認證。",
|
||||
"settings.addProvider.preset.glmCnDesc": "智譜 GLM 中國 API,預置 GLM thinking 控制。",
|
||||
"settings.addProvider.preset.zaiGlobalDesc": "Z.AI 國際 GLM API。",
|
||||
"settings.addProvider.preset.glmCodingPlanCnDesc": "智譜 GLM 中國 coding plan 端點,預設 GLM-5.2。",
|
||||
"settings.addProvider.preset.glmCodingPlanCnAnthropicDesc": "智譜 GLM 中國 Anthropic-compatible coding plan 端點,預設 GLM-5.2[1m]。",
|
||||
"settings.addProvider.preset.zaiCodingPlanGlobalDesc": "Z.AI 國際 coding plan 端點,預設 GLM-5.2。",
|
||||
"settings.addProvider.preset.zaiCodingPlanGlobalAnthropicDesc": "Z.AI 國際 Anthropic-compatible coding plan 端點,預設 GLM-5.2[1m]。",
|
||||
"settings.addProvider.preset.opencodeGoDesc": "OpenCode Go 混合模型閘道,內建每模型能力覆蓋。",
|
||||
"settings.addProvider.preset.opencodeGoAnthropicDesc": "OpenCode Go Anthropic 線路,預置 Qwen 與 MiniMax 模型。",
|
||||
"settings.addProvider.preset.opencodeZenAnthropicDesc": "OpenCode Zen Anthropic 線路,預置 Claude 與 Qwen 模型。",
|
||||
"settings.addProvider.preset.qwenCnDesc": "Qwen/DashScope 中國標準 API 端點。",
|
||||
"settings.addProvider.preset.qwenGlobalDesc": "Qwen/DashScope 國際標準 API 端點。",
|
||||
"settings.addProvider.preset.qwenCodingPlanCnDesc": "Qwen Coding Plan 中國 OpenAI-compatible 端點,預設 qwen3.7-plus。",
|
||||
"settings.addProvider.preset.qwenCodingPlanCnAnthropicDesc": "Qwen Coding Plan 中國 Anthropic-compatible 端點,預設 qwen3.7-plus。",
|
||||
"settings.addProvider.preset.qwenCodingPlanGlobalDesc": "Qwen Coding Plan 國際 OpenAI-compatible 端點,預設 qwen3.7-plus。",
|
||||
"settings.addProvider.preset.qwenCodingPlanGlobalAnthropicDesc": "Qwen Coding Plan 國際 Anthropic-compatible 端點,預設 qwen3.7-plus。",
|
||||
"settings.addProvider.preset.stepfunDesc": "階躍星辰 StepFun coding plan OpenAI-compatible 端點,預設 step-3.7-flash。",
|
||||
"settings.addProvider.preset.stepfunAnthropicDesc": "階躍星辰 StepFun coding plan Anthropic-compatible 端點,預設 step-3.7-flash。",
|
||||
"settings.addProvider.preset.novitaDesc": "NovitaAI 多模型閘道,預置 Kimi、MiniMax、GLM、DeepSeek、Qwen。",
|
||||
"settings.addProvider.preset.gmiDesc": "GMI Cloud 多模型直連閘道,預置 GLM、DeepSeek、Kimi、Gemini、Claude、GPT。",
|
||||
"settings.addProvider.preset.vercelAiGatewayDesc": "Vercel AI Gateway,走 Anthropic-compatible Messages API。",
|
||||
"settings.addProvider.preset.huggingfaceDesc": "HuggingFace Inference Router,預置 Qwen 與 DeepSeek 模型。",
|
||||
"settings.addProvider.preset.nvidiaDesc": "NVIDIA NIM 加速推理端點,預置 Nemotron 模型。",
|
||||
"settings.addProvider.preset.kilocodeDesc": "Kilo Code 閘道,預置 kilo/auto 自動路由模型。",
|
||||
"settings.addProvider.preset.ollamaCloudDesc": "Ollama Cloud 託管端點,支援 none 到 max 的 reasoning effort。",
|
||||
"settings.addProvider.addedBadge": "已新增",
|
||||
"settings.addProvider.modifiedBadge": "已修改",
|
||||
"settings.addProvider.nameConflictBadge": "同名占用",
|
||||
"settings.addProvider.similarExistingBadge": "有相似設定",
|
||||
"settings.addProvider.confirm": "新增供應商",
|
||||
"settings.addProvider.alreadyAddedAction": "已新增",
|
||||
"settings.addProvider.nameConflictAction": "先處理同名設定",
|
||||
"settings.addProvider.viewPresetProvider": "查看設定",
|
||||
"settings.addProvider.viewConflictProvider": "查看同名設定",
|
||||
"settings.addProvider.resetPreset": "重設為預設",
|
||||
"settings.addProvider.confirmResetPreset": "確認重設",
|
||||
"settings.customProviderName": "自訂供應商名稱",
|
||||
"settings.customProviderNamePlaceholder": "",
|
||||
"settings.providerNamePlaceholder": "供應商名稱(如 deepseek-proxy)",
|
||||
"settings.providerProtocol": "接入協定",
|
||||
"settings.providerProtocolOpenAI": "OpenAI-compatible",
|
||||
"settings.providerProtocolOpenAIHint": "適用於相容 OpenAI API 的代理、聚合平台和自建服務。",
|
||||
"settings.providerProtocolAnthropic": "Anthropic-compatible",
|
||||
"settings.providerProtocolAnthropicHint": "適用於 Claude Messages API 風格的服務,例如部分 coding plan 端點。",
|
||||
"settings.providerBaseUrlLabel": "API 位址",
|
||||
"settings.providerChatUrlLabel": "完整請求 URL",
|
||||
"settings.providerChatUrlPlaceholder": "例如 https://api.openai.com/v1/chat/completions",
|
||||
@@ -875,12 +1038,29 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.testFetchModels": "測試並取得模型",
|
||||
"settings.testFetchModelsHint": "會使用上方 API 位址與金鑰確認連線,並用返回結果填充模型列表。",
|
||||
"settings.manualModels": "模型列表",
|
||||
"settings.manualModelsHint": "介面不支援模型發現時,可手動填寫多個模型,用英文逗號分隔。",
|
||||
"settings.manualModelsHint": "介面不支援模型發現時,可手動填寫多個模型;下方可勾選哪些模型支援圖片輸入。",
|
||||
"settings.visionModels": "支援圖片輸入的模型",
|
||||
"settings.visionModelsHint": "啟用模型中可接收圖片輸入的子集,用英文逗號分隔。",
|
||||
"settings.providerAdvancedSettings": "進階設定",
|
||||
"settings.providerAdvancedSettings": "相容設定(通常不用改)",
|
||||
"settings.providerAdvancedExpandHint": "點擊展開詳細設定",
|
||||
"settings.providerAdvancedCollapseHint": "點擊收起",
|
||||
"settings.providerHeaders": "額外請求標頭(可選)",
|
||||
"settings.providerHeadersPlaceholder": "HTTP-Referer: https://example.com\nX-Title: Reasonix",
|
||||
"settings.providerHeadersHint": "用於 OpenRouter 等要求站點來源的中轉站。一行一個 Header: value;API Key 仍透過上方金鑰保存。",
|
||||
"settings.providerExtraBody": "額外請求體(可選)",
|
||||
"settings.providerExtraBodyPlaceholder": "{\n \"enable_thinking\": true\n}",
|
||||
"settings.providerExtraBodyHint": "會合併到聊天請求體頂層 JSON。model、messages、tools、stream、thinking 等核心欄位仍由 Reasonix 控制。",
|
||||
"settings.providerExtraBodyError": "請輸入不含 null 值的 JSON 物件,例如 { \"enable_thinking\": true }。",
|
||||
"settings.providerAuthHeader": "使用 Authorization: Bearer 發送金鑰",
|
||||
"settings.providerAuthHeaderHint": "適用於 MiniMax Global、Vercel AI Gateway 等不使用 x-api-key 的 Anthropic-compatible 閘道。",
|
||||
"settings.fetchingModels": "重新整理中…",
|
||||
"settings.fetchModelsSuccess": "已取得 {n} 個模型",
|
||||
"settings.fetchModelsManualFallbackAuth": "模型發現介面拒絕了金鑰或權限。請檢查 API Key;如果該服務不開放模型發現,也可以在下方手動填寫模型 ID。",
|
||||
"settings.fetchModelsManualFallbackUnsupported": "這個服務可能不支援自動取得模型列表。可以直接在下方手動填寫模型 ID。",
|
||||
"settings.fetchModelsManualFallbackNetwork": "暫時無法連接模型發現介面。請檢查 API 位址或代理;如果你知道模型 ID,也可以先手動填寫。",
|
||||
"settings.fetchModelsManualFallbackDecode": "模型發現介面返回了無法識別的格式。可以直接在下方手動填寫模型 ID。",
|
||||
"settings.fetchModelsManualFallbackEmpty": "沒有自動取得模型。可以直接在下方手動填寫模型 ID。",
|
||||
"settings.fetchModelsManualFallbackGeneric": "無法自動取得模型:{err}。可以直接在下方手動填寫模型 ID。",
|
||||
"settings.fetchModelsUpdatedForProvider": "已更新可用模型:{provider},共 {n} 個",
|
||||
"settings.fetchModelsEmpty": "沒有取得可用模型",
|
||||
"settings.fetchModelsEmptyForProvider": "{provider} 沒有返回可用模型",
|
||||
@@ -888,12 +1068,18 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.fetchModelsAfterKeyFailed": "金鑰已儲存,但暫時無法自動重新整理可用模型:{err}",
|
||||
"settings.fetchModelsAfterKeyFailedForProvider": "金鑰已儲存,但暫時無法重新整理 {provider} 的可用模型:{err}",
|
||||
"settings.newProviderDraftName": "新供應商",
|
||||
"settings.reasoningProtocol": "推理協定",
|
||||
"settings.reasoningProtocol.auto": "Auto(按模型自動判斷)",
|
||||
"settings.reasoningProtocol.deepseek": "DeepSeek",
|
||||
"settings.reasoningProtocol.openai": "OpenAI",
|
||||
"settings.reasoningProtocol.none": "None(關閉)",
|
||||
"settings.reasoningProtocolHint": "控制 effort 的請求格式。Auto 會按模型能力表和官方端點識別;代理不相容時可切到 OpenAI 或 None。",
|
||||
"settings.reasoningProtocol": "模型能力模式",
|
||||
"settings.reasoningProtocol.auto": "自動識別(推薦)",
|
||||
"settings.reasoningProtocol.deepseek": "DeepSeek 思考(thinking + 深度)",
|
||||
"settings.reasoningProtocol.openai": "OpenAI reasoning(reasoning_effort)",
|
||||
"settings.reasoningProtocol.none": "普通聊天(不傳送思考參數)",
|
||||
"settings.reasoningProtocolHint": "預設自動識別。只有代理不相容,或你確定模型需要特定 reasoning 格式時再切換。",
|
||||
"settings.thinkingMode": "Thinking 覆蓋",
|
||||
"settings.thinkingMode.auto": "Auto(使用服務預設)",
|
||||
"settings.thinkingMode.enabled": "Enabled(開啟)",
|
||||
"settings.thinkingMode.disabled": "Disabled(關閉)",
|
||||
"settings.thinkingMode.adaptive": "Adaptive(自適應)",
|
||||
"settings.thinkingModeHint": "服務商專用覆蓋項。相容閘道可用 enabled/disabled;只有服務文件明確支援時才使用 adaptive。",
|
||||
"settings.updateKey": "更新 {env}(全域儲存)",
|
||||
"settings.updateKeyAction": "更新金鑰",
|
||||
"settings.keyStatus": "金鑰",
|
||||
@@ -947,6 +1133,8 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"msg.imageAttachment": "圖片",
|
||||
"msg.workspaceReference": "工作區引用",
|
||||
"msg.folderReference": "資料夾 · 工作區引用",
|
||||
"msg.pastedExpandTooltip": "展開貼上文字",
|
||||
"msg.pastedCollapseTooltip": "收起貼上文字",
|
||||
"turnActions.summary": "總結",
|
||||
"turnActions.rewind": "回溯",
|
||||
"transcript.jumpToBottom": "跳到底部",
|
||||
@@ -1068,27 +1256,23 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"context.changedMeta": "{count} 條變更記錄",
|
||||
"context.readMeta": "{count} 條讀取記錄",
|
||||
"context.viewAll": "檢視全部",
|
||||
"context.health": "上下文狀態",
|
||||
"context.healthGood": "上下文狀態正常",
|
||||
"context.healthGoodBody": "用量、依據檔案和本會話變更保持可追蹤。",
|
||||
"context.healthNearLimit": "上下文接近上限",
|
||||
"context.healthNearLimitBody": "已使用 {pct}%,優先保留關鍵依據並考慮壓縮。",
|
||||
"context.healthManyFiles": "依據檔案較多",
|
||||
"context.healthManyFilesBody": "已讀取 {count} 條檔案記錄,可檢視依據檔案確認是否仍相關。",
|
||||
"context.healthLowCache": "快取命中偏低",
|
||||
"context.healthLowCacheBody": "當前快取命中 {pct}%,後續長任務可關注上下文穩定性。",
|
||||
"context.prompt": "提示詞",
|
||||
"context.completion": "回覆",
|
||||
"context.reasoning": "推理",
|
||||
"context.other": "其他",
|
||||
"context.total": "總計",
|
||||
"context.usageAnalysis": "用量分析",
|
||||
"context.usageAnalysisView": "用量分析視圖",
|
||||
"context.usageAnalysisSource": "按來源",
|
||||
"context.usageAnalysisType": "按類型",
|
||||
"context.tokenBreakdown": "Token 構成",
|
||||
"context.cacheHit": "快取命中",
|
||||
"context.inputCacheHit": "輸入 tokens(命中快取)",
|
||||
"context.inputCacheMiss": "輸入 tokens(未命中快取)",
|
||||
"context.outputTokens": "輸出 tokens",
|
||||
"context.sessionCost": "本次費用",
|
||||
"context.requests": "請求數",
|
||||
"context.time": "耗時",
|
||||
"context.time": "執行時間",
|
||||
"context.durationSeconds": "{seconds}秒",
|
||||
"context.durationMinutesSeconds": "{minutes}分{seconds}秒",
|
||||
|
||||
@@ -1417,11 +1601,76 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.toggleOff": "關閉",
|
||||
"settings.botSetup": "機器人入口",
|
||||
"settings.botSetupHint": "先開啟入口,再選擇要連線的聊天頻道。",
|
||||
"settings.botConnectPhoneTitle": "新增 IM Bot",
|
||||
"settings.botConnectPhoneSubtitle": "QQ 手動配置,飛書、Lark 和微信掃碼接入。",
|
||||
"settings.botGateway": "機器人閘道器",
|
||||
"settings.botGatewayHint": "桌面端統一管理 IM bot 的總開關、模型和執行參數。",
|
||||
"settings.botGatewayHint": "桌面端統一管理 IM bot 的總開關和連線狀態。",
|
||||
"settings.botBrowserPreviewWarning": "目前是瀏覽器預覽,只會儲存 mock 設定,不會寫入本機配置或啟動 QQ/IM Bot gateway。請在桌面應用或 CLI 中配置後再到 QQ 用戶端測試。",
|
||||
"settings.botSimpleEyebrow": "傻瓜式設定",
|
||||
"settings.botSimpleTitle": "行動端與聊天機器人",
|
||||
"settings.botSimpleHint": "先用手機掃碼遠端控制目前工作區;需要長時間從聊天工具存取時,再選擇一個 Bot Channel 配置。",
|
||||
"settings.botMobileRemoteEyebrow": "獨立入口",
|
||||
"settings.botMobileRemoteTitle": "手機遠端控制",
|
||||
"settings.botMobileRemoteHint": "掃碼或複製連結,從行動端打開目前工作區的控制入口;它和 IM Bot 頻道管理分開配置。",
|
||||
"settings.botMobileRemoteControlApi": "控制連結",
|
||||
"settings.botMobileRemoteDisabled": "開啟後生成二維碼和連結",
|
||||
"settings.botMobileRemoteEnableFirst": "開啟後生成二維碼",
|
||||
"settings.botMobileRemoteReady": "等待手機連線",
|
||||
"settings.botMobileRemoteWaiting": "手機掃碼連線",
|
||||
"settings.botMobileRemoteLoopbackNotice": "目前控制 API 仍是本機 loopback,只適合本機預覽;跨裝置 relay 接入後會沿用這個入口。",
|
||||
"settings.botMobileRemoteCopyLink": "複製連結",
|
||||
"settings.botMobileRemoteCopied": "已複製",
|
||||
"settings.botMobileRemoteCopyFailed": "複製失敗",
|
||||
"settings.botChannelEntryTitle": "使用 Bot Channel",
|
||||
"settings.botChannelEntryHint": "連接聊天 Bot,適合更長時間的行動端存取。",
|
||||
"settings.botManageBots": "機器人管理",
|
||||
"settings.botManageBotsHint": "左側選擇頻道;未配置時掃碼或填寫金鑰,配置後在右側管理這個機器人的執行設定。",
|
||||
"settings.botBackToOverview": "返回入口",
|
||||
"settings.botChannelOpenConfigured": "已配置,可繼續修改設定。",
|
||||
"settings.botChannelNeedsSetup": "未配置",
|
||||
"settings.botChannelTabsLabel": "機器人頻道",
|
||||
"settings.botChannelSetupTitle": "配置 {provider}",
|
||||
"settings.botChannelSetupHint": "未配置前只需要完成憑據或掃碼綁定;成功後會自動切換為具體設定。",
|
||||
"settings.botSetupStatus": "機器人設定狀態",
|
||||
"settings.botSetupStatusConnected": "{online}/{total} 在線",
|
||||
"settings.botSetupStatusAccessTrusted": "受信任名單 {count}",
|
||||
"settings.botSetupStatusAccessEveryone": "所有人可用",
|
||||
"settings.botSetupStatusAccessPerBot": "{configured}/{total} 個 Bot 已配置",
|
||||
"settings.botMode": "機器人設定模式",
|
||||
"settings.botModeSimple": "普通",
|
||||
"settings.botModeAdvanced": "高階",
|
||||
"settings.botShowAdvancedSettings": "高階設定",
|
||||
"settings.botHideAdvancedSettings": "收起高階設定",
|
||||
"settings.botAdvancedSettingsHint": "低頻能力:路由、佇列細項和自身訊息過濾。",
|
||||
"settings.botShowAddBot": "新增頻道",
|
||||
"settings.botStepConnectTitle": "連接機器人",
|
||||
"settings.botStepConnectHint": "QQ 手動填密鑰;飛書、Lark、微信掃碼即可接入。",
|
||||
"settings.botStepConnectEmpty": "還沒有連接",
|
||||
"settings.botSharedAllowlistHint": "所有渠道共用第 2 步「誰可以使用」的名單與配對設定,改一處即可全部生效。",
|
||||
"settings.botSharedAllowlistGo": "前往第 2 步編輯",
|
||||
"settings.botGlobalAllowlist": "舊全域白名單",
|
||||
"settings.botGlobalAllowlistHint": "相容舊配置;新 Bot 建議在各自詳情裡設定誰可以使用。",
|
||||
"settings.botAccessShowAllPlatforms": "顯示全部平台",
|
||||
"settings.botAccessShowConnectedOnly": "只看已連接平台",
|
||||
"settings.botListUsers": "使用者",
|
||||
"settings.botListGroups": "群組",
|
||||
"settings.botListApprovers": "審批人",
|
||||
"settings.botListAdmins": "管理員",
|
||||
"settings.botPlatformFeishuLark": "飛書 / Lark",
|
||||
"settings.botQueueDropLabel": "佇列滿時",
|
||||
"settings.botQueueDropHint": "佇列達到上限後,如何處理放不下的訊息。",
|
||||
"settings.botPairingDetailHint": "配對開關在第 2 步「誰可以使用」裡;這裡只調整配對碼有效期和待批數量。",
|
||||
"settings.botSimpleAccessTitle": "誰可以使用",
|
||||
"settings.botSimpleAccessTrustedSummary": "目前使用受信任名單,已填寫 {count} 條成員或群組。",
|
||||
"settings.botSimpleAccessEveryoneSummary": "目前允許所有人使用,適合短時間測試。",
|
||||
"settings.botAccessTrusted": "受信任名單",
|
||||
"settings.botAccessTrustedHint": "只允許填寫過的使用者或群組呼叫機器人。",
|
||||
"settings.botAccessEveryone": "所有人可用",
|
||||
"settings.botAccessEveryoneHint": "臨時測試方便,但不建議長期開啟。",
|
||||
"settings.botAccessPairing": "私聊先配對",
|
||||
"settings.botAccessPairingHint": "陌生私聊先發配對碼,本機批准後再放行。",
|
||||
"settings.botRoleAccess": "管理員與審批人",
|
||||
"settings.botRoleAccessHint": "可選。普通使用者不用填;需要遠端審批或管理時再配置。",
|
||||
"settings.botQueueModeSimple": "新訊息怎麼處理",
|
||||
"settings.botQueueModeSimpleHint": "當機器人還在處理上一條訊息時,選擇下一條訊息的處理方式。",
|
||||
"settings.saveBotSettings": "儲存機器人設定",
|
||||
"settings.botEnableBot": "啟用機器人",
|
||||
"settings.botGatewayMasterSwitch": "總開關",
|
||||
@@ -1432,8 +1681,48 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.botModelHint": "留空時使用預設模型。",
|
||||
"settings.botModelDefault": "使用預設模型",
|
||||
"settings.botRuntime": "執行限制",
|
||||
"settings.botRuntimeHint": "閘道器行程級限制,控制每次機器人任務最多走多遠。",
|
||||
"settings.botMaxSteps": "最大步數",
|
||||
"settings.botDebounceMs": "合併視窗 ms",
|
||||
"settings.botQueueCap": "佇列上限",
|
||||
"settings.botQueueMode.steer": "引導目前回合",
|
||||
"settings.botQueueMode.followup": "追加跟進",
|
||||
"settings.botQueueMode.collect": "收集等待",
|
||||
"settings.botQueueMode.interrupt": "打斷目前",
|
||||
"settings.botQueueDrop.summarize": "彙總溢出",
|
||||
"settings.botQueueDrop.old": "丟棄最舊",
|
||||
"settings.botQueueDrop.new": "丟棄最新",
|
||||
"settings.botIgnoreSelfMessages": "忽略自身回聲",
|
||||
"settings.botIgnoreSelfMessagesHint": "避免 gateway 再次處理由已配置 Bot 身份發出的訊息。",
|
||||
"settings.botSelfUserIds": "Bot 自身 ID",
|
||||
"settings.botSelfUserIdsHint": "可選填寫各平台 Bot 自己的 user/open ID,用於過濾回聲訊息。",
|
||||
"settings.botPairing": "私聊配對",
|
||||
"settings.botPairingTTL": "有效分鐘",
|
||||
"settings.botPairingMaxPending": "待批上限",
|
||||
"settings.botControlApi": "本機控制 API",
|
||||
"settings.botControlApiHint": "僅 loopback 的 HTTP API,可查狀態和受控傳送;需要從指定環境變數讀取 Bearer token。",
|
||||
"settings.botControlAddr": "控制地址",
|
||||
"settings.botControlTokenEnv": "Token 變數",
|
||||
"settings.botGatewayDefaults": "閘道器執行設定",
|
||||
"settings.botGatewayDefaultsHint": "不用編輯 TOML,也能配置排隊、配對和自身訊息過濾。",
|
||||
"settings.botRoutes": "聊天綁定到專案",
|
||||
"settings.botRoutesHint": "把某個群組、聯絡人或執行緒固定到指定工作目錄、模型或審批方式。",
|
||||
"settings.botRoutesEmpty": "還沒有綁定規則。新增一條規則,把某個聊天、使用者或執行緒綁定到專案或模型。",
|
||||
"settings.botRouteTitle": "路由 {n}",
|
||||
"settings.botAddRoute": "新增路由",
|
||||
"settings.botRouteConnection": "來自哪個 Bot",
|
||||
"settings.botRouteAny": "任意",
|
||||
"settings.botRoutePlatform": "平台",
|
||||
"settings.botRouteChatType": "聊天類型",
|
||||
"settings.botRouteChatType.any": "任意",
|
||||
"settings.botRouteChatType.dm": "私聊",
|
||||
"settings.botRouteChatType.group": "群聊",
|
||||
"settings.botRouteChatType.guild": "頻道",
|
||||
"settings.botRouteChatType.direct": "Direct",
|
||||
"settings.botRouteChatType.thread": "Thread",
|
||||
"settings.botRouteChatId": "群組/聊天 ID",
|
||||
"settings.botRouteUserId": "聯絡人 ID",
|
||||
"settings.botRouteThreadId": "執行緒 ID",
|
||||
"settings.botChannels": "連線頻道",
|
||||
"settings.botChannelsHint": "選擇頻道;QQ 手動配置,飛書、Lark 和微信掃碼連線。",
|
||||
"settings.botQQHint": "QQ 官方 Bot API v2。",
|
||||
@@ -1488,8 +1777,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.botInstallLarkHint": "掃碼建立 Lark PersonalAgent。",
|
||||
"settings.botInstallWeixinHint": "微信掃碼登入並儲存本地 token。",
|
||||
"settings.botConnectedBots": "已連線 Bot",
|
||||
"settings.botConnectedBotsSummary": "{online}/{total} 線上",
|
||||
"settings.botConnectionsEmpty": "還沒有連線的機器人頻道。",
|
||||
"settings.botSelectBotHint": "選擇左側 Bot 進入配置。",
|
||||
"settings.botConnectionColumnChannel": "頻道",
|
||||
"settings.botConnectionColumnName": "機器人",
|
||||
"settings.botConnectionColumnRemote": "遠端 ID",
|
||||
@@ -1507,16 +1795,17 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.botConnectionConnected": "已連線",
|
||||
"settings.botConnectionConfigured": "已配置",
|
||||
"settings.botConnectionDisconnected": "未連線",
|
||||
"settings.botManage": "高階設定",
|
||||
"settings.botManage": "修改配置",
|
||||
"settings.deleteBot": "刪除機器人",
|
||||
"settings.deleteBotShort": "刪除",
|
||||
"settings.confirmDeleteBot": "確認刪除",
|
||||
"settings.deleteBotHint": "從已連線 Bot 中移除這個機器人;不會自動清除已儲存的金鑰。",
|
||||
"settings.botChannelModel": "模型",
|
||||
"settings.botChannelModelHint": "留空時使用 Bot 預設模型。",
|
||||
"settings.botChannelModelAuto": "自動",
|
||||
"settings.botChannelModelHint": "這個機器人處理訊息時使用的模型。",
|
||||
"settings.botChannelModelAuto": "使用桌面目前模型",
|
||||
"settings.botToolApprovalMode": "工具審批",
|
||||
"settings.botToolApprovalModeHint": "控制該頻道是否在工具執行前請求確認;計劃確認和 ask 問題仍會等待回覆。",
|
||||
"settings.botToolApprovalMode.inherit": "跟隨預設",
|
||||
"settings.botToolApprovalMode.inherit": "跟隨機器人",
|
||||
"settings.botToolApprovalMode.ask": "詢問",
|
||||
"settings.botToolApprovalMode.auto": "自動",
|
||||
"settings.botToolApprovalMode.yolo": "YOLO",
|
||||
@@ -1539,29 +1828,20 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.botDiagnosticReportFailed": "傳送診斷報告失敗。",
|
||||
"settings.botDiagnosticPrivacy": "僅包含已脫敏的連線元資訊。",
|
||||
"settings.botAccessControl": "存取控制",
|
||||
"settings.botAccessControlHint": "預設使用白名單;臨時測試可以改為允許所有人。",
|
||||
"settings.botAccessMode": "誰可以使用",
|
||||
"settings.botAccessAll": "所有人",
|
||||
"settings.botAccessWhitelist": "僅白名單",
|
||||
"settings.botEditAllowlist": "編輯白名單",
|
||||
"settings.botAllowlist": "白名單",
|
||||
"settings.botAllowlistHint": "限制哪些使用者或群可以呼叫機器人。",
|
||||
"settings.botAllowlistEnabled": "啟用白名單",
|
||||
"settings.botAllowAll": "允許所有人",
|
||||
"settings.botAllowAllWarn": "開啟後所有進入頻道的訊息都可以呼叫機器人。",
|
||||
"settings.botQQAccessRequired": "啟用 QQ Bot 前,請先在高階設定裡新增 QQ 使用者/群白名單,或臨時改為允許所有人。",
|
||||
"settings.botAllowlistEntries": "使用者與群",
|
||||
"settings.botQQAccessRequired": "啟用 QQ Bot 前,請先在第 2 步「誰可以使用」裡新增 QQ 使用者/群組,或臨時改為所有人可用。",
|
||||
"settings.botQQUsers": "QQ 使用者",
|
||||
"settings.botQQGroups": "QQ群",
|
||||
"settings.botFeishuUsers": "飛書使用者",
|
||||
"settings.botFeishuGroups": "飛書群",
|
||||
"settings.botFeishuLarkUsers": "飛書 / Lark 使用者",
|
||||
"settings.botFeishuLarkGroups": "飛書 / Lark 群",
|
||||
"settings.botWeixinUsers": "微信使用者",
|
||||
"settings.botWeixinGroups": "微信群",
|
||||
"settings.botListPlaceholder": "每行一個 ID,也可用逗號分隔",
|
||||
"settings.shellInterpreter": "Shell 直譯器",
|
||||
"settings.shellAuto": "自動(優先 bash,Windows 無 bash 時用 PowerShell)",
|
||||
"settings.shellAuto": "自動(bash 優先)",
|
||||
"settings.shellBash": "bash",
|
||||
"settings.shellPowershell": "PowerShell(Windows PowerShell)",
|
||||
"settings.shellPwsh": "pwsh(PowerShell 7+)",
|
||||
@@ -1711,21 +1991,29 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.telemetryLabel": "匿名啟動統計",
|
||||
"settings.telemetryHint": "啟動時傳送隨機安裝 ID、版本號和作業系統用於統計活躍安裝量;若上次執行發生崩潰,則在本次啟動補發該崩潰報告。絕不包含對話、金鑰或檔案內容。",
|
||||
"settings.metricsLabel": "共享聚合品質指標",
|
||||
"settings.metricsHint": "預設開啟。傳送匿名的輪次結束統計、設定偏好快照,以及 Memory v5 聚合桶,例如是否注入、大小桶、IR 數量桶和記憶圖規模桶。包含用於 DAU 去重的隨機安裝 ID。Bucket 可能包含正規化後的自訂 Provider 名和模型名——絕不包含對話、提示詞、金鑰、路徑、base URL、記憶正文、工具輸出或檔案內容。",
|
||||
"settings.metricsHint": "預設開啟。傳送匿名的輪次結束統計、更新器錯誤類別、設定偏好快照,以及 Memory v5 聚合桶,例如是否注入、大小桶、IR 數量桶和記憶圖規模桶。包含用於 DAU 去重的隨機安裝 ID。Bucket 可能包含正規化後的自訂 Provider 名和模型名——絕不包含對話、提示詞、金鑰、路徑、base URL、記憶正文、工具輸出或檔案內容。",
|
||||
"context.windowTitle": "上下文視窗",
|
||||
"context.windowSubtitle": "當前上下文視窗佔用",
|
||||
"context.windowStatusHealthy": "上下文充足",
|
||||
"context.windowStatusWatch": "即將壓縮",
|
||||
"context.windowStatusPastCompact": "已到壓縮閾值",
|
||||
"context.windowStatusNearLimit": "接近視窗上限",
|
||||
"context.windowUsageSummary": "{pct}% 已用",
|
||||
"context.windowCompactRemaining": "{used} / {window} · 距壓縮還剩 {tokens}",
|
||||
"context.windowCompactDistance": "距壓縮",
|
||||
"context.windowUsedLabel": "已用",
|
||||
"context.windowUsed": "已用上下文",
|
||||
"context.windowCompactThreshold": "壓縮閾值",
|
||||
"context.windowCompactHeadroom": "壓縮前餘量",
|
||||
"context.sessionMetrics": "會話指標",
|
||||
"context.runtimeMetrics": "執行指標",
|
||||
"context.costMetrics": "成本",
|
||||
"context.sessionStatus": "會話狀態",
|
||||
"context.healthGoodShort": "正常",
|
||||
"context.healthNearLimitShort": "接近上限",
|
||||
"context.healthManyFilesShort": "檔案較多",
|
||||
"context.healthLowCacheShort": "命中偏低",
|
||||
"context.sessionTokens": "會話 tokens",
|
||||
"context.sessionTokens": "會話累計 tokens",
|
||||
"context.sessionTokensShort": "累計 tokens",
|
||||
"context.tokensValue": "{value} tokens",
|
||||
"context.inputCache": "輸入快取",
|
||||
"context.costBreakdown": "費用來源",
|
||||
"context.sourceBreakdown": "模型用量明細",
|
||||
"context.sourceShareTokens": "按 tokens 佔比",
|
||||
"context.sourceBreakdown": "用量來源",
|
||||
"context.sourceShareTitle": "來源佔比",
|
||||
"context.sourceInput": "輸入",
|
||||
"context.sourceOutput": "輸出",
|
||||
"context.sourceCacheHit": "命中",
|
||||
@@ -1741,7 +2029,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"context.sourceClassifier": "計畫分類",
|
||||
"context.sourceTitle": "標題產生",
|
||||
"context.sourceRequests": "{count} 次",
|
||||
"context.compaction": "壓縮狀態",
|
||||
"context.moreSources": "還有 {count} 個來源",
|
||||
"crash.send": "傳送報告",
|
||||
"crash.sending": "傳送中…",
|
||||
"crash.sent": "已傳送,謝謝!",
|
||||
|
||||
@@ -206,6 +206,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"caps.mcpSummary": "{connected} 个已连接 · {failed} 个异常 · {tools} 个工具",
|
||||
"caps.connectorsTab": "MCP 服务器",
|
||||
"caps.skillsTab": "Agent 技能",
|
||||
"caps.pluginsTab": "插件",
|
||||
"caps.servers": "MCP 服务器",
|
||||
"caps.noServers": "未配置 MCP 服务器。用 /mcp add 添加。",
|
||||
"caps.availableServers": "可用 MCP",
|
||||
@@ -312,6 +313,62 @@ export const zh: Record<DictKey, string> = {
|
||||
"caps.trustedReadOnlyTitle": "已记住为之后规划和只读 research 可用的只读工具",
|
||||
"caps.searchSkills": "搜索技能…",
|
||||
"caps.noSkillMatches": "没有匹配的技能。",
|
||||
"caps.pluginRefresh": "刷新插件",
|
||||
"caps.pluginInstallTitle": "安装插件",
|
||||
"caps.pluginInstallHint": "选择本地插件目录,或从 Git 仓库安装。",
|
||||
"caps.pluginInstallMethod": "插件安装方式",
|
||||
"caps.pluginInstallLocal": "本地目录",
|
||||
"caps.pluginInstallGit": "Git 仓库",
|
||||
"caps.pluginSource": "插件来源",
|
||||
"caps.pluginGitSource": "Git 仓库地址",
|
||||
"caps.pluginSourcePlaceholder": "git:github.com/obra/superpowers",
|
||||
"caps.pluginChooseLocalFolder": "选择插件目录",
|
||||
"caps.pluginLocalFolder": "本地插件目录",
|
||||
"caps.pluginNoLocalFolder": "尚未选择目录",
|
||||
"caps.pluginInstallName": "安装名称(可选)",
|
||||
"caps.pluginInstallNamePlaceholder": "安装名称(可选)",
|
||||
"caps.pluginLink": "开发模式:链接源目录",
|
||||
"caps.pluginLinkHint": "不复制插件,直接使用所选目录;适合调试插件。移动或删除该目录会导致插件失效。",
|
||||
"caps.pluginReplace": "覆盖同名插件",
|
||||
"caps.pluginReplaceHint": "插件名已存在时,允许用当前来源替换它。",
|
||||
"caps.pluginPreview": "预检",
|
||||
"caps.pluginInstall": "安装插件",
|
||||
"caps.noPluginsTitle": "尚未安装插件",
|
||||
"caps.noPluginsHint": "从 Git 或本地路径安装插件包,把技能、hooks 和 MCP 服务器作为一个统一单元管理。",
|
||||
"caps.installedPlugins": "已安装插件",
|
||||
"caps.pluginsSummary": "{enabled} 个启用 · 共 {total} 个 · {issues} 个异常",
|
||||
"caps.pluginCounts": "{skills} 技能 · {hooks} hooks · {mcps} MCP",
|
||||
"caps.pluginNoCapabilities": "未导出能力",
|
||||
"caps.pluginWarnings": "{count} 个告警",
|
||||
"caps.pluginEnable": "启用插件",
|
||||
"caps.pluginDisable": "停用插件",
|
||||
"caps.pluginEnabled": "已启用",
|
||||
"caps.pluginDisabled": "已停用",
|
||||
"caps.pluginVersion": "版本",
|
||||
"caps.pluginRoot": "安装路径",
|
||||
"caps.pluginUpdate": "更新",
|
||||
"caps.pluginDoctor": "诊断",
|
||||
"caps.pluginRemove": "移除插件",
|
||||
"caps.pluginConfirmRemove": "确认移除",
|
||||
"caps.pluginPlanError": "安装检查失败",
|
||||
"caps.pluginPlanReady": "安装计划",
|
||||
"caps.pluginAction": "操作",
|
||||
"caps.pluginPlanStatus": "插件操作:{status}",
|
||||
"caps.pluginPlanComplete": "插件操作完成",
|
||||
"caps.pluginPlanInstalled": "插件已安装。展开下方插件,可查看它导出的 skills、hooks 和 MCP 服务器的使用方式。",
|
||||
"caps.pluginUsageTitle": "使用方法",
|
||||
"caps.pluginUsageEnabledHint": "启用的插件会加载到新会话中。可用 /skills 查看技能、用 /<skill> 直接调用,或自然描述任务;hooks 和 MCP 服务器会按正常会话流程自动参与。",
|
||||
"caps.pluginUsageDisabledHint": "启用此插件后,它的 skills、hooks 或 MCP 服务器才会参与会话。",
|
||||
"caps.pluginNoCapabilityDetails": "此插件暂无可展示的能力明细。",
|
||||
"caps.pluginSkillsTitle": "Skills",
|
||||
"caps.pluginSkillsHint": "用 /skills 浏览,直接调用,或在对话中说出工作流名称。",
|
||||
"caps.pluginHooksTitle": "Hooks",
|
||||
"caps.pluginHooksHint": "Hooks 会在匹配的生命周期事件中自动触发。",
|
||||
"caps.pluginMCPTitle": "MCP 服务器",
|
||||
"caps.pluginMCPHint": "这些服务器提供的工具会通过正常 MCP/工具流程可用。",
|
||||
"caps.pluginNoDescription": "无描述",
|
||||
"caps.pluginHookNoTarget": "未指定 hook 目标",
|
||||
"caps.pluginMCPNoTarget": "未指定启动目标",
|
||||
"caps.sources": "来源",
|
||||
"caps.sourcesSummaryActive": "{active} 个有效",
|
||||
"caps.sourcesSummaryMissing": "{missing} 个缺失",
|
||||
@@ -412,6 +469,17 @@ export const zh: Record<DictKey, string> = {
|
||||
"composer.removeCurrentProjectDisabled": "不能移除当前项目",
|
||||
"composer.resize": "拖动调整输入区高度,双击重置",
|
||||
"composer.send": "发送(Enter)",
|
||||
"composer.queueGuidance": "加入引导队列(Enter)",
|
||||
"composer.steer": "追加到当前任务(Enter)",
|
||||
"composer.steerPlaceholder": "输入要加入队列的引导…",
|
||||
"composer.guidanceQueue": "待处理引导",
|
||||
"composer.guidanceCount": "待处理引导 {n}",
|
||||
"composer.guidanceRemaining": "还有 {n} 条",
|
||||
"composer.guidanceCollapse": "收起",
|
||||
"composer.guidanceMode": "引导",
|
||||
"composer.guidanceSend": "将这条引导加入信息流",
|
||||
"composer.guidanceDismiss": "移除这条引导提示",
|
||||
"composer.guidanceMore": "更多引导选项",
|
||||
"composer.shellMode": "Shell 模式:给输入前加 !",
|
||||
"composer.shellModeOn": "Shell 模式已开:点击移除 !",
|
||||
"composer.stop": "停止(Esc)",
|
||||
@@ -422,6 +490,9 @@ export const zh: Record<DictKey, string> = {
|
||||
"composer.pastedExpand": "展开",
|
||||
"composer.pastedRemove": "删除粘贴文本",
|
||||
"composer.removeImage": "移除图片",
|
||||
"imageViewer.clickToPreview": "点击查看大图",
|
||||
"imageViewer.closePreview": "关闭预览",
|
||||
"imageViewer.title": "图片预览",
|
||||
"composer.attachImageFailed": "图片粘贴失败",
|
||||
"composer.attachFileFailed": "文件附加失败",
|
||||
"composer.attachDropFailed": "拖放文件附加失败",
|
||||
@@ -546,6 +617,11 @@ export const zh: Record<DictKey, string> = {
|
||||
"heartbeat.approvalModeAskHint": "每次工具调用都需要手动确认",
|
||||
"heartbeat.approvalModeAutoHint": "自动批准安全工具,保留拒绝规则",
|
||||
"heartbeat.approvalModeYoloHint": "跳过所有工具权限提示,任务自动运行",
|
||||
"heartbeat.notifyChannels": "推送到机器人",
|
||||
"heartbeat.notifyChannelsOn": "开启",
|
||||
"heartbeat.notifyChannelsOff": "关闭",
|
||||
"heartbeat.notifyChannelsOnHint": "AI 输出会实时推送到已连接的飞书/QQ 机器人",
|
||||
"heartbeat.notifyChannelsOffHint": "只创建桌面对话,不推送到机器人",
|
||||
"heartbeat.fieldNewConversation": "对话模式",
|
||||
"heartbeat.newConversationEachRunOn": "每次执行新建对话",
|
||||
"heartbeat.newConversationEachRunOff": "复用同一对话",
|
||||
@@ -590,6 +666,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"ask.hideDetails": "收起",
|
||||
"ask.back": "返回",
|
||||
"ask.next": "下一步",
|
||||
"ask.customAnswer": "其它答案",
|
||||
"ask.customPlaceholder": "输入你自己的答案…",
|
||||
"ask.justChat": "只是聊聊",
|
||||
|
||||
@@ -655,6 +732,18 @@ export const zh: Record<DictKey, string> = {
|
||||
"history.historySessionActions": "历史会话操作",
|
||||
"history.trashActions": "回收站操作",
|
||||
|
||||
// 会话恢复
|
||||
"recovery.branch": "恢复分支",
|
||||
"recovery.badge": "恢复",
|
||||
"recovery.open": "打开恢复分支",
|
||||
"recovery.toast": "未保存的本地内容已保存为 {title}。",
|
||||
"recovery.failedLease": "恢复分支被另一个运行时占用,未切换过去。",
|
||||
"recovery.failedUnavailable": "无法打开恢复分支,当前标签仍停留在原会话。",
|
||||
"recovery.banner": "当前会话来自保存冲突恢复。继续前建议与原会话对比。",
|
||||
"recovery.reason": "原因:{reason}",
|
||||
"recovery.digest": "摘要:{digest}",
|
||||
"recovery.parent": "父会话:{parent}",
|
||||
|
||||
// 项目树
|
||||
"projectTree.workspaceTitle": "项目",
|
||||
"projectTree.pinnedTitle": "置顶",
|
||||
@@ -829,6 +918,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.tab.capabilities": "MCP 与技能",
|
||||
"settings.tab.mcp": "MCP 与工具",
|
||||
"settings.tab.skills": "技能",
|
||||
"settings.tab.plugins": "插件",
|
||||
"settings.tab.memory": "记忆",
|
||||
"settings.tab.hooks": "Hooks",
|
||||
"settings.tab.shortcuts": "快捷键",
|
||||
@@ -844,6 +934,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.tabSub.capabilities": "服务器、工具与技能",
|
||||
"settings.tabSub.mcp": "服务器与工具",
|
||||
"settings.tabSub.skills": "来源与启用",
|
||||
"settings.tabSub.plugins": "包与诊断",
|
||||
"settings.tabSub.memory": "项目与全局",
|
||||
"settings.tabSub.hooks": "Shell 自动化",
|
||||
"settings.tabSub.shortcuts": "按键与帮助",
|
||||
@@ -925,6 +1016,10 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.subagentEffort": "子代理 effort",
|
||||
"settings.subagentEffortDefault": "auto(模型服务默认)",
|
||||
"settings.subagentHint": "作用于 task 和 runAs=subagent skills;工具调用参数或单个 skill 配置会覆盖它。",
|
||||
"settings.subagentDepth": "子代理嵌套深度",
|
||||
"settings.subagentDepthHint": "控制第一层子代理能否再派发一层 reviewer 或 worker 子代理。",
|
||||
"settings.subagentDepthOne": "1 层",
|
||||
"settings.subagentDepthTwo": "2 层",
|
||||
"settings.defaultToolApprovalMode": "新会话默认审批",
|
||||
"settings.defaultToolApprovalModeHint": "保存在全局设置中,仅用于新建会话;切换或恢复已有会话时不会覆盖其 Ask / Auto / Yolo 状态。",
|
||||
"settings.defaultToolApprovalMode.ask": "询问",
|
||||
@@ -947,7 +1042,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.coldResumePrune.on": "开",
|
||||
"settings.coldResumePrune.off": "关",
|
||||
"settings.reasoningLanguage": "思考语言",
|
||||
"settings.reasoningLanguageHint": "只影响可见思考过程。自动跟随对话语言,不额外注入语言指令。",
|
||||
"settings.reasoningLanguageHint": "只影响可见思考过程。自动在原始提问明显为中文时锚定中文,引用内容不参与判断。",
|
||||
"settings.reasoningLanguage.auto": "自动",
|
||||
"settings.reasoningLanguage.zh": "中文",
|
||||
"settings.reasoningLanguage.en": "English",
|
||||
@@ -1008,7 +1103,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.providerModelsUrlPlaceholder": "例如 https://api.openai.com/v1/models",
|
||||
"settings.providerModelsUrlHint": "可选。模型发现需要使用不同于聊天请求的地址时填写。",
|
||||
"settings.providerModels": "模型(逗号分隔)",
|
||||
"settings.providerApiKeyEnv": "api_key_env(如 DEEPSEEK_API_KEY)",
|
||||
"settings.providerApiKeyEnv": "api_key_env(可以不设置)",
|
||||
"settings.providerBalanceUrl": "余额查询 URL(可选)",
|
||||
"settings.balanceUrlPlaceholder": "余额查询接口(如 https://api.deepseek.com/user/balance)",
|
||||
"settings.balanceUrlHint": "可选。填了就会查询并在状态栏显示钱包余额。",
|
||||
@@ -1018,12 +1113,22 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.providerReasoningProtocol": "reasoning protocol",
|
||||
"settings.providerReasoningProtocolAuto": "auto(按模型和端点自动判断)",
|
||||
"settings.providerReasoningProtocolHint": "用于 OpenAI-compatible 推理模型。deepseek 使用 DeepSeek reasoning 参数,openai 使用普通 reasoning_effort,none 表示禁用。",
|
||||
"settings.effortBehavior": "effort 行为",
|
||||
"settings.effortUseProtocolDefaults": "协议默认",
|
||||
"settings.effortUseCustom": "自定义覆盖",
|
||||
"settings.effortProtocolDefault.auto": "协议默认会把 supported_efforts 保存为空。Reasonix 会把已知 DeepSeek 模型/端点识别为 high|max,把 OpenAI reasoning 识别为 low|medium|high,不支持的服务不暴露 /effort。",
|
||||
"settings.effortProtocolDefault.deepseek": "DeepSeek 默认暴露 auto|high|max。Reasonix 会保持 thinking 开启,并用 reasoning_effort 控制深度。",
|
||||
"settings.effortProtocolDefault.openai": "OpenAI reasoning 默认暴露 auto|low|medium|high,并发送普通 reasoning_effort。",
|
||||
"settings.effortProtocolDefault.minimax": "MiniMax 风格端点使用 adaptive|disabled 这类服务商自定义 thinking 等级。只有端点文档明确支持时才添加自定义等级。",
|
||||
"settings.effortProtocolDefault.none": "该供应商已禁用 effort;Reasonix 不会发送 reasoning_effort 或 thinking 控制参数。",
|
||||
"settings.supportedEfforts": "支持的 effort 等级",
|
||||
"settings.supportedEffortsCustomPlaceholder": "自定义等级(按回车)",
|
||||
"settings.supportedEffortsHint": "勾选该模型服务在 /effort 命令中暴露的等级,或添加自定义名称。留空则使用内置默认(DeepSeek:high|max,Anthropic:low|medium|high|xhigh|max)。",
|
||||
"settings.supportedEffortsNoPresets": "没有标准预设",
|
||||
"settings.supportedEffortsNoPresetsHint": "仅在端点文档明确给出 effort 名称时添加自定义等级。",
|
||||
"settings.supportedEffortsHint": "自定义等级会覆盖协议默认。只选择端点文档支持的等级;不兼容组合可能导致模型请求失败。",
|
||||
"settings.defaultEffort": "默认 effort",
|
||||
"settings.defaultEffortAuto": "auto(使用内置默认)",
|
||||
"settings.defaultEffortHint": "在 /effort 为 auto 或未设置时使用。必须是上方等级之一;为空时回退到第一个等级(无选中时回退到内置默认)。",
|
||||
"settings.defaultEffortAuto": "未设置(使用协议/自定义默认)",
|
||||
"settings.defaultEffortHint": "在 /effort 为 auto 或未设置时使用。留空则使用协议默认;自定义等级时回退到第一个自定义等级。",
|
||||
"settings.proxyMode": "代理模式",
|
||||
"settings.proxyMode.auto": "自动",
|
||||
"settings.proxyMode.env": "环境变量",
|
||||
@@ -1044,11 +1149,76 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.toggleOff": "关闭",
|
||||
"settings.botSetup": "机器人入口",
|
||||
"settings.botSetupHint": "先开启入口,再选择要连接的聊天渠道。",
|
||||
"settings.botConnectPhoneTitle": "添加 IM Bot",
|
||||
"settings.botConnectPhoneSubtitle": "QQ 手动配置,飞书、Lark 和微信扫码接入。",
|
||||
"settings.botGateway": "机器人网关",
|
||||
"settings.botGatewayHint": "桌面端统一管理 IM bot 的总开关、模型和运行参数。",
|
||||
"settings.botGatewayHint": "桌面端统一管理 IM bot 的总开关和连接状态。",
|
||||
"settings.botBrowserPreviewWarning": "当前是浏览器预览,只会保存 mock 设置,不会写入本机配置或启动 QQ/IM Bot gateway。请在桌面应用或 CLI 中配置后再到 QQ 客户端测试。",
|
||||
"settings.botSimpleEyebrow": "傻瓜式配置",
|
||||
"settings.botSimpleTitle": "移动端与聊天机器人",
|
||||
"settings.botSimpleHint": "先用手机扫码远程控制当前工作区;需要长期从聊天工具访问时,再选择一个 Bot Channel 配置。",
|
||||
"settings.botMobileRemoteEyebrow": "独立入口",
|
||||
"settings.botMobileRemoteTitle": "手机远程控制",
|
||||
"settings.botMobileRemoteHint": "扫码或复制链接,从移动端打开当前工作区的控制入口;它和 IM Bot 渠道管理分开配置。",
|
||||
"settings.botMobileRemoteControlApi": "控制链接",
|
||||
"settings.botMobileRemoteDisabled": "开启后生成二维码和链接",
|
||||
"settings.botMobileRemoteEnableFirst": "开启后生成二维码",
|
||||
"settings.botMobileRemoteReady": "等待手机连接",
|
||||
"settings.botMobileRemoteWaiting": "手机扫码连接",
|
||||
"settings.botMobileRemoteLoopbackNotice": "当前控制 API 仍是本机 loopback,只适合本机预览;跨设备 relay 接入后会沿用这个入口。",
|
||||
"settings.botMobileRemoteCopyLink": "复制链接",
|
||||
"settings.botMobileRemoteCopied": "已复制",
|
||||
"settings.botMobileRemoteCopyFailed": "复制失败",
|
||||
"settings.botChannelEntryTitle": "使用 Bot Channel",
|
||||
"settings.botChannelEntryHint": "连接聊天 Bot,适合更长时间的移动端访问。",
|
||||
"settings.botManageBots": "机器人管理",
|
||||
"settings.botManageBotsHint": "左侧选择渠道;未配置时扫码或填写密钥,配置后在右侧管理这个机器人的运行设置。",
|
||||
"settings.botBackToOverview": "返回入口",
|
||||
"settings.botChannelOpenConfigured": "已配置,可继续修改设置。",
|
||||
"settings.botChannelNeedsSetup": "未配置",
|
||||
"settings.botChannelTabsLabel": "机器人渠道",
|
||||
"settings.botChannelSetupTitle": "配置 {provider}",
|
||||
"settings.botChannelSetupHint": "未配置前只需要完成凭据或扫码绑定;成功后会自动切换为具体设置。",
|
||||
"settings.botSetupStatus": "机器人设置状态",
|
||||
"settings.botSetupStatusConnected": "{online}/{total} 在线",
|
||||
"settings.botSetupStatusAccessTrusted": "受信任名单 {count}",
|
||||
"settings.botSetupStatusAccessEveryone": "所有人可用",
|
||||
"settings.botSetupStatusAccessPerBot": "{configured}/{total} 个 Bot 已配置",
|
||||
"settings.botMode": "机器人设置模式",
|
||||
"settings.botModeSimple": "普通",
|
||||
"settings.botModeAdvanced": "高级",
|
||||
"settings.botShowAdvancedSettings": "高级设置",
|
||||
"settings.botHideAdvancedSettings": "收起高级设置",
|
||||
"settings.botAdvancedSettingsHint": "低频能力:路由、队列细项和自身消息过滤。",
|
||||
"settings.botShowAddBot": "新增渠道",
|
||||
"settings.botStepConnectTitle": "连接机器人",
|
||||
"settings.botStepConnectHint": "QQ 手动填密钥;飞书、Lark、微信扫码即可接入。",
|
||||
"settings.botStepConnectEmpty": "还没有连接",
|
||||
"settings.botSharedAllowlistHint": "所有渠道共用第 2 步「谁可以使用」的名单与配对设置,改一处即可全部生效。",
|
||||
"settings.botSharedAllowlistGo": "前往第 2 步编辑",
|
||||
"settings.botGlobalAllowlist": "旧全局白名单",
|
||||
"settings.botGlobalAllowlistHint": "兼容旧配置;新 Bot 推荐在各自详情里设置谁可以使用。",
|
||||
"settings.botAccessShowAllPlatforms": "显示全部平台",
|
||||
"settings.botAccessShowConnectedOnly": "只看已连接平台",
|
||||
"settings.botListUsers": "用户",
|
||||
"settings.botListGroups": "群",
|
||||
"settings.botListApprovers": "审批人",
|
||||
"settings.botListAdmins": "管理员",
|
||||
"settings.botPlatformFeishuLark": "飞书 / Lark",
|
||||
"settings.botQueueDropLabel": "队列满时",
|
||||
"settings.botQueueDropHint": "队列达到上限后,如何处理放不下的消息。",
|
||||
"settings.botPairingDetailHint": "配对开关在第 2 步「谁可以使用」里;这里只调整配对码有效期和待批数量。",
|
||||
"settings.botSimpleAccessTitle": "谁可以使用",
|
||||
"settings.botSimpleAccessTrustedSummary": "当前使用受信任名单,已填写 {count} 条成员或群。",
|
||||
"settings.botSimpleAccessEveryoneSummary": "当前允许所有人使用,适合短时间测试。",
|
||||
"settings.botAccessTrusted": "受信任名单",
|
||||
"settings.botAccessTrustedHint": "只允许填写过的用户或群调用机器人。",
|
||||
"settings.botAccessEveryone": "所有人可用",
|
||||
"settings.botAccessEveryoneHint": "临时测试方便,但不建议长期打开。",
|
||||
"settings.botAccessPairing": "私聊先配对",
|
||||
"settings.botAccessPairingHint": "陌生私聊先发配对码,本机批准后再放行。",
|
||||
"settings.botRoleAccess": "管理员与审批人",
|
||||
"settings.botRoleAccessHint": "可选。普通用户不用填;需要远程审批或管理时再配置。",
|
||||
"settings.botQueueModeSimple": "新消息怎么处理",
|
||||
"settings.botQueueModeSimpleHint": "当机器人还在处理上一条消息时,选择下一条消息的处理方式。",
|
||||
"settings.saveBotSettings": "保存机器人设置",
|
||||
"settings.botEnableBot": "启用机器人",
|
||||
"settings.botGatewayMasterSwitch": "总开关",
|
||||
@@ -1059,8 +1229,48 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.botModelHint": "留空时使用默认模型。",
|
||||
"settings.botModelDefault": "使用默认模型",
|
||||
"settings.botRuntime": "运行限制",
|
||||
"settings.botRuntimeHint": "网关进程级限制,控制每次机器人任务最多走多远。",
|
||||
"settings.botMaxSteps": "最大步数",
|
||||
"settings.botDebounceMs": "合并窗口 ms",
|
||||
"settings.botQueueCap": "队列上限",
|
||||
"settings.botQueueMode.steer": "引导当前回合",
|
||||
"settings.botQueueMode.followup": "追加跟进",
|
||||
"settings.botQueueMode.collect": "收集等待",
|
||||
"settings.botQueueMode.interrupt": "打断当前",
|
||||
"settings.botQueueDrop.summarize": "汇总溢出",
|
||||
"settings.botQueueDrop.old": "丢弃最旧",
|
||||
"settings.botQueueDrop.new": "丢弃最新",
|
||||
"settings.botIgnoreSelfMessages": "忽略自身回声",
|
||||
"settings.botIgnoreSelfMessagesHint": "避免 gateway 再次处理由已配置 Bot 身份发出的消息。",
|
||||
"settings.botSelfUserIds": "Bot 自身 ID",
|
||||
"settings.botSelfUserIdsHint": "可选填写各平台 Bot 自己的 user/open ID,用于过滤回声消息。",
|
||||
"settings.botPairing": "私聊配对",
|
||||
"settings.botPairingTTL": "有效分钟",
|
||||
"settings.botPairingMaxPending": "待批上限",
|
||||
"settings.botControlApi": "本机控制 API",
|
||||
"settings.botControlApiHint": "仅 loopback 的 HTTP API,可查状态和受控发送;需要从指定环境变量读取 Bearer token。",
|
||||
"settings.botControlAddr": "控制地址",
|
||||
"settings.botControlTokenEnv": "Token 变量",
|
||||
"settings.botGatewayDefaults": "网关运行设置",
|
||||
"settings.botGatewayDefaultsHint": "不用编辑 TOML,也能配置排队、配对和自身消息过滤。",
|
||||
"settings.botRoutes": "聊天绑定到项目",
|
||||
"settings.botRoutesHint": "把某个群、联系人或线程固定到指定工作目录、模型或审批方式。",
|
||||
"settings.botRoutesEmpty": "还没有绑定规则。添加一条规则,把某个聊天、用户或线程绑定到项目或模型。",
|
||||
"settings.botRouteTitle": "路由 {n}",
|
||||
"settings.botAddRoute": "添加路由",
|
||||
"settings.botRouteConnection": "来自哪个 Bot",
|
||||
"settings.botRouteAny": "任意",
|
||||
"settings.botRoutePlatform": "平台",
|
||||
"settings.botRouteChatType": "聊天类型",
|
||||
"settings.botRouteChatType.any": "任意",
|
||||
"settings.botRouteChatType.dm": "私聊",
|
||||
"settings.botRouteChatType.group": "群聊",
|
||||
"settings.botRouteChatType.guild": "频道",
|
||||
"settings.botRouteChatType.direct": "Direct",
|
||||
"settings.botRouteChatType.thread": "Thread",
|
||||
"settings.botRouteChatId": "群/聊天 ID",
|
||||
"settings.botRouteUserId": "联系人 ID",
|
||||
"settings.botRouteThreadId": "线程 ID",
|
||||
"settings.botChannels": "连接渠道",
|
||||
"settings.botChannelsHint": "选择渠道;QQ 手动配置,飞书、Lark 和微信扫码连接。",
|
||||
"settings.botQQHint": "QQ 官方 Bot API v2。",
|
||||
@@ -1115,8 +1325,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.botInstallLarkHint": "扫码创建 Lark PersonalAgent。",
|
||||
"settings.botInstallWeixinHint": "微信扫码登录并保存本地 token。",
|
||||
"settings.botConnectedBots": "已连接 Bot",
|
||||
"settings.botConnectedBotsSummary": "{online}/{total} 在线",
|
||||
"settings.botConnectionsEmpty": "还没有连接的机器人渠道。",
|
||||
"settings.botSelectBotHint": "选择左侧 Bot 进入配置。",
|
||||
"settings.botConnectionColumnChannel": "渠道",
|
||||
"settings.botConnectionColumnName": "机器人",
|
||||
"settings.botConnectionColumnRemote": "远端 ID",
|
||||
@@ -1134,16 +1343,17 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.botConnectionConnected": "已连接",
|
||||
"settings.botConnectionConfigured": "已配置",
|
||||
"settings.botConnectionDisconnected": "未连接",
|
||||
"settings.botManage": "高级设置",
|
||||
"settings.botManage": "修改配置",
|
||||
"settings.deleteBot": "删除机器人",
|
||||
"settings.deleteBotShort": "删除",
|
||||
"settings.confirmDeleteBot": "确认删除",
|
||||
"settings.deleteBotHint": "从已连接 Bot 中移除这个机器人;不会自动清除已保存的密钥。",
|
||||
"settings.botChannelModel": "模型",
|
||||
"settings.botChannelModelHint": "留空时使用 Bot 默认模型。",
|
||||
"settings.botChannelModelAuto": "自动",
|
||||
"settings.botChannelModelHint": "这个机器人处理消息时使用的模型。",
|
||||
"settings.botChannelModelAuto": "使用桌面当前模型",
|
||||
"settings.botToolApprovalMode": "工具审批",
|
||||
"settings.botToolApprovalModeHint": "控制该渠道是否在工具执行前请求确认;计划确认和 ask 问题仍会等待回复。",
|
||||
"settings.botToolApprovalMode.inherit": "跟随默认",
|
||||
"settings.botToolApprovalMode.inherit": "跟随机器人",
|
||||
"settings.botToolApprovalMode.ask": "询问",
|
||||
"settings.botToolApprovalMode.auto": "自动",
|
||||
"settings.botToolApprovalMode.yolo": "YOLO",
|
||||
@@ -1166,26 +1376,17 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.botDiagnosticReportFailed": "发送诊断报告失败。",
|
||||
"settings.botDiagnosticPrivacy": "仅包含已脱敏的连接元信息。",
|
||||
"settings.botAccessControl": "访问控制",
|
||||
"settings.botAccessControlHint": "默认使用白名单;临时测试可以改为允许所有人。",
|
||||
"settings.botAccessMode": "谁可以使用",
|
||||
"settings.botAccessAll": "所有人",
|
||||
"settings.botAccessWhitelist": "仅白名单",
|
||||
"settings.botEditAllowlist": "编辑白名单",
|
||||
"settings.botAllowlist": "白名单",
|
||||
"settings.botAllowlistHint": "限制哪些用户或群可以调用机器人。",
|
||||
"settings.botAllowlistEnabled": "启用白名单",
|
||||
"settings.botAllowAll": "允许所有人",
|
||||
"settings.botAllowAllWarn": "开启后所有进入渠道的消息都可以调用机器人。",
|
||||
"settings.botQQAccessRequired": "启用 QQ Bot 前,请先在高级设置里添加 QQ 用户/群白名单,或临时改为允许所有人。",
|
||||
"settings.botAllowlistEntries": "用户与群",
|
||||
"settings.botQQAccessRequired": "启用 QQ Bot 前,请先在第 2 步「谁可以使用」里添加 QQ 用户/群,或临时改为所有人可用。",
|
||||
"settings.botQQUsers": "QQ 用户",
|
||||
"settings.botQQGroups": "QQ群",
|
||||
"settings.botFeishuUsers": "飞书用户",
|
||||
"settings.botFeishuGroups": "飞书群",
|
||||
"settings.botFeishuLarkUsers": "飞书 / Lark 用户",
|
||||
"settings.botFeishuLarkGroups": "飞书 / Lark 群",
|
||||
"settings.botWeixinUsers": "微信用户",
|
||||
"settings.botWeixinGroups": "微信群",
|
||||
"settings.botListPlaceholder": "每行一个 ID,也可用逗号分隔",
|
||||
"settings.permissions": "权限",
|
||||
"settings.writerMode": "写操作模式",
|
||||
@@ -1197,8 +1398,11 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.yoloHint": "Yolo批准会跳过所有工具权限审批(写文件、bash)。deny 规则仍会拦截,ask 问题和计划确认仍会等待你选择。仅运行时生效,重启后失效。",
|
||||
"settings.addRule": "添加 {list} 规则…",
|
||||
"settings.sandboxTitle": "沙箱与工作区",
|
||||
"settings.sandboxBoundaryHint": "沙箱可写根只限制已批准的 Bash 和文件工具能写到哪里;它不会自动批准命令。mvn deploy 这类命令还需要权限规则或审批模式放行。",
|
||||
"settings.reloadSessionConfig": "应用手动配置修改",
|
||||
"settings.reloadSessionConfigHint": "设置页内修改会自动生效;此按钮用于应用手动编辑 config.toml / reasonix.toml 后的变更。",
|
||||
"settings.shellInterpreter": "Shell 解释器",
|
||||
"settings.shellAuto": "自动(优先 bash,Windows 无 bash 时用 PowerShell)",
|
||||
"settings.shellAuto": "自动(bash 优先)",
|
||||
"settings.shellBash": "bash",
|
||||
"settings.shellPowershell": "PowerShell(Windows PowerShell)",
|
||||
"settings.shellPwsh": "pwsh(PowerShell 7+)",
|
||||
@@ -1208,6 +1412,9 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.allowNetwork": "允许沙箱内 bash 访问网络",
|
||||
"settings.workspaceRoot": "工作区根目录",
|
||||
"settings.workspaceDefault": "(默认:当前目录)",
|
||||
"settings.effectiveWriteRoots": "实际可写根",
|
||||
"settings.effectiveWriteRootsHint": "按当前工作区展开。设置页内修改会自动生效;手动编辑配置文件后可用上方按钮刷新当前会话。",
|
||||
"settings.noEffectiveWriteRoots": "暂无可写根",
|
||||
"settings.appearance": "外观",
|
||||
"settings.theme": "主题",
|
||||
"settings.themeStyle": "视觉风格",
|
||||
@@ -1315,6 +1522,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.pageDesc.bots": "配置 QQ、飞书、Lark 和微信机器人渠道,管理每个 Bot 的模型和运行参数。",
|
||||
"settings.pageDesc.mcp": "管理 MCP 服务器连接和工具发现。",
|
||||
"settings.pageDesc.skills": "浏览、启用和管理智能体技能与来源。",
|
||||
"settings.pageDesc.plugins": "安装和管理插件包,用统一单元提供技能、hooks 与 MCP 服务器。",
|
||||
"settings.pageDesc.memory": "管理 Reasonix 会长期参考的偏好、项目约定和指令文件。",
|
||||
"settings.pageDesc.hooks": "配置围绕对话、工具调用、压缩和会话生命周期执行的 shell hooks。",
|
||||
"settings.pageDesc.shortcuts": "自定义键盘快捷键,并让帮助表保持同步。",
|
||||
@@ -1329,7 +1537,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.customProviders": "自定义供应商",
|
||||
"settings.customProvidersHint": "你自己添加的 OpenAI-compatible 或代理接入,可以编辑、删除并持久化模型列表。",
|
||||
"settings.customProvidersEmpty": "还没有自定义供应商。常用服务优先使用上方内置接入。",
|
||||
"settings.providerAccessEmptyHint": "点击“添加供应商”,选择 DeepSeek 或自定义 OpenAI-compatible 接入。",
|
||||
"settings.providerAccessEmptyHint": "使用“推荐预设”可快速接入常用供应商,通常只需填写 API Key;使用“自定义供应商”可手动配置 OpenAI-compatible 或 Anthropic-compatible 端点。",
|
||||
"settings.builtinProviderBadge": "内置",
|
||||
"settings.customProviderBadge": "自定义",
|
||||
"settings.configureProvider": "配置",
|
||||
@@ -1341,24 +1549,75 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.searchModels": "搜索模型...",
|
||||
"settings.noMatchingModels": "没有匹配的模型",
|
||||
"settings.addProvider.chooseTitle": "添加供应商",
|
||||
"settings.addProvider.chooseHint": "选择官方预设,或添加一个自定义 OpenAI-compatible 接入。",
|
||||
"settings.addProvider.officialChoice": "官方供应商",
|
||||
"settings.addProvider.officialChoiceHint": "从 Reasonix 预设的 DeepSeek 接入中选择。",
|
||||
"settings.addProvider.chooseHint": "选择推荐预设,或手动添加一个自定义供应商。",
|
||||
"settings.addProvider.officialChoice": "推荐预设",
|
||||
"settings.addProvider.officialChoiceHint": "选择 DeepSeek 或已适配的自定义供应商预设。",
|
||||
"settings.addProvider.customChoice": "自定义供应商",
|
||||
"settings.addProvider.customChoiceHint": "按 base_url、模型、密钥环境变量等参数手动配置。",
|
||||
"settings.addProvider.officialTitle": "添加官方供应商",
|
||||
"settings.addProvider.officialHint": "官方预设会写入固定端点和默认参数,只需要选择供应商并配置密钥。",
|
||||
"settings.addProvider.officialHint": "推荐预设会填好端点、模型、视觉和推理默认值,通常只需要填 API Key。",
|
||||
"settings.addProvider.customTitle": "添加自定义供应商",
|
||||
"settings.addProvider.customHint": "适合代理、聚合平台或其它 OpenAI-compatible 服务。",
|
||||
"settings.addProvider.official.deepseek": "DeepSeek 官方",
|
||||
"settings.addProvider.official.deepseekDesc": "官方 API Key 接入,可用 DeepSeek V4 Flash / Pro。",
|
||||
"settings.addProvider.preset.kimiCnDesc": "Kimi 国内 API,走 Moonshot CN 端点,预置 K2 视觉模型。",
|
||||
"settings.addProvider.preset.kimiGlobalDesc": "Kimi 国际 API,走 Moonshot global 端点,预置 K2 视觉模型。",
|
||||
"settings.addProvider.preset.kimiCodingPlanDesc": "Kimi Coding Plan,走独立 Anthropic-compatible 端点,不归属 CN/Global。",
|
||||
"settings.addProvider.preset.mimoApiDesc": "小米 MiMo 直连 API,适合 v2.5 按量付费模型。",
|
||||
"settings.addProvider.preset.mimoAnthropicDesc": "小米 MiMo 直连 Anthropic-compatible 端点,自动使用 MiMo v2.5 模型。",
|
||||
"settings.addProvider.preset.mimoTokenPlanCnDesc": "小米 MiMo token plan 中国区端点。",
|
||||
"settings.addProvider.preset.mimoTokenPlanCnAnthropicDesc": "小米 MiMo token plan 中国区 Anthropic-compatible 端点。",
|
||||
"settings.addProvider.preset.mimoTokenPlanSgpDesc": "小米 MiMo token plan 新加坡端点。",
|
||||
"settings.addProvider.preset.mimoTokenPlanSgpAnthropicDesc": "小米 MiMo token plan 新加坡 Anthropic-compatible 端点。",
|
||||
"settings.addProvider.preset.mimoTokenPlanAmsDesc": "小米 MiMo token plan 阿姆斯特丹端点。",
|
||||
"settings.addProvider.preset.mimoTokenPlanAmsAnthropicDesc": "小米 MiMo token plan 阿姆斯特丹 Anthropic-compatible 端点。",
|
||||
"settings.addProvider.preset.minimaxCnApiDesc": "MiniMax 国内 OpenAI-compatible M 系列 API 端点。",
|
||||
"settings.addProvider.preset.minimaxGlobalApiDesc": "MiniMax 国际 OpenAI-compatible M 系列 API 端点。",
|
||||
"settings.addProvider.preset.minimaxCnAnthropicDesc": "MiniMax 国内 Anthropic-compatible M 系列端点。",
|
||||
"settings.addProvider.preset.minimaxGlobalAnthropicDesc": "MiniMax 国际 Anthropic-compatible 端点,自动使用 Bearer 认证。",
|
||||
"settings.addProvider.preset.glmCnDesc": "智谱 GLM 国内 API,预置 GLM thinking 控制。",
|
||||
"settings.addProvider.preset.zaiGlobalDesc": "Z.AI 国际 GLM API。",
|
||||
"settings.addProvider.preset.glmCodingPlanCnDesc": "智谱 GLM 国内 coding plan 端点,默认 GLM-5.2。",
|
||||
"settings.addProvider.preset.glmCodingPlanCnAnthropicDesc": "智谱 GLM 国内 Anthropic-compatible coding plan 端点,默认 GLM-5.2[1m]。",
|
||||
"settings.addProvider.preset.zaiCodingPlanGlobalDesc": "Z.AI 国际 coding plan 端点,默认 GLM-5.2。",
|
||||
"settings.addProvider.preset.zaiCodingPlanGlobalAnthropicDesc": "Z.AI 国际 Anthropic-compatible coding plan 端点,默认 GLM-5.2[1m]。",
|
||||
"settings.addProvider.preset.opencodeGoDesc": "OpenCode Go 混合模型网关,内置每模型能力覆盖。",
|
||||
"settings.addProvider.preset.opencodeGoAnthropicDesc": "OpenCode Go Anthropic 线路,预置 Qwen 与 MiniMax 模型。",
|
||||
"settings.addProvider.preset.opencodeZenAnthropicDesc": "OpenCode Zen Anthropic 线路,预置 Claude 与 Qwen 模型。",
|
||||
"settings.addProvider.preset.qwenCnDesc": "Qwen/DashScope 国内标准 API 端点。",
|
||||
"settings.addProvider.preset.qwenGlobalDesc": "Qwen/DashScope 国际标准 API 端点。",
|
||||
"settings.addProvider.preset.qwenCodingPlanCnDesc": "Qwen Coding Plan 国内 OpenAI-compatible 端点,默认 qwen3.7-plus。",
|
||||
"settings.addProvider.preset.qwenCodingPlanCnAnthropicDesc": "Qwen Coding Plan 国内 Anthropic-compatible 端点,默认 qwen3.7-plus。",
|
||||
"settings.addProvider.preset.qwenCodingPlanGlobalDesc": "Qwen Coding Plan 国际 OpenAI-compatible 端点,默认 qwen3.7-plus。",
|
||||
"settings.addProvider.preset.qwenCodingPlanGlobalAnthropicDesc": "Qwen Coding Plan 国际 Anthropic-compatible 端点,默认 qwen3.7-plus。",
|
||||
"settings.addProvider.preset.stepfunDesc": "阶跃星辰 StepFun coding plan OpenAI-compatible 端点,默认 step-3.7-flash。",
|
||||
"settings.addProvider.preset.stepfunAnthropicDesc": "阶跃星辰 StepFun coding plan Anthropic-compatible 端点,默认 step-3.7-flash。",
|
||||
"settings.addProvider.preset.novitaDesc": "NovitaAI 多模型网关,预置 Kimi、MiniMax、GLM、DeepSeek、Qwen。",
|
||||
"settings.addProvider.preset.gmiDesc": "GMI Cloud 多模型直连网关,预置 GLM、DeepSeek、Kimi、Gemini、Claude、GPT。",
|
||||
"settings.addProvider.preset.vercelAiGatewayDesc": "Vercel AI Gateway,走 Anthropic-compatible Messages API。",
|
||||
"settings.addProvider.preset.huggingfaceDesc": "HuggingFace Inference Router,预置 Qwen 与 DeepSeek 模型。",
|
||||
"settings.addProvider.preset.nvidiaDesc": "NVIDIA NIM 加速推理端点,预置 Nemotron 模型。",
|
||||
"settings.addProvider.preset.kilocodeDesc": "Kilo Code 网关,预置 kilo/auto 自动路由模型。",
|
||||
"settings.addProvider.preset.ollamaCloudDesc": "Ollama Cloud 托管端点,支持 none 到 max 的 reasoning effort。",
|
||||
"settings.addProvider.addedBadge": "已添加",
|
||||
"settings.addProvider.modifiedBadge": "已修改",
|
||||
"settings.addProvider.nameConflictBadge": "同名占用",
|
||||
"settings.addProvider.similarExistingBadge": "有相似配置",
|
||||
"settings.addProvider.confirm": "添加供应商",
|
||||
"settings.addProvider.alreadyAddedAction": "已添加",
|
||||
"settings.addProvider.nameConflictAction": "先处理同名配置",
|
||||
"settings.addProvider.viewPresetProvider": "查看配置",
|
||||
"settings.addProvider.viewConflictProvider": "查看同名配置",
|
||||
"settings.addProvider.resetPreset": "重置为预设",
|
||||
"settings.addProvider.confirmResetPreset": "确认重置",
|
||||
"settings.customProviderName": "自定义供应商名称",
|
||||
"settings.customProviderNamePlaceholder": "",
|
||||
"settings.providerNamePlaceholder": "供应商名称(如 deepseek-proxy)",
|
||||
"settings.providerProtocol": "接入协议",
|
||||
"settings.providerProtocolOpenAI": "OpenAI-compatible",
|
||||
"settings.providerProtocolOpenAIHint": "适用于兼容 OpenAI API 的代理、聚合平台和自建服务。",
|
||||
"settings.providerProtocolAnthropic": "Anthropic-compatible",
|
||||
"settings.providerProtocolAnthropicHint": "适用于 Claude Messages API 风格的服务,例如部分 coding plan 端点。",
|
||||
"settings.providerBaseUrlLabel": "API 地址",
|
||||
"settings.providerChatUrlLabel": "完整请求 URL",
|
||||
"settings.providerChatUrlPlaceholder": "例如 https://api.openai.com/v1/chat/completions",
|
||||
@@ -1372,12 +1631,29 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.testFetchModels": "测试并获取模型",
|
||||
"settings.testFetchModelsHint": "会使用上方 API 地址与密钥确认连接,并用返回结果填充模型列表。",
|
||||
"settings.manualModels": "模型列表",
|
||||
"settings.manualModelsHint": "接口不支持模型发现时,可手动填写多个模型,用英文逗号分隔。",
|
||||
"settings.manualModelsHint": "接口不支持模型发现时,可手动填写多个模型;下方可勾选哪些模型支持图片输入。",
|
||||
"settings.visionModels": "支持图片输入的模型",
|
||||
"settings.visionModelsHint": "启用模型中可接收图片输入的子集,用英文逗号分隔。",
|
||||
"settings.providerAdvancedSettings": "高级设置",
|
||||
"settings.providerAdvancedSettings": "兼容设置(通常不用改)",
|
||||
"settings.providerAdvancedExpandHint": "点击展开详细配置",
|
||||
"settings.providerAdvancedCollapseHint": "点击收起",
|
||||
"settings.providerHeaders": "额外请求头(可选)",
|
||||
"settings.providerHeadersPlaceholder": "HTTP-Referer: https://example.com\nX-Title: Reasonix",
|
||||
"settings.providerHeadersHint": "用于 OpenRouter 等要求站点来源的中转站。一行一个 Header: value;API Key 仍通过上方密钥保存。",
|
||||
"settings.providerExtraBody": "额外请求体(可选)",
|
||||
"settings.providerExtraBodyPlaceholder": "{\n \"enable_thinking\": true\n}",
|
||||
"settings.providerExtraBodyHint": "会合并到聊天请求体顶层 JSON。model、messages、tools、stream、thinking 等核心字段仍由 Reasonix 控制。",
|
||||
"settings.providerExtraBodyError": "请输入不含 null 值的 JSON 对象,例如 { \"enable_thinking\": true }。",
|
||||
"settings.providerAuthHeader": "使用 Authorization: Bearer 发送密钥",
|
||||
"settings.providerAuthHeaderHint": "适用于 MiniMax Global、Vercel AI Gateway 等不使用 x-api-key 的 Anthropic-compatible 网关。",
|
||||
"settings.fetchingModels": "刷新中…",
|
||||
"settings.fetchModelsSuccess": "已获取 {n} 个模型",
|
||||
"settings.fetchModelsManualFallbackAuth": "模型发现接口拒绝了密钥或权限。请检查 API Key;如果该服务不开放模型发现,也可以在下方手动填写模型 ID。",
|
||||
"settings.fetchModelsManualFallbackUnsupported": "这个服务可能不支持自动获取模型列表。可以直接在下方手动填写模型 ID。",
|
||||
"settings.fetchModelsManualFallbackNetwork": "暂时无法连接模型发现接口。请检查 API 地址或代理;如果你知道模型 ID,也可以先手动填写。",
|
||||
"settings.fetchModelsManualFallbackDecode": "模型发现接口返回了无法识别的格式。可以直接在下方手动填写模型 ID。",
|
||||
"settings.fetchModelsManualFallbackEmpty": "没有自动获取到模型。可以直接在下方手动填写模型 ID。",
|
||||
"settings.fetchModelsManualFallbackGeneric": "无法自动获取模型:{err}。可以直接在下方手动填写模型 ID。",
|
||||
"settings.fetchModelsUpdatedForProvider": "已更新可用模型:{provider},共 {n} 个",
|
||||
"settings.fetchModelsReadyForProvider": "已发现 {provider} 的 {n} 个模型,请确认后保存启用列表。",
|
||||
"settings.enabledModelsSavedForProvider": "已保存 {provider} 的 {n} 个启用模型",
|
||||
@@ -1387,12 +1663,18 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.fetchModelsAfterKeyFailed": "密钥已保存,但暂时无法自动刷新可用模型:{err}",
|
||||
"settings.fetchModelsAfterKeyFailedForProvider": "密钥已保存,但暂时无法刷新 {provider} 的可用模型:{err}",
|
||||
"settings.newProviderDraftName": "新供应商",
|
||||
"settings.reasoningProtocol": "推理协议",
|
||||
"settings.reasoningProtocol.auto": "Auto(按模型自动判断)",
|
||||
"settings.reasoningProtocol.deepseek": "DeepSeek",
|
||||
"settings.reasoningProtocol.openai": "OpenAI",
|
||||
"settings.reasoningProtocol.none": "None(关闭)",
|
||||
"settings.reasoningProtocolHint": "控制 effort 的请求格式。Auto 会按模型能力表和官方端点识别;代理不兼容时可切到 OpenAI 或 None。",
|
||||
"settings.reasoningProtocol": "模型能力模式",
|
||||
"settings.reasoningProtocol.auto": "自动识别(推荐)",
|
||||
"settings.reasoningProtocol.deepseek": "DeepSeek 思考(thinking + 深度)",
|
||||
"settings.reasoningProtocol.openai": "OpenAI reasoning(reasoning_effort)",
|
||||
"settings.reasoningProtocol.none": "普通聊天(不发送思考参数)",
|
||||
"settings.reasoningProtocolHint": "默认自动识别。只有代理不兼容,或你确定模型需要特定 reasoning 格式时再切换。",
|
||||
"settings.thinkingMode": "Thinking 覆盖",
|
||||
"settings.thinkingMode.auto": "Auto(使用服务默认)",
|
||||
"settings.thinkingMode.enabled": "Enabled(开启)",
|
||||
"settings.thinkingMode.disabled": "Disabled(关闭)",
|
||||
"settings.thinkingMode.adaptive": "Adaptive(自适应)",
|
||||
"settings.thinkingModeHint": "服务商专用覆盖项。兼容网关可用 enabled/disabled;只有服务文档明确支持时才使用 adaptive。",
|
||||
"settings.updateKey": "更新 {env}(全局保存)",
|
||||
"settings.updateKeyAction": "更新密钥",
|
||||
"settings.keyStatus": "密钥",
|
||||
@@ -1507,6 +1789,8 @@ export const zh: Record<DictKey, string> = {
|
||||
"msg.imageAttachment": "图片",
|
||||
"msg.workspaceReference": "工作区引用",
|
||||
"msg.folderReference": "文件夹 · 工作区引用",
|
||||
"msg.pastedExpandTooltip": "展开粘贴文本",
|
||||
"msg.pastedCollapseTooltip": "收起粘贴文本",
|
||||
"turnActions.summary": "总结",
|
||||
"turnActions.rewind": "回溯",
|
||||
"transcript.jumpToBottom": "跳到底部",
|
||||
@@ -1588,7 +1872,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.telemetryLabel": "匿名启动统计",
|
||||
"settings.telemetryHint": "启动时发送随机安装 ID、版本号和操作系统用于统计活跃安装量;若上次运行发生崩溃,则在本次启动补发该崩溃报告。绝不包含对话、密钥或文件内容。",
|
||||
"settings.metricsLabel": "共享聚合质量指标",
|
||||
"settings.metricsHint": "默认开启。发送匿名的轮次结束统计、设置偏好快照,以及 Memory v5 聚合桶,例如是否注入、大小桶、IR 数量桶和记忆图规模桶。包含用于 DAU 去重的随机安装 ID。Bucket 可能包含归一化后的自定义 Provider 名和模型名——绝不包含对话、提示词、密钥、路径、base URL、记忆正文、工具输出或文件内容。",
|
||||
"settings.metricsHint": "默认开启。发送匿名的轮次结束统计、更新器错误类别、设置偏好快照,以及 Memory v5 聚合桶,例如是否注入、大小桶、IR 数量桶和记忆图规模桶。包含用于 DAU 去重的随机安装 ID。Bucket 可能包含归一化后的自定义 Provider 名和模型名——绝不包含对话、提示词、密钥、路径、base URL、记忆正文、工具输出或文件内容。",
|
||||
"updater.currentVersion": "当前版本:{v}",
|
||||
"updater.channelLabel": "更新渠道:{channel}",
|
||||
"updater.checkButton": "检查更新",
|
||||
@@ -1631,10 +1915,20 @@ export const zh: Record<DictKey, string> = {
|
||||
"context.scopeGlobal": "范围:全局",
|
||||
"context.scopeProject": "所属项目 · {name}",
|
||||
"context.windowTitle": "上下文窗口",
|
||||
"context.windowSubtitle": "当前上下文窗口占用",
|
||||
"context.windowStatusHealthy": "上下文充足",
|
||||
"context.windowStatusWatch": "即将压缩",
|
||||
"context.windowStatusPastCompact": "已到压缩阈值",
|
||||
"context.windowStatusNearLimit": "接近窗口上限",
|
||||
"context.windowUsageSummary": "{pct}% 已用",
|
||||
"context.windowCompactRemaining": "{used} / {window} · 距压缩还剩 {tokens}",
|
||||
"context.windowCompactDistance": "距压缩",
|
||||
"context.windowUsedLabel": "已用",
|
||||
"context.windowUsed": "已用上下文",
|
||||
"context.windowCompactThreshold": "压缩阈值",
|
||||
"context.windowCompactHeadroom": "压缩前余量",
|
||||
"context.sessionMetrics": "会话指标",
|
||||
"context.runtimeMetrics": "运行指标",
|
||||
"context.costMetrics": "成本",
|
||||
"context.sessionStatus": "会话状态",
|
||||
"context.sessionChanges": "本会话变更",
|
||||
"context.referencedFiles": "依赖文件",
|
||||
"context.noChanges": "当前会话还没有变更文件",
|
||||
@@ -1642,34 +1936,28 @@ export const zh: Record<DictKey, string> = {
|
||||
"context.changedMeta": "{count} 条变更记录",
|
||||
"context.readMeta": "{count} 条读取记录",
|
||||
"context.viewAll": "查看全部",
|
||||
"context.health": "上下文状态",
|
||||
"context.healthGood": "上下文状态正常",
|
||||
"context.healthGoodShort": "正常",
|
||||
"context.healthGoodBody": "用量、依据文件和本会话变更保持可追踪。",
|
||||
"context.healthNearLimit": "上下文接近上限",
|
||||
"context.healthNearLimitShort": "接近上限",
|
||||
"context.healthNearLimitBody": "已使用 {pct}%,优先保留关键依据并考虑压缩。",
|
||||
"context.healthManyFiles": "依据文件较多",
|
||||
"context.healthManyFilesShort": "文件较多",
|
||||
"context.healthManyFilesBody": "已读取 {count} 条文件记录,可查看依据文件确认是否仍相关。",
|
||||
"context.healthLowCache": "缓存命中偏低",
|
||||
"context.healthLowCacheShort": "命中偏低",
|
||||
"context.healthLowCacheBody": "当前缓存命中 {pct}%,后续长任务可关注上下文稳定性。",
|
||||
"context.prompt": "提示词",
|
||||
"context.completion": "回复",
|
||||
"context.reasoning": "推理",
|
||||
"context.other": "其他",
|
||||
"context.total": "总计",
|
||||
"context.usageAnalysis": "用量分析",
|
||||
"context.usageAnalysisView": "用量分析视图",
|
||||
"context.usageAnalysisSource": "按来源",
|
||||
"context.usageAnalysisType": "按类型",
|
||||
"context.tokenBreakdown": "Token 构成",
|
||||
"context.cacheHit": "缓存命中",
|
||||
"context.inputCache": "输入缓存",
|
||||
"context.inputCacheHit": "输入 tokens(命中缓存)",
|
||||
"context.inputCacheMiss": "输入 tokens(未命中缓存)",
|
||||
"context.outputTokens": "输出 tokens",
|
||||
"context.sessionTokens": "会话 tokens",
|
||||
"context.sessionTokens": "会话累计 tokens",
|
||||
"context.sessionTokensShort": "累计 tokens",
|
||||
"context.tokensValue": "{value} tokens",
|
||||
"context.sessionCost": "会话费用",
|
||||
"context.costBreakdown": "费用来源",
|
||||
"context.sourceBreakdown": "模型用量明细",
|
||||
"context.sourceShareTokens": "按 tokens 占比",
|
||||
"context.sourceBreakdown": "用量来源",
|
||||
"context.sourceShareTitle": "来源占比",
|
||||
"context.sourceInput": "输入",
|
||||
"context.sourceOutput": "输出",
|
||||
"context.sourceCacheHit": "命中",
|
||||
@@ -1685,9 +1973,9 @@ export const zh: Record<DictKey, string> = {
|
||||
"context.sourceClassifier": "计划分类",
|
||||
"context.sourceTitle": "标题生成",
|
||||
"context.sourceRequests": "{count} 次",
|
||||
"context.moreSources": "还有 {count} 个来源",
|
||||
"context.requests": "请求数",
|
||||
"context.time": "耗时",
|
||||
"context.compaction": "压缩状态",
|
||||
"context.time": "运行时间",
|
||||
"context.durationSeconds": "{seconds}秒",
|
||||
"context.durationMinutesSeconds": "{minutes}分{seconds}秒",
|
||||
|
||||
|
||||
+2998
-198
File diff suppressed because it is too large.
Load diff
@@ -25,7 +25,9 @@ require (
|
||||
require (
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 // indirect
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/danieljoos/wincred v1.2.3 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
@@ -33,6 +35,7 @@ require (
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/labstack/echo/v4 v4.15.2 // indirect
|
||||
github.com/labstack/gommon v0.5.0 // indirect
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.9.5 // indirect
|
||||
@@ -42,6 +45,7 @@ require (
|
||||
github.com/leaanthony/u v1.1.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/mattn/go-pointer v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.24 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
@@ -49,6 +53,11 @@ require (
|
||||
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect
|
||||
github.com/samber/lo v1.53.0 // indirect
|
||||
github.com/tkrajina/go-reflector v0.5.8 // indirect
|
||||
github.com/tree-sitter/go-tree-sitter v0.25.0 // indirect
|
||||
github.com/tree-sitter/tree-sitter-javascript v0.25.0 // indirect
|
||||
github.com/tree-sitter/tree-sitter-python v0.25.0 // indirect
|
||||
github.com/tree-sitter/tree-sitter-rust v0.24.2 // indirect
|
||||
github.com/tree-sitter/tree-sitter-typescript v0.23.2 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.23 // indirect
|
||||
@@ -57,6 +66,8 @@ require (
|
||||
golang.org/x/crypto v0.53.0 // indirect
|
||||
golang.org/x/image v0.43.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
mvdan.cc/sh/v3 v3.13.1 // indirect
|
||||
)
|
||||
|
||||
replace reasonix => ../
|
||||
@@ -7,8 +7,12 @@ git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
|
||||
@@ -18,10 +22,14 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
|
||||
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
@@ -29,8 +37,14 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/labstack/echo/v4 v4.15.2 h1:nnh2sCzGCVYnU+wCisMPiYapEg/QVo/gcI9ePKg5/T4=
|
||||
github.com/labstack/echo/v4 v4.15.2/go.mod h1:Xzp1Ns1RA2c9fY7nSgUJkpkUZGNbEIVHZbtbOMPktBI=
|
||||
github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c=
|
||||
@@ -54,6 +68,8 @@ github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy
|
||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mattn/go-pointer v0.0.1 h1:n+XhsuGeVO6MEAp7xyEukFINEa+Quek5psIR/ylA6o0=
|
||||
github.com/mattn/go-pointer v0.0.1/go.mod h1:2zXcozF6qYGgmsG+SeTZz3oAbFLdD3OWqnUbNvJZAlc=
|
||||
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
|
||||
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU=
|
||||
@@ -67,6 +83,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
|
||||
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
|
||||
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
|
||||
@@ -79,6 +97,34 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||
github.com/tree-sitter/go-tree-sitter v0.25.0 h1:sx6kcg8raRFCvc9BnXglke6axya12krCJF5xJ2sftRU=
|
||||
github.com/tree-sitter/go-tree-sitter v0.25.0/go.mod h1:r77ig7BikoZhHrrsjAnv8RqGti5rtSyvDHPzgTPsUuU=
|
||||
github.com/tree-sitter/tree-sitter-c v0.23.4 h1:nBPH3FV07DzAD7p0GfNvXM+Y7pNIoPenQWBpvM++t4c=
|
||||
github.com/tree-sitter/tree-sitter-c v0.23.4/go.mod h1:MkI5dOiIpeN94LNjeCp8ljXN/953JCwAby4bClMr6bw=
|
||||
github.com/tree-sitter/tree-sitter-cpp v0.23.4 h1:LaWZsiqQKvR65yHgKmnaqA+uz6tlDJTJFCyFIeZU/8w=
|
||||
github.com/tree-sitter/tree-sitter-cpp v0.23.4/go.mod h1:doqNW64BriC7WBCQ1klf0KmJpdEvfxyXtoEybnBo6v8=
|
||||
github.com/tree-sitter/tree-sitter-embedded-template v0.23.2 h1:nFkkH6Sbe56EXLmZBqHHcamTpmz3TId97I16EnGy4rg=
|
||||
github.com/tree-sitter/tree-sitter-embedded-template v0.23.2/go.mod h1:HNPOhN0qF3hWluYLdxWs5WbzP/iE4aaRVPMsdxuzIaQ=
|
||||
github.com/tree-sitter/tree-sitter-go v0.23.4 h1:yt5KMGnTHS+86pJmLIAZMWxukr8W7Ae1STPvQUuNROA=
|
||||
github.com/tree-sitter/tree-sitter-go v0.23.4/go.mod h1:Jrx8QqYN0v7npv1fJRH1AznddllYiCMUChtVjxPK040=
|
||||
github.com/tree-sitter/tree-sitter-html v0.23.2 h1:1UYDV+Yd05GGRhVnTcbP58GkKLSHHZwVaN+lBZV11Lc=
|
||||
github.com/tree-sitter/tree-sitter-html v0.23.2/go.mod h1:gpUv/dG3Xl/eebqgeYeFMt+JLOY9cgFinb/Nw08a9og=
|
||||
github.com/tree-sitter/tree-sitter-java v0.23.5 h1:J9YeMGMwXYlKSP3K4Us8CitC6hjtMjqpeOf2GGo6tig=
|
||||
github.com/tree-sitter/tree-sitter-java v0.23.5/go.mod h1:NRKlI8+EznxA7t1Yt3xtraPk1Wzqh3GAIC46wxvc320=
|
||||
github.com/tree-sitter/tree-sitter-javascript v0.25.0 h1:ZkWETb66/w8cc13yhfnNuHOLDQWl3BnKlH6f9AdR88c=
|
||||
github.com/tree-sitter/tree-sitter-javascript v0.25.0/go.mod h1:lmGD1EJdCA+v0S1u2fFgepMg/opzSg/4pgFym2FPGAs=
|
||||
github.com/tree-sitter/tree-sitter-json v0.24.8 h1:tV5rMkihgtiOe14a9LHfDY5kzTl5GNUYe6carZBn0fQ=
|
||||
github.com/tree-sitter/tree-sitter-json v0.24.8/go.mod h1:F351KK0KGvCaYbZ5zxwx/gWWvZhIDl0eMtn+1r+gQbo=
|
||||
github.com/tree-sitter/tree-sitter-php v0.23.11 h1:iHewsLNDmznh8kgGyfWfujsZxIz1YGbSd2ZTEM0ZiP8=
|
||||
github.com/tree-sitter/tree-sitter-php v0.23.11/go.mod h1:T/kbfi+UcCywQfUNAJnGTN/fMSUjnwPXA8k4yoIks74=
|
||||
github.com/tree-sitter/tree-sitter-python v0.25.0 h1:O6XD9v8U1LOcRc3cNj9nM7XufrtEBezE6VrpRrHZDf0=
|
||||
github.com/tree-sitter/tree-sitter-python v0.25.0/go.mod h1:cpdthSy/Yoa28aJFBscFHlGiU+cnSiSh1kuDVtI8YeM=
|
||||
github.com/tree-sitter/tree-sitter-ruby v0.23.1 h1:T/NKHUA+iVbHM440hFx+lzVOzS4dV6z8Qw8ai+72bYo=
|
||||
github.com/tree-sitter/tree-sitter-ruby v0.23.1/go.mod h1:kUS4kCCQloFcdX6sdpr8p6r2rogbM6ZjTox5ZOQy8cA=
|
||||
github.com/tree-sitter/tree-sitter-rust v0.24.2 h1:NL4nF67ib21RMzzfvkmXlVwe45vvhW10DVyO+D0z/W0=
|
||||
github.com/tree-sitter/tree-sitter-rust v0.24.2/go.mod h1:hfeGWic9BAfgTrc7Xf6FaOAguCFJRo3RBbs7QJ6D7MI=
|
||||
github.com/tree-sitter/tree-sitter-typescript v0.23.2 h1:/Odvphn18PniVixb9e97X0DbNVsU6Qocv9mfkyzdXwU=
|
||||
github.com/tree-sitter/tree-sitter-typescript v0.23.2/go.mod h1:zjzMXT/Ulffel2xfOcAkQQkiAkmgnbtPGlFQw/5X4xA=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
@@ -146,7 +192,10 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk=
|
||||
mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0=
|
||||
@@ -42,6 +42,7 @@ type HeartbeatTask struct {
|
||||
ApprovalMode string `json:"approvalMode"` // "ask" | "auto" | "yolo"; empty defaults to "yolo"
|
||||
TimeWindowStart string `json:"timeWindowStart,omitempty"` // "HH:MM" — interval tasks only run after this time (inclusive)
|
||||
TimeWindowEnd string `json:"timeWindowEnd,omitempty"` // "HH:MM" — interval tasks only run before this time (exclusive)
|
||||
NotifyChannels *bool `json:"notifyChannels,omitempty"` // true = push to bot channels; nil/false = skip
|
||||
}
|
||||
|
||||
// heartbeatConfig is the on-disk format.
|
||||
@@ -322,9 +323,21 @@ func (e *HeartbeatEngine) executeTask(t HeartbeatTask) HeartbeatTask {
|
||||
t.ApprovalMode = mode
|
||||
e.app.SetToolApprovalModeForTab(tabMeta.ID, mode)
|
||||
|
||||
// Attach bot event forwarding if the bot runtime is active and has
|
||||
// session-mapped targets. The forwarder is set on the tab's event sink
|
||||
// so AI output events are streamed to connected bot channels in
|
||||
// real-time alongside the desktop UI.
|
||||
botForwardAttached := false
|
||||
if t.NotifyChannels != nil && *t.NotifyChannels {
|
||||
botForwardAttached = e.attachBotForwarder(tabMeta.ID)
|
||||
}
|
||||
|
||||
// Submit as a plain user turn so scheduled prompts cannot invoke desktop
|
||||
// shell or slash-command handlers such as "!cmd", "/clear", or "/compact".
|
||||
if !e.app.submitUserTurnToTab(tabMeta.ID, t.Prompt) {
|
||||
if botForwardAttached {
|
||||
e.clearBotForwarder(tabMeta.ID)
|
||||
}
|
||||
log.Printf("[heartbeat] submit skipped for %q", t.Title)
|
||||
return t
|
||||
}
|
||||
@@ -806,3 +819,40 @@ func (a *App) HeartbeatGenerateID() string {
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// attachBotForwarder sets up event forwarding to connected bot channels for
|
||||
// the tab identified by tabID. It is called before submitting a heartbeat
|
||||
// prompt. If the bot runtime is not active or has no session-mapped targets
|
||||
// the call is a no-op.
|
||||
func (e *HeartbeatEngine) attachBotForwarder(tabID string) bool {
|
||||
runtime := e.app.botRuntime
|
||||
if runtime == nil || !runtime.Running() {
|
||||
return false
|
||||
}
|
||||
cfg, err := e.app.loadDesktopBotConfig()
|
||||
if err != nil {
|
||||
log.Printf("[heartbeat] load config for bot forward: %v", err)
|
||||
return false
|
||||
}
|
||||
targets := runtime.ForwardTargets(cfg)
|
||||
if len(targets) == 0 {
|
||||
return false // no session-mapped channels to forward to
|
||||
}
|
||||
tab := e.app.tabByID(tabID)
|
||||
if tab == nil || tab.sink == nil {
|
||||
return false
|
||||
}
|
||||
log.Printf("[heartbeat] bot forwarding attached: %d target(s) for tab %s", len(targets), tabID)
|
||||
|
||||
forwarder := newBotEventForwarder(runtime, targets)
|
||||
tab.sink.SetBotSink(forwarder)
|
||||
return true
|
||||
}
|
||||
|
||||
func (e *HeartbeatEngine) clearBotForwarder(tabID string) {
|
||||
tab := e.app.tabByID(tabID)
|
||||
if tab == nil || tab.sink == nil {
|
||||
return
|
||||
}
|
||||
tab.sink.SetBotSink(nil)
|
||||
}
|
||||
@@ -9,9 +9,11 @@ import (
|
||||
"unicode/utf8"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/boot"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/store"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
@@ -86,6 +88,25 @@ func TestHistoryMessagesDoNotReplayMemoryCompilerContract(t *testing.T) {
|
||||
assertNoHistoryMemoryContract(t, got[0].Content)
|
||||
}
|
||||
|
||||
func TestHistoryMessagesStripActiveGoalFromVisibleUserContent(t *testing.T) {
|
||||
raw := "<active-goal>\nship the approval redesign\n</active-goal>\n\ncontinue implementation"
|
||||
msgs := []provider.Message{
|
||||
{Role: provider.RoleUser, Content: raw},
|
||||
{Role: provider.RoleAssistant, Content: "done"},
|
||||
}
|
||||
|
||||
got := historyMessages(msgs, control.StripComposePrefixes)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("history length = %d, want 2: %+v", len(got), got)
|
||||
}
|
||||
if got[0].Content != "continue implementation" {
|
||||
t.Fatalf("visible user content = %q, want active-goal stripped", got[0].Content)
|
||||
}
|
||||
if strings.Contains(got[0].Content, "<active-goal>") || strings.Contains(got[0].Content, "ship the approval redesign") {
|
||||
t.Fatalf("active-goal leaked into visible history content: %+v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryMessagesCarryCheckpointTurnsAcrossHiddenSyntheticUsers(t *testing.T) {
|
||||
msgs := []provider.Message{
|
||||
{Role: provider.RoleSystem, Content: "sys"},
|
||||
@@ -828,6 +849,232 @@ func TestRebindTabToLoadedSessionReusesPreloadedTranscript(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRebindTabToLoadedSessionPersistsAndRestoresSessionProfile(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
root := globalTabWorkspaceRoot()
|
||||
dir := desktopSessionDir(root)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir session dir: %v", err)
|
||||
}
|
||||
|
||||
currentPath := filepath.Join(dir, "current.jsonl")
|
||||
targetPath := filepath.Join(dir, "target.jsonl")
|
||||
writeHistoryTestSession(t, currentPath, "current prompt")
|
||||
writeHistoryTestSession(t, targetPath, "target prompt")
|
||||
if err := agent.SaveBranchMetaPreserveUpdated(targetPath, agent.BranchMeta{
|
||||
TokenMode: boot.TokenModeFull,
|
||||
Mode: "yolo",
|
||||
ToolApprovalMode: control.ToolApprovalYolo,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveBranchMetaPreserveUpdated target: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := agent.LoadSession(targetPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession: %v", err)
|
||||
}
|
||||
ctrl := control.New(control.Options{SessionDir: dir, SessionPath: currentPath, Label: "current", Sink: event.Discard})
|
||||
ctrl.SetMode(true, false)
|
||||
ctrl.SetToolApprovalMode(control.ToolApprovalAuto)
|
||||
defer ctrl.Close()
|
||||
|
||||
app := NewApp()
|
||||
tab := &WorkspaceTab{
|
||||
ID: "tab",
|
||||
Scope: "global",
|
||||
WorkspaceRoot: root,
|
||||
SessionPath: currentPath,
|
||||
Ctrl: ctrl,
|
||||
Ready: true,
|
||||
tokenMode: boot.TokenModeEconomy,
|
||||
mode: "plan",
|
||||
toolApprovalMode: control.ToolApprovalAuto,
|
||||
sink: &tabEventSink{tabID: "tab", app: app},
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
app.tabs[tab.ID] = tab
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
|
||||
if err := app.rebindTabToLoadedSessionPath(tab, targetPath, loaded); err != nil {
|
||||
t.Fatalf("rebindTabToLoadedSessionPath: %v", err)
|
||||
}
|
||||
|
||||
currentMeta, ok, err := agent.LoadBranchMeta(currentPath)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta current ok=%v err=%v", ok, err)
|
||||
}
|
||||
if currentMeta.TokenMode != boot.TokenModeEconomy || currentMeta.Mode != "plan" || currentMeta.ToolApprovalMode != control.ToolApprovalAuto {
|
||||
t.Fatalf("current session profile = token:%q mode:%q approval:%q, want economy/plan/auto",
|
||||
currentMeta.TokenMode, currentMeta.Mode, currentMeta.ToolApprovalMode)
|
||||
}
|
||||
if got := currentTabTokenMode(tab); got != boot.TokenModeFull {
|
||||
t.Fatalf("rebound token mode = %q, want full", got)
|
||||
}
|
||||
if got := currentTabMode(tab); got != "yolo" {
|
||||
t.Fatalf("rebound mode = %q, want yolo", got)
|
||||
}
|
||||
if got := currentTabToolApprovalMode(tab); got != control.ToolApprovalYolo {
|
||||
t.Fatalf("rebound tool approval = %q, want yolo", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseTabPersistsSessionProfileBeforeRemovingVisibleTab(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
root := globalTabWorkspaceRoot()
|
||||
dir := desktopSessionDir(root)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir session dir: %v", err)
|
||||
}
|
||||
|
||||
currentPath := filepath.Join(dir, "profile.jsonl")
|
||||
otherPath := filepath.Join(dir, "other.jsonl")
|
||||
writeHistoryTestSession(t, currentPath, "profile prompt")
|
||||
writeHistoryTestSession(t, otherPath, "other prompt")
|
||||
if err := agent.SaveBranchMetaPreserveUpdated(currentPath, agent.BranchMeta{}); err != nil {
|
||||
t.Fatalf("SaveBranchMetaPreserveUpdated current: %v", err)
|
||||
}
|
||||
|
||||
ctrl := control.New(control.Options{SessionDir: dir, SessionPath: currentPath, Label: "profile", Sink: event.Discard})
|
||||
ctrl.SetMode(true, false)
|
||||
ctrl.SetToolApprovalMode(control.ToolApprovalAuto)
|
||||
ctrl.SetGoal("finish the review")
|
||||
|
||||
app := NewApp()
|
||||
tab := &WorkspaceTab{
|
||||
ID: "profile",
|
||||
Scope: "global",
|
||||
WorkspaceRoot: root,
|
||||
SessionPath: currentPath,
|
||||
Ctrl: ctrl,
|
||||
Ready: true,
|
||||
tokenMode: boot.TokenModeEconomy,
|
||||
mode: "plan",
|
||||
toolApprovalMode: control.ToolApprovalAuto,
|
||||
sink: &tabEventSink{tabID: "profile", app: app},
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
other := &WorkspaceTab{
|
||||
ID: "other",
|
||||
Scope: "global",
|
||||
WorkspaceRoot: root,
|
||||
SessionPath: otherPath,
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
app.tabs[tab.ID] = tab
|
||||
app.tabs[other.ID] = other
|
||||
app.tabOrder = []string{tab.ID, other.ID}
|
||||
app.activeTabID = tab.ID
|
||||
|
||||
if err := app.CloseTab(tab.ID); err != nil {
|
||||
t.Fatalf("CloseTab: %v", err)
|
||||
}
|
||||
|
||||
meta, ok, err := agent.LoadBranchMeta(currentPath)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta current ok=%v err=%v", ok, err)
|
||||
}
|
||||
if meta.TokenMode != boot.TokenModeEconomy || meta.Mode != "plan" || meta.ToolApprovalMode != control.ToolApprovalAuto || meta.Goal != "finish the review" {
|
||||
t.Fatalf("closed session profile = token:%q mode:%q approval:%q goal:%q, want economy/plan/auto/goal",
|
||||
meta.TokenMode, meta.Mode, meta.ToolApprovalMode, meta.Goal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeepOnlyVisibleTabPersistsRemovedSessionProfile(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
root := globalTabWorkspaceRoot()
|
||||
dir := desktopSessionDir(root)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir session dir: %v", err)
|
||||
}
|
||||
|
||||
keepPath := filepath.Join(dir, "keep.jsonl")
|
||||
removedPath := filepath.Join(dir, "removed.jsonl")
|
||||
writeHistoryTestSession(t, keepPath, "keep prompt")
|
||||
writeHistoryTestSession(t, removedPath, "removed prompt")
|
||||
if err := agent.SaveBranchMetaPreserveUpdated(removedPath, agent.BranchMeta{}); err != nil {
|
||||
t.Fatalf("SaveBranchMetaPreserveUpdated removed: %v", err)
|
||||
}
|
||||
|
||||
removedCtrl := control.New(control.Options{SessionDir: dir, SessionPath: removedPath, Label: "removed", Sink: event.Discard})
|
||||
removedCtrl.SetMode(true, false)
|
||||
removedCtrl.SetToolApprovalMode(control.ToolApprovalAuto)
|
||||
removedCtrl.SetGoal("keep this profile")
|
||||
|
||||
app := NewApp()
|
||||
keep := &WorkspaceTab{
|
||||
ID: "keep",
|
||||
Scope: "global",
|
||||
WorkspaceRoot: root,
|
||||
SessionPath: keepPath,
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
removed := &WorkspaceTab{
|
||||
ID: "removed",
|
||||
Scope: "global",
|
||||
WorkspaceRoot: root,
|
||||
SessionPath: removedPath,
|
||||
Ctrl: removedCtrl,
|
||||
Ready: true,
|
||||
tokenMode: boot.TokenModeEconomy,
|
||||
mode: "plan",
|
||||
toolApprovalMode: control.ToolApprovalAuto,
|
||||
sink: &tabEventSink{tabID: "removed", app: app},
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
app.tabs[keep.ID] = keep
|
||||
app.tabs[removed.ID] = removed
|
||||
app.tabOrder = []string{keep.ID, removed.ID}
|
||||
app.activeTabID = removed.ID
|
||||
|
||||
if _, err := app.keepOnlyVisibleTab(keep.ID); err != nil {
|
||||
t.Fatalf("keepOnlyVisibleTab: %v", err)
|
||||
}
|
||||
|
||||
meta, ok, err := agent.LoadBranchMeta(removedPath)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta removed ok=%v err=%v", ok, err)
|
||||
}
|
||||
if meta.TokenMode != boot.TokenModeEconomy || meta.Mode != "plan" || meta.ToolApprovalMode != control.ToolApprovalAuto || meta.Goal != "keep this profile" {
|
||||
t.Fatalf("removed session profile = token:%q mode:%q approval:%q goal:%q, want economy/plan/auto/goal",
|
||||
meta.TokenMode, meta.Mode, meta.ToolApprovalMode, meta.Goal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTabSessionProfileIgnoresTerminalGoalState(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
root := globalTabWorkspaceRoot()
|
||||
dir := desktopSessionDir(root)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir session dir: %v", err)
|
||||
}
|
||||
|
||||
sessionPath := filepath.Join(dir, "terminal-goal.jsonl")
|
||||
writeHistoryTestSession(t, sessionPath, "terminal prompt")
|
||||
if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{
|
||||
TokenMode: boot.TokenModeEconomy,
|
||||
Mode: "plan",
|
||||
ToolApprovalMode: control.ToolApprovalAuto,
|
||||
Goal: "stale terminal goal",
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveBranchMetaPreserveUpdated: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(store.SessionGoalState(sessionPath), []byte(`{"goal":"stale terminal goal","status":"complete"}`), 0o644); err != nil {
|
||||
t.Fatalf("write goal state: %v", err)
|
||||
}
|
||||
|
||||
profile := loadTabSessionProfile(sessionPath)
|
||||
if profile.goal != "" {
|
||||
t.Fatalf("loaded profile goal = %q, want terminal goal ignored", profile.goal)
|
||||
}
|
||||
if profile.tokenMode != boot.TokenModeEconomy || profile.mode != "plan" || profile.toolApprovalMode != control.ToolApprovalAuto {
|
||||
t.Fatalf("loaded profile = token:%q mode:%q approval:%q, want economy/plan/auto",
|
||||
profile.tokenMode, profile.mode, profile.toolApprovalMode)
|
||||
}
|
||||
}
|
||||
|
||||
func writeHistoryTestSession(t *testing.T, path, prompt string) {
|
||||
t.Helper()
|
||||
session := agent.NewSession("")
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"embed"
|
||||
"os"
|
||||
"path/filepath"
|
||||
goruntime "runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/wailsapp/wails/v2"
|
||||
@@ -112,6 +113,7 @@ func main() {
|
||||
Title: "Reasonix",
|
||||
Width: width,
|
||||
Height: height,
|
||||
Frameless: goruntime.GOOS == "windows",
|
||||
MinWidth: 760,
|
||||
MinHeight: 480,
|
||||
// Match the dark UI shell so the initial webview background doesn't flash
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/notify"
|
||||
)
|
||||
|
||||
type desktopRecordSink struct {
|
||||
events []event.Kind
|
||||
}
|
||||
|
||||
func (s *desktopRecordSink) Emit(e event.Event) {
|
||||
s.events = append(s.events, e.Kind)
|
||||
}
|
||||
|
||||
type desktopRecordSender struct {
|
||||
messages []notify.Message
|
||||
}
|
||||
|
||||
func (s *desktopRecordSender) Send(m notify.Message) error {
|
||||
s.messages = append(s.messages, m)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestDesktopControllerSinkSendsConfiguredSystemNotifications(t *testing.T) {
|
||||
inner := &desktopRecordSink{}
|
||||
sender := &desktopRecordSender{}
|
||||
app := &App{notificationSender: sender}
|
||||
sink := app.desktopControllerSink(inner, config.NotificationsConfig{
|
||||
Enabled: true,
|
||||
TurnDone: true,
|
||||
ApprovalRequest: true,
|
||||
AskRequest: true,
|
||||
})
|
||||
|
||||
sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
|
||||
if len(inner.events) != 1 || inner.events[0] != event.TurnDone {
|
||||
t.Fatalf("forwarded events = %+v, want [TurnDone]", inner.events)
|
||||
}
|
||||
if len(sender.messages) != 1 {
|
||||
t.Fatalf("notifications = %d, want 1", len(sender.messages))
|
||||
}
|
||||
if sender.messages[0].Body != "Turn finished" {
|
||||
t.Fatalf("notification body = %q, want Turn finished", sender.messages[0].Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopControllerSinkSkipsSystemNotificationsWhenDisabled(t *testing.T) {
|
||||
inner := &desktopRecordSink{}
|
||||
sender := &desktopRecordSender{}
|
||||
app := &App{notificationSender: sender}
|
||||
sink := app.desktopControllerSink(inner, config.NotificationsConfig{
|
||||
Enabled: false,
|
||||
TurnDone: true,
|
||||
})
|
||||
|
||||
sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
|
||||
if len(inner.events) != 1 || inner.events[0] != event.TurnDone {
|
||||
t.Fatalf("forwarded events = %+v, want [TurnDone]", inner.events)
|
||||
}
|
||||
if len(sender.messages) != 0 {
|
||||
t.Fatalf("notifications = %d, want 0", len(sender.messages))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/installsource"
|
||||
"reasonix/internal/pluginpkg"
|
||||
)
|
||||
|
||||
type PluginView struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Root string `json:"root"`
|
||||
ManifestKind string `json:"manifestKind,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Skills int `json:"skills"`
|
||||
Hooks int `json:"hooks"`
|
||||
MCPServers int `json:"mcpServers"`
|
||||
SkillDetails []PluginSkillView `json:"skillDetails,omitempty"`
|
||||
HookDetails []PluginHookView `json:"hookDetails,omitempty"`
|
||||
MCPServerDetails []PluginMCPServerView `json:"mcpServerDetails,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type PluginInstallOptions struct {
|
||||
DryRun bool `json:"dryRun,omitempty"`
|
||||
Link bool `json:"link,omitempty"`
|
||||
Replace bool `json:"replace,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
type PluginSkillView struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Invocation string `json:"invocation,omitempty"`
|
||||
RunAs string `json:"runAs,omitempty"`
|
||||
}
|
||||
|
||||
type PluginHookView struct {
|
||||
Event string `json:"event"`
|
||||
Match string `json:"match,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
ContextFile string `json:"contextFile,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type PluginMCPServerView struct {
|
||||
Name string `json:"name"`
|
||||
Transport string `json:"transport,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
func (a *App) Plugins() []PluginView {
|
||||
st, err := pluginpkg.LoadState(config.ReasonixHomeDir())
|
||||
if err != nil {
|
||||
return []PluginView{{Error: err.Error()}}
|
||||
}
|
||||
out := make([]PluginView, 0, len(st.Plugins))
|
||||
for _, p := range st.Plugins {
|
||||
view := PluginView{
|
||||
Name: p.Name,
|
||||
Version: p.Version,
|
||||
Description: p.Description,
|
||||
Source: p.Source,
|
||||
Root: pluginpkg.ResolveRoot(config.ReasonixHomeDir(), p.Root),
|
||||
ManifestKind: p.ManifestKind,
|
||||
Enabled: p.Enabled,
|
||||
}
|
||||
if pkg, warnings, err := pluginpkg.ParseDir(view.Root); err == nil {
|
||||
applyPluginPackageDetails(&view, pkg, warnings)
|
||||
} else {
|
||||
view.Error = err.Error()
|
||||
}
|
||||
out = append(out, view)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func applyPluginPackageDetails(view *PluginView, pkg pluginpkg.Package, warnings []string) {
|
||||
view.Skills, view.Hooks, view.MCPServers = pkg.CapabilityCounts()
|
||||
view.Warnings = warnings
|
||||
inv := pkg.Inventory()
|
||||
view.SkillDetails = make([]PluginSkillView, 0, len(inv.Skills))
|
||||
for _, sk := range inv.Skills {
|
||||
view.SkillDetails = append(view.SkillDetails, PluginSkillView{
|
||||
Name: sk.Name,
|
||||
Description: sk.Description,
|
||||
Path: sk.Path,
|
||||
Invocation: sk.Invocation,
|
||||
RunAs: sk.RunAs,
|
||||
})
|
||||
}
|
||||
view.HookDetails = make([]PluginHookView, 0, len(inv.Hooks))
|
||||
for _, hook := range inv.Hooks {
|
||||
view.HookDetails = append(view.HookDetails, PluginHookView{
|
||||
Event: hook.Event,
|
||||
Match: hook.Match,
|
||||
Command: hook.Command,
|
||||
ContextFile: hook.ContextFile,
|
||||
Description: hook.Description,
|
||||
})
|
||||
}
|
||||
view.MCPServerDetails = make([]PluginMCPServerView, 0, len(inv.MCPServers))
|
||||
for _, server := range inv.MCPServers {
|
||||
view.MCPServerDetails = append(view.MCPServerDetails, PluginMCPServerView{
|
||||
Name: server.Name,
|
||||
Transport: server.Transport,
|
||||
Command: server.Command,
|
||||
URL: server.URL,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) PlanPluginInstall(source string, opts PluginInstallOptions) (string, error) {
|
||||
opts.DryRun = true
|
||||
return a.runPluginInstallSource(source, opts, false)
|
||||
}
|
||||
|
||||
func (a *App) InstallPlugin(source string, opts PluginInstallOptions) (string, error) {
|
||||
if err := a.ensureActiveTabRebuildAllowed("plugins"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
out, err := a.runPluginInstallSource(source, opts, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
a.invalidateSkillRootsCache()
|
||||
if rebuildErr := a.rebuild(); rebuildErr != nil {
|
||||
return out, rebuildErr
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *App) RemovePlugin(name string) error {
|
||||
if err := a.ensureActiveTabRebuildAllowed("plugins"); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, _ := json.Marshal(map[string]any{"op": "uninstall", "kind": "plugin", "name": strings.TrimSpace(name), "scope": "global"})
|
||||
tl := installsource.NewTool(installsource.Options{
|
||||
ProjectRoot: a.activeWorkspaceRoot(),
|
||||
OnDisconnect: func(serverName string) bool {
|
||||
tab := a.activeTab()
|
||||
if tab == nil || tab.Ctrl == nil {
|
||||
return false
|
||||
}
|
||||
removed, _ := tab.Ctrl.RemoveMCPServer(serverName)
|
||||
return removed
|
||||
},
|
||||
})
|
||||
if _, err := tl.Execute(context.Background(), raw); err != nil {
|
||||
return err
|
||||
}
|
||||
a.invalidateSkillRootsCache()
|
||||
return a.rebuild()
|
||||
}
|
||||
|
||||
func (a *App) SetPluginEnabled(name string, enabled bool) error {
|
||||
if err := a.ensureActiveTabRebuildAllowed("plugins"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := pluginpkg.SetEnabled(config.ReasonixHomeDir(), strings.TrimSpace(name), enabled); err != nil {
|
||||
return err
|
||||
}
|
||||
a.invalidateSkillRootsCache()
|
||||
return a.rebuild()
|
||||
}
|
||||
|
||||
func (a *App) UpdatePlugin(name string) (string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
for _, p := range a.Plugins() {
|
||||
if p.Name == name {
|
||||
if strings.TrimSpace(p.Source) == "" {
|
||||
return "", fmt.Errorf("plugin %q has no recorded source", name)
|
||||
}
|
||||
return a.InstallPlugin(p.Source, PluginInstallOptions{Name: name, Replace: true})
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("plugin %q is not installed", name)
|
||||
}
|
||||
|
||||
func (a *App) PluginDoctor(name string) PluginView {
|
||||
name = strings.TrimSpace(name)
|
||||
for _, p := range a.Plugins() {
|
||||
if p.Name != name {
|
||||
continue
|
||||
}
|
||||
if p.Error != "" {
|
||||
return p
|
||||
}
|
||||
if p.Root == "" {
|
||||
p.Error = "missing plugin root"
|
||||
return p
|
||||
}
|
||||
if _, err := os.Stat(p.Root); err != nil {
|
||||
p.Error = err.Error()
|
||||
return p
|
||||
}
|
||||
return p
|
||||
}
|
||||
return PluginView{Name: name, Error: "plugin is not installed"}
|
||||
}
|
||||
|
||||
func (a *App) runPluginInstallSource(source string, opts PluginInstallOptions, apply bool) (string, error) {
|
||||
mode := "copy"
|
||||
if opts.Link {
|
||||
mode = "link"
|
||||
}
|
||||
body := map[string]any{
|
||||
"source": strings.TrimSpace(source),
|
||||
"kind": "plugin",
|
||||
"mode": mode,
|
||||
"replace": opts.Replace,
|
||||
"apply": apply && !opts.DryRun,
|
||||
}
|
||||
if strings.TrimSpace(opts.Name) != "" {
|
||||
body["name"] = strings.TrimSpace(opts.Name)
|
||||
}
|
||||
raw, _ := json.Marshal(body)
|
||||
tl := installsource.NewTool(installsource.Options{ProjectRoot: a.activeWorkspaceRoot()})
|
||||
return tl.Execute(context.Background(), raw)
|
||||
}
|
||||
+846
-130
File diff suppressed because it is too large.
Load diff
@@ -97,6 +97,16 @@ func TestProviderViewFromEntry_MigratesProviderWideVision(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderViewFromEntryIncludesThinking(t *testing.T) {
|
||||
view := providerViewFromEntry(config.ProviderEntry{
|
||||
Name: "anthropic",
|
||||
Thinking: "ADAPTIVE",
|
||||
}, false, true)
|
||||
if view.Thinking != "adaptive" {
|
||||
t.Fatalf("ProviderView.Thinking = %q, want adaptive", view.Thinking)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderViewFromEntryShowsKeySource(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
t.Setenv("TEST_PROVIDER_KEY_SOURCE", "")
|
||||
@@ -120,6 +130,43 @@ func TestProviderViewFromEntryShowsKeySource(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsExposesEffectiveSandboxWriteRoots(t *testing.T) {
|
||||
home := isolateDesktopUserDirs(t)
|
||||
project := robustTempDir(t)
|
||||
cfg := config.LoadForEdit(config.UserConfigPath())
|
||||
cfg.Sandbox.AllowWrite = []string{
|
||||
"${HOME}/.m2",
|
||||
"${HOME}/.m2/repository",
|
||||
}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
app.tabs = map[string]*WorkspaceTab{
|
||||
"project": {ID: "project", Scope: "project", WorkspaceRoot: project, Ready: true},
|
||||
}
|
||||
app.activeTabID = "project"
|
||||
|
||||
got := app.Settings().Sandbox
|
||||
if got.EffectiveWorkspaceRoot != project {
|
||||
t.Fatalf("EffectiveWorkspaceRoot = %q, want %q", got.EffectiveWorkspaceRoot, project)
|
||||
}
|
||||
// Settings expose expanded configured roots; the writer confiner normalizes
|
||||
// separators later when enforcing them.
|
||||
want := []string{
|
||||
project,
|
||||
home + "/.m2",
|
||||
home + "/.m2/repository",
|
||||
}
|
||||
if !reflect.DeepEqual(got.EffectiveWriteRoots, want) {
|
||||
t.Fatalf("EffectiveWriteRoots = %v, want %v", got.EffectiveWriteRoots, want)
|
||||
}
|
||||
if !reflect.DeepEqual(got.AllowWrite, cfg.Sandbox.AllowWrite) {
|
||||
t.Fatalf("AllowWrite = %v, want raw configured paths %v", got.AllowWrite, cfg.Sandbox.AllowWrite)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderViewFromEntryExposesNoAuthAvailability(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
t.Setenv("LOCAL_API_KEY", "")
|
||||
@@ -382,6 +429,60 @@ func TestSaveProviderFiltersNonChatModels(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveProviderPersistsThinkingOverride(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
app := NewApp()
|
||||
if err := app.SaveProvider(ProviderView{
|
||||
Name: "glm-proxy",
|
||||
Kind: "openai",
|
||||
BaseURL: "https://proxy.example.com/v1",
|
||||
Models: []string{"glm-4.5-air"},
|
||||
APIKeyEnv: "GLM_PROXY_API_KEY",
|
||||
Thinking: "DISABLED",
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveProvider: %v", err)
|
||||
}
|
||||
|
||||
cfg := config.LoadForEdit(config.UserConfigPath())
|
||||
got, ok := cfg.Provider("glm-proxy")
|
||||
if !ok {
|
||||
t.Fatal("saved provider not found")
|
||||
}
|
||||
if got.Thinking != "disabled" {
|
||||
t.Fatalf("saved provider thinking = %q, want disabled", got.Thinking)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveProviderPersistsAuthHeader(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
app := NewApp()
|
||||
if err := app.SaveProvider(ProviderView{
|
||||
Name: "minimax-global-anthropic",
|
||||
Kind: "anthropic",
|
||||
BaseURL: "https://api.minimax.io/anthropic",
|
||||
Models: []string{"MiniMax-M3"},
|
||||
APIKeyEnv: "MINIMAX_API_KEY",
|
||||
AuthHeader: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveProvider: %v", err)
|
||||
}
|
||||
|
||||
cfg := config.LoadForEdit(config.UserConfigPath())
|
||||
got, ok := cfg.Provider("minimax-global-anthropic")
|
||||
if !ok {
|
||||
t.Fatal("saved provider not found")
|
||||
}
|
||||
if !got.AuthHeader {
|
||||
t.Fatal("saved provider auth_header = false, want true")
|
||||
}
|
||||
view := providerViewFromEntry(*got, false, true)
|
||||
if !view.AuthHeader {
|
||||
t.Fatal("provider view authHeader = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveProviderPersistsCustomEndpointURLs(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
@@ -427,6 +528,76 @@ func TestSaveProviderPersistsCustomEndpointURLs(t *testing.T) {
|
||||
t.Fatalf("Settings providers missing sub2api: %+v", view.Providers)
|
||||
}
|
||||
|
||||
func TestSaveProviderPreservesHiddenProviderFields(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
cfg := config.LoadForEdit(config.UserConfigPath())
|
||||
cfg.Providers = []config.ProviderEntry{{
|
||||
Name: "custom",
|
||||
Kind: "openai",
|
||||
BaseURL: "https://proxy.example.com/v1",
|
||||
Models: []string{"model-a", "model-b"},
|
||||
Default: "model-a",
|
||||
APIKeyEnv: "CUSTOM_API_KEY",
|
||||
Price: &provider.Pricing{Input: 1, Output: 2, Currency: "$"},
|
||||
Prices: map[string]*provider.Pricing{"model-b": {Input: 3, Output: 4, Currency: "$"}},
|
||||
Thinking: "adaptive",
|
||||
Effort: "high",
|
||||
VisionDetail: "low",
|
||||
ExtraBody: map[string]any{"enable_thinking": true},
|
||||
NoProxy: true,
|
||||
}}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("SaveTo: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
settings := app.Settings()
|
||||
var view ProviderView
|
||||
found := false
|
||||
for _, p := range settings.Providers {
|
||||
if p.Name == "custom" {
|
||||
view = p
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("Settings providers missing custom: %+v", settings.Providers)
|
||||
}
|
||||
if view.ExtraBody["enable_thinking"] != true {
|
||||
t.Fatalf("settings extra_body = %+v, want enable_thinking=true", view.ExtraBody)
|
||||
}
|
||||
|
||||
if err := app.SaveProvider(view); err != nil {
|
||||
t.Fatalf("SaveProvider: %v", err)
|
||||
}
|
||||
|
||||
gotCfg := config.LoadForEdit(config.UserConfigPath())
|
||||
got, ok := gotCfg.Provider("custom")
|
||||
if !ok {
|
||||
t.Fatal("saved provider not found")
|
||||
}
|
||||
if got.Price == nil || got.Price.Input != 1 || got.Price.Output != 2 || got.Price.Currency != "$" {
|
||||
t.Fatalf("provider-wide price = %+v, want preserved", got.Price)
|
||||
}
|
||||
if got.Prices["model-b"] == nil || got.Prices["model-b"].Input != 3 || got.Prices["model-b"].Output != 4 || got.Prices["model-b"].Currency != "$" {
|
||||
t.Fatalf("per-model prices = %+v, want model-b price preserved", got.Prices)
|
||||
}
|
||||
if got.Thinking != "adaptive" || got.Effort != "high" {
|
||||
t.Fatalf("thinking/effort = %q/%q, want adaptive/high", got.Thinking, got.Effort)
|
||||
}
|
||||
if got.VisionDetail != "low" {
|
||||
t.Fatalf("vision_detail = %q, want low", got.VisionDetail)
|
||||
}
|
||||
if got.ExtraBody["enable_thinking"] != true {
|
||||
t.Fatalf("extra_body = %+v, want enable_thinking=true", got.ExtraBody)
|
||||
}
|
||||
if !got.NoProxy {
|
||||
t.Fatal("no_proxy = false, want preserved true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveProviderClearsProviderWideVisionForPerModelSelection(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
@@ -658,7 +829,7 @@ func TestSetReasoningLanguageUpdatesLiveTabControllers(t *testing.T) {
|
||||
}
|
||||
|
||||
userComposed := userCtrl.Compose("hi")
|
||||
if !strings.Contains(userComposed, "Simplified Chinese") {
|
||||
if !strings.Contains(userComposed, "简体中文") {
|
||||
t.Fatalf("user-level tab Compose = %q, want zh reasoning language", userComposed)
|
||||
}
|
||||
projectComposed := projectCtrl.Compose("hi")
|
||||
|
||||
@@ -10,6 +10,40 @@ import (
|
||||
"reasonix/internal/event"
|
||||
)
|
||||
|
||||
type closeTrackingSink struct {
|
||||
closed atomic.Bool
|
||||
}
|
||||
|
||||
func (s *closeTrackingSink) Emit(event.Event) {}
|
||||
|
||||
func (s *closeTrackingSink) Close() {
|
||||
s.closed.Store(true)
|
||||
}
|
||||
|
||||
func TestTabEventSinkSetBotSinkClosesPreviousSink(t *testing.T) {
|
||||
sink := &tabEventSink{}
|
||||
first := &closeTrackingSink{}
|
||||
second := &closeTrackingSink{}
|
||||
|
||||
sink.SetBotSink(first)
|
||||
if first.closed.Load() {
|
||||
t.Fatal("newly attached sink was closed")
|
||||
}
|
||||
|
||||
sink.SetBotSink(second)
|
||||
if !first.closed.Load() {
|
||||
t.Fatal("previous sink was not closed when replaced")
|
||||
}
|
||||
if second.closed.Load() {
|
||||
t.Fatal("replacement sink was closed too early")
|
||||
}
|
||||
|
||||
sink.SetBotSink(nil)
|
||||
if !second.closed.Load() {
|
||||
t.Fatal("second sink was not closed when cleared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTabEventSinkDoesNotBlockOnRuntimeEventsEmit(t *testing.T) {
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
|
||||
@@ -122,7 +122,7 @@ api_key_env = "PROJECT_API_KEY"
|
||||
if !got.Supported || got.Current != "auto" || got.Default != "high" {
|
||||
t.Fatalf("EffortForTab model registry = %+v, want supported auto/high", got)
|
||||
}
|
||||
wantLevels := []string{"auto", "high", "max"}
|
||||
wantLevels := []string{"auto", "disabled", "high", "max"}
|
||||
if len(got.Levels) != len(wantLevels) {
|
||||
t.Fatalf("levels = %v, want %v", got.Levels, wantLevels)
|
||||
}
|
||||
|
||||
+897
-114
File diff suppressed because it is too large.
Load diff
@@ -9,6 +9,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
@@ -679,6 +680,42 @@ func TestBuildTabControllerReusesOpenSessionPathRuntime(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTabControllerBlocksWhenSessionLeaseHeld(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := desktopSessionDir(globalTabWorkspaceRoot())
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "leased.jsonl")
|
||||
if err := os.WriteFile(path, nil, 0o644); err != nil {
|
||||
t.Fatalf("write placeholder session: %v", err)
|
||||
}
|
||||
lease, err := agent.TryAcquireSessionLease(path)
|
||||
if err != nil {
|
||||
t.Fatalf("TryAcquireSessionLease: %v", err)
|
||||
}
|
||||
defer lease.Release()
|
||||
|
||||
app := NewApp()
|
||||
tab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "leased")
|
||||
tab.SessionPath = path
|
||||
tab.sink = &tabEventSink{tabID: "leased", app: app}
|
||||
app.tabs[tab.ID] = tab
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
|
||||
app.buildTabController(tab)
|
||||
if tab.Ctrl != nil {
|
||||
t.Fatalf("tab controller = %T, want nil when lease is held", tab.Ctrl)
|
||||
}
|
||||
if !tab.Ready {
|
||||
t.Fatal("tab should be ready with startup error")
|
||||
}
|
||||
if !strings.Contains(tab.StartupErr, agent.ErrSessionLeaseHeld.Error()) {
|
||||
t.Fatalf("startup error = %q, want lease-held error", tab.StartupErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenGlobalTabResolvesTopicToLatestSessionRuntime(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := desktopSessionDir(globalTabWorkspaceRoot())
|
||||
|
||||
+339
-5
@@ -1567,6 +1567,53 @@ func TestAutoTitleTopicStripsReasoningLanguagePrefix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoTitleTopicRefreshesOnThirdUserTurn(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
topic, err := NewApp().CreateTopic("project", projectRoot, "")
|
||||
if err != nil {
|
||||
t.Fatalf("create topic: %v", err)
|
||||
}
|
||||
sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
firstTurn := strings.Join([]string{
|
||||
`{"role":"user","content":"帮我看看"}`,
|
||||
`{"role":"assistant","content":"可以"}`,
|
||||
}, "\n") + "\n"
|
||||
if err := os.WriteFile(sessionPath, []byte(firstTurn), 0o644); err != nil {
|
||||
t.Fatalf("write first session: %v", err)
|
||||
}
|
||||
|
||||
title, updated := autoTitleTopicFromSession(projectRoot, topic.ID, sessionPath)
|
||||
if !updated || title != "帮我看看" {
|
||||
t.Fatalf("first auto title = %q updated=%v, want 帮我看看/true", title, updated)
|
||||
}
|
||||
|
||||
thirdTurn := strings.Join([]string{
|
||||
`{"role":"user","content":"帮我看看"}`,
|
||||
`{"role":"assistant","content":"可以"}`,
|
||||
`{"role":"user","content":"继续"}`,
|
||||
`{"role":"assistant","content":"继续分析"}`,
|
||||
`{"role":"user","content":"实现自动更新会话标题"}`,
|
||||
`{"role":"assistant","content":"已实现"}`,
|
||||
}, "\n") + "\n"
|
||||
if err := os.WriteFile(sessionPath, []byte(thirdTurn), 0o644); err != nil {
|
||||
t.Fatalf("write third session: %v", err)
|
||||
}
|
||||
|
||||
title, updated = autoTitleTopicFromSession(projectRoot, topic.ID, sessionPath)
|
||||
if !updated || title != "实现自动更新会话标题" {
|
||||
t.Fatalf("third-turn auto title = %q updated=%v, want 实现自动更新会话标题/true", title, updated)
|
||||
}
|
||||
if got := loadTopicTitle(projectRoot, topic.ID); got != "实现自动更新会话标题" {
|
||||
t.Fatalf("stored title = %q, want 实现自动更新会话标题", got)
|
||||
}
|
||||
meta := loadTopicAutoTitleMeta(projectRoot)[topic.ID]
|
||||
if meta.Stage != 3 {
|
||||
t.Fatalf("auto title stage = %d, want 3", meta.Stage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoTitleDoesNotOverrideManualTopicTitle(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
@@ -1592,6 +1639,50 @@ func TestAutoTitleDoesNotOverrideManualTopicTitle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoTitleDoesNotOverrideManualSessionTitle(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
topic, err := NewApp().CreateTopic("project", projectRoot, "")
|
||||
if err != nil {
|
||||
t.Fatalf("create topic: %v", err)
|
||||
}
|
||||
sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"讲讲这个代码库的架构"}`+"\n"), 0o644); err != nil {
|
||||
t.Fatalf("write session: %v", err)
|
||||
}
|
||||
if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{CustomTitle: "手动会话标题"}); err != nil {
|
||||
t.Fatalf("save branch meta: %v", err)
|
||||
}
|
||||
|
||||
if title, updated := autoTitleTopicFromSession(projectRoot, topic.ID, sessionPath); updated || title != "" {
|
||||
t.Fatalf("manual session title should not auto-update, title=%q updated=%v", title, updated)
|
||||
}
|
||||
if got := loadTopicTitle(projectRoot, topic.ID); got != defaultTopicTitle {
|
||||
t.Fatalf("stored title = %q, want default title", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameTopicBlankKeepsManualTitleSource(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
app := NewApp()
|
||||
topic, err := app.CreateTopic("project", projectRoot, "")
|
||||
if err != nil {
|
||||
t.Fatalf("create topic: %v", err)
|
||||
}
|
||||
if err := app.RenameTopic(topic.ID, " "); err != nil {
|
||||
t.Fatalf("rename blank topic: %v", err)
|
||||
}
|
||||
if got := loadTopicTitle(projectRoot, topic.ID); got != defaultTopicTitle {
|
||||
t.Fatalf("stored title = %q, want %q", got, defaultTopicTitle)
|
||||
}
|
||||
if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceManual {
|
||||
t.Fatalf("title source = %q, want manual", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrashTopicMovesRelatedSessionsToTrash(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
@@ -1956,7 +2047,7 @@ func TestTrashTopicCancelsRunningSessionRuntime(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrashTopicFallbackCreatesIndexedTopic(t *testing.T) {
|
||||
func TestTrashTopicFallbackCreatesUnindexedBlank(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
@@ -1998,14 +2089,257 @@ func TestTrashTopicFallbackCreatesIndexedTopic(t *testing.T) {
|
||||
t.Fatalf("fallback should create exactly one visible tab, got %d", len(app.tabs))
|
||||
}
|
||||
for id, tab := range app.tabs {
|
||||
if strings.TrimSpace(tab.TopicID) == "" {
|
||||
t.Fatalf("fallback tab %q has empty topic ID", id)
|
||||
if strings.TrimSpace(tab.TopicID) != "" {
|
||||
t.Fatalf("fallback tab %q topic ID = %q, want transient unindexed blank", id, tab.TopicID)
|
||||
}
|
||||
if strings.TrimSpace(tab.SessionPath) == "" {
|
||||
t.Fatalf("fallback tab %q has no precreated session path", id)
|
||||
}
|
||||
f := loadProjectsFile()
|
||||
if len(f.Projects) != 1 || !containsDesktopString(f.Projects[0].Topics, tab.TopicID) {
|
||||
t.Fatalf("fallback topic %q was not indexed in project topics %#v", tab.TopicID, f.Projects)
|
||||
if len(f.Projects) != 1 || containsDesktopString(f.Projects[0].Topics, topicID) {
|
||||
t.Fatalf("deleted topic %q should be removed without indexing a replacement: %#v", topicID, f.Projects)
|
||||
}
|
||||
}
|
||||
nodes := app.ListProjectTree()
|
||||
if len(nodes) != 1 || len(nodes[0].Children) != 0 {
|
||||
t.Fatalf("transient fallback blank should stay out of project tree: %+v", nodes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransientFallbackIndexesOnFirstUserTurn(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
topicID := "topic_only"
|
||||
if err := addProject(projectRoot, ""); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
if err := setTopicTitle(projectRoot, topicID, "Only topic"); err != nil {
|
||||
t.Fatalf("set topic title: %v", err)
|
||||
}
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
sessionPath := writeTopicSession(t, dir, "only-topic.jsonl", topicID, "Only topic", projectRoot)
|
||||
ctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: "test", WorkspaceRoot: projectRoot})
|
||||
defer ctrl.Close()
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{
|
||||
"only": {
|
||||
ID: "only",
|
||||
Scope: "project",
|
||||
WorkspaceRoot: projectRoot,
|
||||
TopicID: topicID,
|
||||
TopicTitle: "Only topic",
|
||||
Ctrl: ctrl,
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
},
|
||||
},
|
||||
tabOrder: []string{"only"},
|
||||
activeTabID: "only",
|
||||
}
|
||||
|
||||
if err := app.TrashTopic(topicID); err != nil {
|
||||
t.Fatalf("TrashTopic: %v", err)
|
||||
}
|
||||
var fallback *WorkspaceTab
|
||||
for _, tab := range app.tabs {
|
||||
fallback = tab
|
||||
}
|
||||
if fallback == nil {
|
||||
t.Fatal("fallback tab missing")
|
||||
}
|
||||
if fallback.TopicID != "" {
|
||||
t.Fatalf("fallback topic before first turn = %q, want empty", fallback.TopicID)
|
||||
}
|
||||
|
||||
app.ensureTabTopicIndexedForUserTurn(fallback)
|
||||
if strings.TrimSpace(fallback.TopicID) == "" {
|
||||
t.Fatal("first user turn should assign a topic ID")
|
||||
}
|
||||
f := loadProjectsFile()
|
||||
if len(f.Projects) != 1 || !containsDesktopString(f.Projects[0].Topics, fallback.TopicID) {
|
||||
t.Fatalf("first user turn should index fallback topic %q: %#v", fallback.TopicID, f.Projects)
|
||||
}
|
||||
meta, ok, err := agent.LoadBranchMeta(fallback.SessionPath)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta(%q): ok=%v err=%v", fallback.SessionPath, ok, err)
|
||||
}
|
||||
if meta.TopicID != fallback.TopicID || meta.TopicTitle != defaultTopicTitle {
|
||||
t.Fatalf("fallback session meta = %+v, want topic %q title %q", meta, fallback.TopicID, defaultTopicTitle)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransientFallbackDiscardedWhenSingleSurfaceNavigatesAway(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
if err := addProject(projectRoot, ""); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
dir := desktopSessionDir(projectRoot)
|
||||
transientPath, err := createEmptySessionFile(dir, "test")
|
||||
if err != nil {
|
||||
t.Fatalf("create transient session: %v", err)
|
||||
}
|
||||
if err := agent.SaveBranchMetaPreserveUpdated(transientPath, agent.BranchMeta{
|
||||
Scope: "project",
|
||||
WorkspaceRoot: projectRoot,
|
||||
}); err != nil {
|
||||
t.Fatalf("save transient meta: %v", err)
|
||||
}
|
||||
|
||||
targetTopicID := "topic_target"
|
||||
if err := setTopicTitle(projectRoot, targetTopicID, "Target"); err != nil {
|
||||
t.Fatalf("set target topic title: %v", err)
|
||||
}
|
||||
targetPath := writeTopicSession(t, dir, "target.jsonl", targetTopicID, "Target", projectRoot)
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{
|
||||
"transient": {
|
||||
ID: "transient",
|
||||
Scope: "project",
|
||||
WorkspaceRoot: projectRoot,
|
||||
TopicTitle: defaultTopicTitle,
|
||||
SessionPath: transientPath,
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
},
|
||||
"target": {
|
||||
ID: "target",
|
||||
Scope: "project",
|
||||
WorkspaceRoot: projectRoot,
|
||||
TopicID: targetTopicID,
|
||||
TopicTitle: "Target",
|
||||
SessionPath: targetPath,
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
},
|
||||
},
|
||||
tabOrder: []string{"transient", "target"},
|
||||
activeTabID: "transient",
|
||||
}
|
||||
|
||||
if _, err := app.keepOnlyVisibleTab("target"); err != nil {
|
||||
t.Fatalf("keepOnlyVisibleTab: %v", err)
|
||||
}
|
||||
if _, ok := app.tabs["transient"]; ok {
|
||||
t.Fatal("transient tab should be removed after single-surface navigation")
|
||||
}
|
||||
if _, err := os.Stat(transientPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("transient session artifact should be removed, stat err = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(agent.BranchMetaPath(transientPath)); !os.IsNotExist(err) {
|
||||
t.Fatalf("transient session meta should be removed, stat err = %v", err)
|
||||
}
|
||||
f := loadProjectsFile()
|
||||
if len(f.Projects) != 1 || containsDesktopString(f.Projects[0].Topics, "") {
|
||||
t.Fatalf("transient blank should not be indexed in project topics: %#v", f.Projects)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseTabDiscardsUnusedTransientBlankSession(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
dir := desktopSessionDir(projectRoot)
|
||||
transientPath, err := createEmptySessionFile(dir, "test")
|
||||
if err != nil {
|
||||
t.Fatalf("create transient session: %v", err)
|
||||
}
|
||||
if err := agent.SaveBranchMetaPreserveUpdated(transientPath, agent.BranchMeta{
|
||||
Scope: "project",
|
||||
WorkspaceRoot: projectRoot,
|
||||
}); err != nil {
|
||||
t.Fatalf("save transient meta: %v", err)
|
||||
}
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{
|
||||
"transient": {
|
||||
ID: "transient",
|
||||
Scope: "project",
|
||||
WorkspaceRoot: projectRoot,
|
||||
TopicTitle: defaultTopicTitle,
|
||||
SessionPath: transientPath,
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
},
|
||||
"other": {
|
||||
ID: "other",
|
||||
Scope: "global",
|
||||
TopicID: "topic_other",
|
||||
TopicTitle: "Other",
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
},
|
||||
},
|
||||
tabOrder: []string{"transient", "other"},
|
||||
activeTabID: "transient",
|
||||
}
|
||||
|
||||
if err := app.CloseTab("transient"); err != nil {
|
||||
t.Fatalf("CloseTab: %v", err)
|
||||
}
|
||||
if _, ok := app.tabs["transient"]; ok {
|
||||
t.Fatal("transient tab should be closed")
|
||||
}
|
||||
if _, err := os.Stat(transientPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("transient session artifact should be removed, stat err = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(agent.BranchMetaPath(transientPath)); !os.IsNotExist(err) {
|
||||
t.Fatalf("transient session meta should be removed, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseTabKeepsIndexedBlankSession(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
topicID := "topic_indexed_blank"
|
||||
if err := addProject(projectRoot, ""); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
if err := setTopicTitle(projectRoot, topicID, defaultTopicTitle); err != nil {
|
||||
t.Fatalf("set topic title: %v", err)
|
||||
}
|
||||
dir := desktopSessionDir(projectRoot)
|
||||
indexedPath, err := createEmptySessionFile(dir, "test")
|
||||
if err != nil {
|
||||
t.Fatalf("create indexed blank session: %v", err)
|
||||
}
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{
|
||||
"indexed": {
|
||||
ID: "indexed",
|
||||
Scope: "project",
|
||||
WorkspaceRoot: projectRoot,
|
||||
TopicID: topicID,
|
||||
TopicTitle: defaultTopicTitle,
|
||||
SessionPath: indexedPath,
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
},
|
||||
"other": {
|
||||
ID: "other",
|
||||
Scope: "global",
|
||||
TopicID: "topic_other",
|
||||
TopicTitle: "Other",
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
},
|
||||
},
|
||||
tabOrder: []string{"indexed", "other"},
|
||||
activeTabID: "indexed",
|
||||
}
|
||||
|
||||
if err := app.CloseTab("indexed"); err != nil {
|
||||
t.Fatalf("CloseTab: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(indexedPath); err != nil {
|
||||
t.Fatalf("indexed blank session should be preserved, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrashTopicTrashConflictKeepsRunningRuntime(t *testing.T) {
|
||||
|
||||
+16
-14
@@ -33,36 +33,38 @@ import (
|
||||
// has no Wails dependency so the logic is unit-tested directly; updater_app.go is
|
||||
// the thin Wails binding that wires these into App methods and progress events.
|
||||
|
||||
// Manifest endpoints — R2 CDN first (fast, especially in CN), GitHub releases as
|
||||
// fallback. The build channel picks the rolling pointer so a canary build polls
|
||||
// the canary line and a stable build polls latest; the two never cross.
|
||||
// Manifest endpoints — R2 CDN first (fast, especially in CN), then the crash
|
||||
// worker release gateway. The build channel picks the rolling pointer so a
|
||||
// canary build polls the canary line and a stable build polls latest; the two
|
||||
// never cross. The gateway deliberately avoids GitHub's repository-wide
|
||||
// /releases/latest shortcut because CLI tags (v*) and desktop tags (desktop-v*)
|
||||
// are separate release lines in the same repo.
|
||||
const (
|
||||
r2Base = "https://dl.reasonix.io"
|
||||
ghReleasesBase = "https://github.com/esengine/reasonix/releases"
|
||||
httpTimeout = 15 * time.Second
|
||||
r2Base = "https://dl.reasonix.io"
|
||||
releaseGatewayBase = "https://crash.reasonix.io/v1/desktop/releases"
|
||||
downloadPageURL = "https://reasonix.io/#start"
|
||||
httpTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
// manifestEndpoints returns the primary (R2) then fallback (GitHub) manifest URLs
|
||||
// for the running build's channel.
|
||||
func manifestEndpoints() []string {
|
||||
if channel == "canary" {
|
||||
// Canary publishes only to R2 (no GitHub release), so there is no
|
||||
// GitHub fallback for this channel.
|
||||
return []string{r2Base + "/canary/latest.json"}
|
||||
return []string{
|
||||
r2Base + "/canary/latest.json",
|
||||
releaseGatewayBase + "/canary/latest.json",
|
||||
}
|
||||
}
|
||||
return []string{
|
||||
r2Base + "/latest/latest.json",
|
||||
ghReleasesBase + "/latest/download/latest.json",
|
||||
releaseGatewayBase + "/stable/latest.json",
|
||||
}
|
||||
}
|
||||
|
||||
// downloadPage is the human-facing releases page shown when self-update is
|
||||
// unavailable (macOS) or the manifest omits its own link.
|
||||
func downloadPage() string {
|
||||
if channel == "canary" {
|
||||
return ghReleasesBase // lists pre-releases too
|
||||
}
|
||||
return ghReleasesBase + "/latest"
|
||||
return downloadPageURL
|
||||
}
|
||||
|
||||
// UpdateInfo is the CheckUpdate result that drives the frontend's update banner.
|
||||
|
||||
+13
-1
@@ -27,6 +27,7 @@ func (a *App) Version() string { return version }
|
||||
func (a *App) CheckUpdate() (*UpdateInfo, error) {
|
||||
c, err := httpClient()
|
||||
if err != nil {
|
||||
a.recordUpdateError(err)
|
||||
return &UpdateInfo{
|
||||
Current: version,
|
||||
Channel: channel,
|
||||
@@ -41,6 +42,7 @@ func (a *App) CheckUpdate() (*UpdateInfo, error) {
|
||||
defer cancel()
|
||||
m, err := fetchManifest(ctx, c)
|
||||
if err != nil {
|
||||
a.recordUpdateError(err)
|
||||
return &UpdateInfo{
|
||||
Current: version,
|
||||
Channel: channel,
|
||||
@@ -55,7 +57,7 @@ func (a *App) CheckUpdate() (*UpdateInfo, error) {
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
// OpenDownloadPage opens the releases page in the browser — the macOS manual-update
|
||||
// OpenDownloadPage opens the install page in the browser — the macOS manual-update
|
||||
// path and a fallback link elsewhere.
|
||||
func (a *App) OpenDownloadPage() {
|
||||
page := downloadPage()
|
||||
@@ -208,6 +210,16 @@ func (a *App) emitProgress(phase string, received, total int64, errMsg string) {
|
||||
|
||||
// failUpdate emits an error progress event and returns the error to the caller.
|
||||
func (a *App) failUpdate(err error) error {
|
||||
a.recordUpdateError(err)
|
||||
a.emitProgress("error", 0, 0, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) recordUpdateError(err error) {
|
||||
if err == nil || version == "dev" {
|
||||
return
|
||||
}
|
||||
if m := a.metrics.Load(); m != nil {
|
||||
m.inc("updater_error", errorClass(err.Error()))
|
||||
}
|
||||
}
|
||||
+13
-2
@@ -98,6 +98,14 @@ func TestChannelSelectsDistinctPointers(t *testing.T) {
|
||||
if !strings.Contains(stable[0], "/latest/latest.json") {
|
||||
t.Errorf("stable primary = %q, want the latest/ pointer", stable[0])
|
||||
}
|
||||
if stable[1] != releaseGatewayBase+"/stable/latest.json" {
|
||||
t.Errorf("stable fallback = %q, want the release gateway", stable[1])
|
||||
}
|
||||
for _, u := range append(stable, canary...) {
|
||||
if strings.Contains(u, "/releases/latest") {
|
||||
t.Errorf("manifest endpoint uses GitHub's repository-wide latest release: %q", u)
|
||||
}
|
||||
}
|
||||
for _, u := range canary {
|
||||
if strings.Contains(u, "/latest/") {
|
||||
t.Errorf("canary endpoint hits the stable latest/ pointer: %q", u)
|
||||
@@ -106,8 +114,11 @@ func TestChannelSelectsDistinctPointers(t *testing.T) {
|
||||
if !strings.Contains(canary[0], "/canary/latest.json") {
|
||||
t.Errorf("canary primary = %q, want the canary/ pointer", canary[0])
|
||||
}
|
||||
if downloadPage() == (ghReleasesBase + "/latest") {
|
||||
t.Error("canary download page should not be the stable latest releases page")
|
||||
if canary[1] != releaseGatewayBase+"/canary/latest.json" {
|
||||
t.Errorf("canary fallback = %q, want the release gateway", canary[1])
|
||||
}
|
||||
if strings.Contains(downloadPage(), "/releases/latest") {
|
||||
t.Errorf("download page should not use GitHub's repository-wide latest release: %q", downloadPage())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package main
|
||||
|
||||
import "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
|
||||
// MinimiseMainWindow backs the Windows frameless titlebar controls.
|
||||
func (a *App) MinimiseMainWindow() {
|
||||
if a.ctx == nil {
|
||||
return
|
||||
}
|
||||
runtime.WindowMinimise(a.ctx)
|
||||
}
|
||||
|
||||
// ToggleMaximiseMainWindow backs the Windows frameless titlebar controls.
|
||||
func (a *App) ToggleMaximiseMainWindow() {
|
||||
if a.ctx == nil {
|
||||
return
|
||||
}
|
||||
runtime.WindowToggleMaximise(a.ctx)
|
||||
}
|
||||
|
||||
// IsMainWindowMaximised reports the native maximise state for the Windows
|
||||
// frameless titlebar controls.
|
||||
func (a *App) IsMainWindowMaximised() bool {
|
||||
if a.ctx == nil {
|
||||
return false
|
||||
}
|
||||
return runtime.WindowIsMaximised(a.ctx)
|
||||
}
|
||||
|
||||
// CloseMainWindow preserves Reasonix's configured close behavior for the
|
||||
// Windows frameless titlebar close button.
|
||||
func (a *App) CloseMainWindow() {
|
||||
if a.ctx == nil {
|
||||
return
|
||||
}
|
||||
if a.beforeClose(a.ctx) {
|
||||
return
|
||||
}
|
||||
a.forceQuit.Store(true)
|
||||
runtime.Quit(a.ctx)
|
||||
}
|
||||
+192
-30
@@ -6,13 +6,14 @@
|
||||
·
|
||||
<a href="./GUIDE.md">General guide</a>
|
||||
|
||||
> For desktop users. This guide explains how to connect Feishu, Lark, WeChat, and QQ
|
||||
> bots, how to use Reasonix from IM, and how approvals, Ask questions, YOLO, and
|
||||
> bot commands work.
|
||||
> For desktop and CLI users. This guide explains how to connect Feishu, Lark,
|
||||
> WeChat, and QQ bots, how to use Reasonix from IM, and how approvals, Ask
|
||||
> questions, YOLO, and bot commands work.
|
||||
|
||||
## Contents
|
||||
|
||||
- [What the bot does](#what-the-bot-does)
|
||||
- [Where it runs](#where-it-runs)
|
||||
- [Connect the four channels](#connect-the-four-channels)
|
||||
- [Run the bot headlessly](#run-the-bot-headlessly)
|
||||
- [Usage flow](#usage-flow)
|
||||
@@ -24,9 +25,10 @@
|
||||
|
||||
## What the bot does
|
||||
|
||||
After a bot is connected, you can send Reasonix messages from Feishu, Lark, or
|
||||
WeChat. The desktop app handles the model, tools, permissions, sandboxing, and
|
||||
local context, then sends progress and results back to the IM channel.
|
||||
After a bot is connected, you can send Reasonix messages from Feishu, Lark,
|
||||
WeChat, or QQ. The desktop app or `reasonix bot start` process handles the
|
||||
model, tools, permissions, sandboxing, and local context, then sends progress
|
||||
and results back to the IM channel.
|
||||
|
||||
Common uses:
|
||||
|
||||
@@ -37,6 +39,26 @@ Common uses:
|
||||
- Open the matching desktop IM session to inspect context, cost, tokens, and tool
|
||||
traces.
|
||||
|
||||
## Where it runs
|
||||
|
||||
The bot gateway is a shared Go runtime. The same core behavior works on
|
||||
Windows, macOS, and Linux; platform differences mostly come from each IM
|
||||
provider's credentials, network reachability, callback/WebSocket setup, and
|
||||
saved local account state.
|
||||
|
||||
There are two supported entry points:
|
||||
|
||||
- **Desktop runtime**: configure bots in **Settings -> Bots**. The desktop app
|
||||
starts the gateway, keeps status in the app, persists per-connection tool
|
||||
approval mode changes, and lets you open matching local IM sessions.
|
||||
- **CLI runtime**: run `reasonix bot start` for a headless long-lived process.
|
||||
It uses the same config, allowlist, routes, queue settings, pairing store,
|
||||
adapters, and project/session index as the desktop runtime.
|
||||
|
||||
The normal `reasonix run` command does not automatically start the IM gateway.
|
||||
Remote bot behavior is active only while the desktop bot runtime is running or
|
||||
while a `reasonix bot start` process is alive.
|
||||
|
||||
## Connect the four channels
|
||||
|
||||
Open the Reasonix desktop app and go to **Settings -> Bots**. In **Add IM Bot**,
|
||||
@@ -77,7 +99,14 @@ flowchart LR
|
||||
|
||||
Feishu and Lark share the same capability set, but they are saved as separate
|
||||
connections. You can give them different models, working directories, or tool
|
||||
approval modes.
|
||||
approval modes. Bot text replies are sent as standalone Interactive Card JSON
|
||||
2.0 markdown, which avoids Feishu/Lark platform quote prefixes while preserving
|
||||
CommonMark formatting. If a card is too large for the platform limit, Reasonix
|
||||
falls back to plain text automatically.
|
||||
|
||||
For webhook mode, configure a verification token. Incoming webhook events are
|
||||
verified fail-closed: an empty or missing configured token rejects callers
|
||||
instead of silently opening the webhook.
|
||||
|
||||
### WeChat
|
||||
|
||||
@@ -87,8 +116,9 @@ approval modes.
|
||||
4. Wait until the page shows the connection as connected.
|
||||
5. Send the WeChat bot a message.
|
||||
|
||||
WeChat does not provide interactive card buttons here, so approvals and Ask
|
||||
questions are handled through text commands.
|
||||
WeChat does not provide interactive card buttons here, so approvals use numeric
|
||||
or text commands. Ask questions can be answered by replying with normal text,
|
||||
option numbers, or `/answer <id> <answer>`.
|
||||
|
||||
### QQ
|
||||
|
||||
@@ -99,13 +129,16 @@ questions are handled through text commands.
|
||||
5. Send the QQ bot a message.
|
||||
|
||||
QQ Bot uses the official QQ Bot platform API. It supports inline keyboard
|
||||
buttons for approvals. Ask questions are sent as text; for single-choice
|
||||
questions you can reply with the option number, or use `/answer <id> <option>`.
|
||||
When a button expires or the platform reports an action failure, copy the ID
|
||||
shown in the card and send the equivalent text command.
|
||||
buttons for approvals. Ask questions are sent as text; reply with normal text,
|
||||
option numbers, or `/answer <id> <answer>`. When a button expires or the
|
||||
platform reports an action failure, copy the ID shown in the card and send the
|
||||
equivalent text command.
|
||||
|
||||
QQ does not support QR-code scanning for connection setup. You must
|
||||
configure the App ID and App Secret manually.
|
||||
QQ does not support QR-code scanning for connection setup. You must configure
|
||||
the App ID and App Secret manually. The adapter reads only the configured
|
||||
`app_secret_env` value; it does not fall back to an unrelated `QQ_SECRET`
|
||||
environment variable. QQ and WeChat HTTP calls use bounded clients so a stalled
|
||||
provider request cannot block the gateway indefinitely.
|
||||
|
||||
## Run the bot headlessly
|
||||
|
||||
@@ -114,7 +147,8 @@ runtime itself can also run as a long-lived headless gateway:
|
||||
|
||||
```sh
|
||||
reasonix bot doctor
|
||||
reasonix bot start --channels feishu,lark,weixin --dir /path/to/project
|
||||
reasonix bot doctor --deep
|
||||
reasonix bot start --channels qq,feishu,lark,weixin --dir /path/to/project
|
||||
```
|
||||
|
||||
Use `--channels` to choose which configured IM inputs to accept. `feishu` and
|
||||
@@ -135,17 +169,96 @@ The headless gateway uses the same config records as the desktop app:
|
||||
- `workspace_root`, `model`, and `tool_approval_mode` can be set per
|
||||
connection. This lets different IM channels route to different local projects
|
||||
or approval postures.
|
||||
- `access` can also be set per connection with `enabled`, `allow_all`,
|
||||
`pairing_enabled`, `users`, `groups`, `admins`, and `approvers`. When a
|
||||
connection has active access settings, they are checked before the legacy
|
||||
global `[bot.allowlist]`.
|
||||
- `[[bot.routes]]` adds finer routing by connection, platform, chat type, chat
|
||||
ID, user ID, or thread ID. Empty match fields are wildcards; the first matching
|
||||
route wins and can override `workspace_root`, `model`, and
|
||||
`tool_approval_mode`.
|
||||
- `session_mappings` are filled from inbound messages with the remote chat ID
|
||||
and scope. The desktop UI can open the matching conversation once the mapping
|
||||
also has a local `session_id` target, such as a saved `path:` session target
|
||||
from a desktop-managed bot runtime or a manually configured mapping.
|
||||
- The bot's project/session index is intentionally bounded to configured
|
||||
`workspace_root` values, route workspaces, active bot sessions, and saved
|
||||
`session_mappings`. Commands such as `/use project` and `/attach session`
|
||||
can only jump to those indexed targets; arbitrary local directories are not
|
||||
accepted from IM text.
|
||||
|
||||
Access control is still mandatory. Either enable an allowlist under
|
||||
`[bot.allowlist]` with at least one relevant platform user ID, or set
|
||||
`allow_all = true` deliberately. Group IDs are optional additional scoping for
|
||||
group chats; they do not replace the required user allowlist. Remote users go
|
||||
through the same controller, permission policy, tool approval mode, and sandbox
|
||||
rules as local desktop or CLI turns.
|
||||
Access control is still mandatory. New desktop-created bots should normally set
|
||||
access inside that bot's own detail panel, which saves to `[[bot.connections]]`
|
||||
or `[bot.qq].access`. The legacy global `[bot.allowlist]` remains a fallback for
|
||||
older configs and for connections without active per-bot access. You can
|
||||
deliberately set `allow_all = true`, or enable `pairing_enabled` for a single
|
||||
bot / `[bot.pairing]` globally so an unknown DM sender receives a one-time
|
||||
pairing code. That code must be approved locally with
|
||||
`reasonix bot pairing approve <code>` before the sender can drive the bot; when
|
||||
the request is tied to a connection, approval adds the sender to that
|
||||
connection's access list. Users listed in `admins` / `approvers` or the legacy
|
||||
`*_admins` / `*_approvers` also receive base bot admission, so they do not need
|
||||
to be duplicated in `users` / `*_users`. Group chats are not opened by DM
|
||||
pairing or role admission; group IDs remain an additional narrowing layer.
|
||||
Use these commands to manage pending requests:
|
||||
|
||||
```sh
|
||||
reasonix bot pairing list
|
||||
reasonix bot pairing approve CODE
|
||||
reasonix bot pairing reject CODE
|
||||
```
|
||||
|
||||
If `qq_admins`, `feishu_admins`, `weixin_admins`, or the matching
|
||||
`*_approvers` lists are configured, `/yolo` and `/mode` are admin-only while
|
||||
`/projects`, `/use project`, `/sessions`, `/attach session`, and `/search all`
|
||||
are also admin-only. `/approve` and `/deny` require an approver or admin. When
|
||||
no role lists are set, existing allowlisted users keep the previous command
|
||||
behavior for compatibility. Remote users go through the same controller,
|
||||
permission policy, tool approval mode, and sandbox rules as local desktop or CLI
|
||||
turns.
|
||||
|
||||
```toml
|
||||
[bot.allowlist]
|
||||
enabled = true
|
||||
feishu_users = ["ou_member"]
|
||||
feishu_admins = ["ou_admin"]
|
||||
feishu_approvers = ["ou_approver"]
|
||||
```
|
||||
|
||||
`ignore_self_messages = true` is enabled by default. The gateway remembers the
|
||||
platform `message_id` values it just sent and ignores matching echo events. If a
|
||||
platform does not echo the same message ID reliably, configure the bot's own user
|
||||
IDs under `[bot.self_user_ids]` as a second layer of loop protection. `/status`
|
||||
also includes the current queue mode and adapter health, such as
|
||||
`feishu-lark=running` or `weixin-weixin=degraded`.
|
||||
|
||||
The optional `[bot.control]` section exposes a local loopback HTTP API and is
|
||||
disabled by default. When enabled, `token_env` must point to an environment
|
||||
variable and every request must include `Authorization: Bearer <token>`. The
|
||||
server only binds to `localhost`, `127.0.0.1`, or `::1`. Current endpoints are
|
||||
`GET /status` for session and adapter health snapshots, `GET /metrics` for
|
||||
Prometheus text metrics, and `POST /send` for sending text or media through a
|
||||
configured connection.
|
||||
|
||||
Example:
|
||||
|
||||
```sh
|
||||
export REASONIX_BOT_CONTROL_TOKEN="change-me"
|
||||
|
||||
curl -H "Authorization: Bearer $REASONIX_BOT_CONTROL_TOKEN" \
|
||||
http://127.0.0.1:37913/status
|
||||
|
||||
curl -X POST http://127.0.0.1:37913/send \
|
||||
-H "Authorization: Bearer $REASONIX_BOT_CONTROL_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"connection_id": "feishu-lark",
|
||||
"domain": "lark",
|
||||
"chat_id": "oc_xxx",
|
||||
"chat_type": "dm",
|
||||
"text": "hello from local control API"
|
||||
}'
|
||||
```
|
||||
|
||||
## Usage flow
|
||||
|
||||
@@ -153,7 +266,7 @@ rules as local desktop or CLI turns.
|
||||
sequenceDiagram
|
||||
participant U as "User"
|
||||
participant IM as "Feishu / Lark / WeChat / QQ"
|
||||
participant R as "Reasonix desktop"
|
||||
participant R as "Reasonix desktop or bot start"
|
||||
participant T as "Local tools and model"
|
||||
|
||||
U->>IM: "Send a request"
|
||||
@@ -195,8 +308,8 @@ without exposing real account IDs, local paths, or private chat content.
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Feishu | Scan to create a PersonalAgent | Interactive card buttons, or commands | Interactive card buttons, or commands | Feishu workspaces, DMs, and groups |
|
||||
| Lark | Scan to create a PersonalAgent | Interactive card buttons, or commands | Interactive card buttons, or commands | International Lark workspaces |
|
||||
| WeChat | Scan with WeChat | Reply `1` / `2`, or commands | Single-choice questions can use a number, or commands | Lightweight personal/mobile testing |
|
||||
| QQ | Manual setup (App ID + App Secret) | Inline keyboard buttons, numeric replies, or commands | Single-choice questions can use a number, or commands | QQ groups, DMs, and official QQ Bot platform |
|
||||
| WeChat | Scan with WeChat | Reply `1` / `2`, or commands | Reply with normal text, option numbers, or commands | Lightweight personal/mobile testing |
|
||||
| QQ | Manual setup (App ID + App Secret) | Inline keyboard buttons, numeric replies, or commands | Reply with normal text, option numbers, or commands | QQ groups, DMs, and official QQ Bot platform |
|
||||
|
||||
Feishu and Lark card buttons are converted into commands such as
|
||||
`/approve <id>`, `/deny <id>`, or `/answer <id> <option>`. QQ approval buttons
|
||||
@@ -210,7 +323,7 @@ These commands work in Feishu, Lark, WeChat, and QQ.
|
||||
| Command | Purpose | Example |
|
||||
| --- | --- | --- |
|
||||
| `/help` | Show available commands | `/help` |
|
||||
| `/status` | Show active tasks, retained sessions, and tool approval mode | `/status` |
|
||||
| `/status` | Show active tasks, queue state, tool approval mode, and adapter health | `/status` |
|
||||
| `/stop` | Stop the current task | `/stop` |
|
||||
| `/new` | Start a fresh session | `/new` |
|
||||
| `/reset` | Reset the current session | `/reset` |
|
||||
@@ -225,14 +338,63 @@ These commands work in Feishu, Lark, WeChat, and QQ.
|
||||
| `/mode yolo` | Switch to YOLO | `/mode yolo` |
|
||||
| `/mode ask` | Switch to Ask mode | `/mode ask` |
|
||||
| `/mode auto` | Switch to Auto mode | `/mode auto` |
|
||||
| `/queue status` | Show the current queue mode | `/queue status` |
|
||||
| `/queue steer` | Treat mid-run messages as guidance for the current task | `/queue steer` |
|
||||
| `/queue followup` | Queue mid-run messages as later turns | `/queue followup` |
|
||||
| `/queue collect` | Merge queued messages into one later turn | `/queue collect` |
|
||||
| `/queue interrupt` | Cancel the current task and keep the newest message | `/queue interrupt` |
|
||||
| `/projects [query]` | List indexed project workspaces | `/projects reasonix` |
|
||||
| `/use project <id\|name>` | Route this remote session to an indexed project | `/use project p1` |
|
||||
| `/use project default` | Clear the project override and return to configured routing | `/use project default` |
|
||||
| `/sessions search <query>` | Search indexed desktop/bot sessions | `/sessions search release bug` |
|
||||
| `/attach session <id\|query>` | Continue this remote session from an indexed `path:` transcript | `/attach session s1` |
|
||||
| `/search all <query>` | Search file contents across indexed project roots | `/search all TODO` |
|
||||
|
||||
Shortcut replies:
|
||||
|
||||
- When an approval is pending, reply `1` to approve and `2` to deny.
|
||||
- When a single-choice Ask question is pending, reply with the option number.
|
||||
- When an Ask question is pending, reply with any normal non-slash text. Option
|
||||
numbers still work for choice questions.
|
||||
- Slash commands such as `/stop`, `/mode`, or `/answer ...` are not captured as
|
||||
Ask shortcut replies.
|
||||
- If there is no pending operation, `1` / `2` are treated as normal text or
|
||||
produce guidance.
|
||||
|
||||
The default queue mode is `steer`: when the same session is already running, a
|
||||
new message is injected as mid-turn guidance instead of waiting for the whole
|
||||
turn to finish. `queue_cap` and `queue_drop` bound backlog growth in config.
|
||||
`reasonix bot doctor --deep` reports queue, pairing, and role diagnostics.
|
||||
|
||||
Queue modes:
|
||||
|
||||
- `steer`: mid-run messages become guidance for the current turn when possible.
|
||||
- `followup`: mid-run messages are queued as later turns.
|
||||
- `collect`: queued messages are merged into one later turn.
|
||||
- `interrupt`: the active turn is canceled and the newest message is kept as the
|
||||
next turn.
|
||||
|
||||
Project and session navigation:
|
||||
|
||||
- `/projects [query]` lists workspaces from configured bot routes, connection
|
||||
workspaces, active bot sessions, and saved session mappings.
|
||||
- `/use project <id|name>` pins the current remote session to one indexed
|
||||
project. `/use project default` clears the override.
|
||||
- `/sessions search <query>` searches indexed desktop and bot session metadata.
|
||||
- `/attach session <id|query>` continues the remote session from an indexed
|
||||
`path:` transcript.
|
||||
- `/search all <query>` searches file contents across indexed project roots.
|
||||
Reasonix uses `rg` when available and falls back to a bounded Go scanner.
|
||||
|
||||
These navigation commands never accept arbitrary paths typed from IM. They only
|
||||
jump to indexed targets and, when role lists are configured, require an admin.
|
||||
|
||||
When an adapter supplies media URLs, the gateway downloads those files into the
|
||||
current workspace's `.reasonix/attachments` directory and passes them to
|
||||
Reasonix as `@.reasonix/attachments/...` references. If an attachment cannot be
|
||||
saved, the bot sends a short warning and continues with the available text. The
|
||||
built-in Feishu, Weixin, and QQ adapters currently focus on text events; ordinary
|
||||
IM attachment extraction can be added at the adapter layer.
|
||||
|
||||
## Approvals and YOLO
|
||||
|
||||
Reasonix bots use the same permission system as the desktop app. Ask mode is the
|
||||
@@ -299,11 +461,11 @@ You may need to bind again if:
|
||||
| Symptom | What to check |
|
||||
| --- | --- |
|
||||
| QR code says the link expired | Generate a new QR code in Settings; QR codes expire (Feishu, Lark, WeChat only — QQ uses manual setup and has no QR code). |
|
||||
| Connected but no reply | Make sure the Reasonix desktop app is running, the bot connection is enabled, and the sender ID is allowlisted or access is open. |
|
||||
| Connected but no reply | Make sure the desktop bot runtime or `reasonix bot start` process is running, the bot connection is enabled, and the sender ID is allowlisted, paired, or access is open. |
|
||||
| Feishu or Lark button action fails | Send the text command from the card, such as `/approve <id>` or `/deny <id>`. |
|
||||
| QQ button action fails | Same as Feishu/Lark — send the text command from the card, such as `/approve <id>` or `/deny <id>`. |
|
||||
| WeChat reply `1` does nothing | Numeric shortcuts only work when an approval or single-choice Ask is pending; use the full command if needed. |
|
||||
| QQ reply `1` does nothing | Same as WeChat — numeric shortcuts only work when an approval or single-choice Ask is pending; use the full command if needed. |
|
||||
| WeChat reply `1` does nothing | Numeric shortcuts only work when an approval or Ask is pending; use the full command if needed. |
|
||||
| QQ reply `1` does nothing | Same as WeChat — numeric shortcuts only work when an approval or Ask is pending; use the full command if needed. |
|
||||
| Need to confirm the current mode | Send `/status` or `/yolo status`. |
|
||||
| Need a fresh context | Send `/new` or `/reset`. |
|
||||
| Need to stop the current task | Send `/stop`. |
|
||||
|
||||
+171
-23
@@ -6,12 +6,13 @@
|
||||
·
|
||||
<a href="./GUIDE.zh-CN.md">通用指南</a>
|
||||
|
||||
> 面向桌面端用户。本文说明如何连接飞书、Lark、微信和 QQ 机器人,如何在 IM
|
||||
> 里使用 Reasonix,以及审批、问答、YOLO 和常用命令的交互方式。
|
||||
> 面向桌面端和 CLI 用户。本文说明如何连接飞书、Lark、微信和 QQ 机器人,
|
||||
> 如何在 IM 里使用 Reasonix,以及审批、问答、YOLO 和常用命令的交互方式。
|
||||
|
||||
## 目录
|
||||
|
||||
- [能做什么](#能做什么)
|
||||
- [在哪里运行](#在哪里运行)
|
||||
- [连接四个渠道](#连接四个渠道)
|
||||
- [无界面运行 Bot](#无界面运行-bot)
|
||||
- [使用流程](#使用流程)
|
||||
@@ -23,8 +24,9 @@
|
||||
|
||||
## 能做什么
|
||||
|
||||
连接机器人后,你可以在飞书、Lark 或微信里给 Reasonix 发消息,让桌面端
|
||||
Reasonix 在本机执行同一套模型、工具、权限与沙盒逻辑。
|
||||
连接机器人后,你可以在飞书、Lark、微信或 QQ 里给 Reasonix 发消息,让桌面端
|
||||
Reasonix 或 `reasonix bot start` 进程在本机执行同一套模型、工具、权限与
|
||||
沙盒逻辑。
|
||||
|
||||
典型场景:
|
||||
|
||||
@@ -34,6 +36,24 @@ Reasonix 在本机执行同一套模型、工具、权限与沙盒逻辑。
|
||||
- 对临时测试任务开启 YOLO,跳过普通工具审批。
|
||||
- 打开桌面端对应 IM 会话,继续查看上下文、成本、tokens 和工具轨迹。
|
||||
|
||||
## 在哪里运行
|
||||
|
||||
Bot gateway 是一套共享的 Go runtime。核心行为在 Windows、macOS 和 Linux
|
||||
上都生效;实际差异主要来自各 IM 平台的凭据、网络、回调或 WebSocket 配置,
|
||||
以及本机保存的账号状态。
|
||||
|
||||
目前有两个入口:
|
||||
|
||||
- **桌面端 runtime**:在 **设置 -> 机器人** 中配置。桌面端会启动 gateway,
|
||||
在应用内维护状态,持久化每个连接的工具审批模式变化,并允许打开匹配的
|
||||
本地 IM 会话。
|
||||
- **CLI runtime**:执行 `reasonix bot start` 启动无界面长期进程。它复用
|
||||
与桌面端相同的配置、白名单、路由、队列设置、配对存储、适配器和
|
||||
项目/会话索引。
|
||||
|
||||
普通 `reasonix run` 不会自动启动 IM 网关。只有桌面端 bot runtime 正在运行,
|
||||
或存在一个存活的 `reasonix bot start` 进程时,远端 IM bot 能力才会生效。
|
||||
|
||||
## 连接四个渠道
|
||||
|
||||
打开桌面端 Reasonix,进入 **设置 -> 机器人**。在 **添加 IM Bot** 区域选择
|
||||
@@ -73,7 +93,12 @@ flowchart LR
|
||||
5. 给 Lark Bot 发送消息。
|
||||
|
||||
飞书和 Lark 使用同一套能力,但作为两个独立连接保存。你可以给它们设置不同
|
||||
模型、工作目录或工具审批模式。
|
||||
模型、工作目录或工具审批模式。Bot 文本回复会以独立 Interactive Card JSON
|
||||
2.0 markdown 发送,避免飞书/Lark 平台级引用前缀,同时保留 CommonMark
|
||||
格式;如果卡片超过平台限制,Reasonix 会自动降级为纯文本。
|
||||
|
||||
Webhook 模式需要配置 verification token。传入事件会 fail-closed 校验:如果
|
||||
配置中的 token 为空或缺失,调用方会被拒绝,不会静默开放 webhook。
|
||||
|
||||
### 微信
|
||||
|
||||
@@ -83,7 +108,8 @@ flowchart LR
|
||||
4. 等待页面显示已连接。
|
||||
5. 给微信 Bot 发送消息。
|
||||
|
||||
微信没有交互卡片按钮,因此审批和问答主要通过文字命令完成。
|
||||
微信没有交互卡片按钮,因此审批通过数字或文字命令完成。Ask 问题可以直接
|
||||
回复普通文本、选项编号,或使用 `/answer <id> <回答>`。
|
||||
|
||||
### QQ
|
||||
|
||||
@@ -94,11 +120,13 @@ flowchart LR
|
||||
5. 给 QQ Bot 发送消息。
|
||||
|
||||
QQ Bot 使用官方 QQ Bot 平台 API。它支持内联键盘按钮来完成审批。
|
||||
Ask 问答会以文字形式发送;单选题可以直接回复选项编号,也可以用
|
||||
`/answer <id> <选项>`。当按钮过期或平台提示操作失败时,可以直接
|
||||
复制卡片里的 ID,用文字命令继续。
|
||||
Ask 问答会以文字形式发送;可以直接回复普通文本、选项编号,或使用
|
||||
`/answer <id> <回答>`。当按钮过期或平台提示操作失败时,可以直接复制
|
||||
卡片里的 ID,用文字命令继续。
|
||||
|
||||
QQ 不支持扫码连接,必须手动配置 App ID 和 App Secret。
|
||||
QQ 不支持扫码连接,必须手动配置 App ID 和 App Secret。适配器只读取配置的
|
||||
`app_secret_env`,不会再回退读取无关的 `QQ_SECRET` 环境变量。QQ 和微信的
|
||||
HTTP 调用使用带超时的 client,避免平台请求卡住后无限阻塞 gateway。
|
||||
|
||||
## 无界面运行 Bot
|
||||
|
||||
@@ -107,7 +135,8 @@ QQ 不支持扫码连接,必须手动配置 App ID 和 App Secret。
|
||||
|
||||
```sh
|
||||
reasonix bot doctor
|
||||
reasonix bot start --channels feishu,lark,weixin --dir /path/to/project
|
||||
reasonix bot doctor --deep
|
||||
reasonix bot start --channels qq,feishu,lark,weixin --dir /path/to/project
|
||||
```
|
||||
|
||||
`--channels` 用来选择接受哪些已配置的 IM 输入。`feishu` 和 `lark` 会选择对应
|
||||
@@ -124,15 +153,88 @@ Bot。`--dir` 用来把远端消息绑定到某个项目工作区,`--model`
|
||||
密钥仍保存在环境变量或 Reasonix 用户凭据中。
|
||||
- `workspace_root`、`model` 和 `tool_approval_mode` 可以按连接单独设置,
|
||||
因此不同 IM 渠道可以路由到不同本地项目或审批模式。
|
||||
- `access` 也可以按连接单独设置,包括 `enabled`、`allow_all`、
|
||||
`pairing_enabled`、`users`、`groups`、`admins` 和 `approvers`。当某个连接
|
||||
有启用中的 access 配置时,会优先检查该连接自己的访问控制,再退回旧的全局
|
||||
`[bot.allowlist]`。
|
||||
- `[[bot.routes]]` 可以继续按远端连接、平台、会话类型、chat ID、用户 ID
|
||||
或 thread ID 做更细粒度路由;空匹配字段表示通配,按配置顺序第一个命中。
|
||||
命中后可覆盖 `workspace_root`、`model` 和 `tool_approval_mode`。
|
||||
- `session_mappings` 会根据收到的远端消息自动填充远端 chat ID 和作用域。
|
||||
只有当该映射同时具备本地 `session_id` 目标时,桌面端才能打开对应会话;
|
||||
例如桌面端托管的 Bot runtime 保存了 `path:` 会话目标,或用户手动配置了
|
||||
映射目标。
|
||||
- Bot 的项目/会话索引只来自已配置的 `workspace_root`、route workspace、
|
||||
当前活跃 bot 会话,以及已保存的 `session_mappings`。`/use project` 和
|
||||
`/attach session` 只能跳到这些已索引目标;IM 文本里临时输入的任意本地
|
||||
目录不会被接受。
|
||||
|
||||
访问控制仍然是必需项。你需要在 `[bot.allowlist]` 下为对应平台至少配置一个
|
||||
用户 ID,或者有意设置 `allow_all = true`。群 ID 是群聊里的额外收窄条件,
|
||||
不能替代用户白名单。远端用户进入的是同一个 Reasonix controller、权限策略、
|
||||
工具审批模式和沙盒边界,和本地桌面端或 CLI 回合一致。
|
||||
访问控制仍然是必需项。桌面端新建的 Bot 通常建议在该 Bot 自己的详情面板里
|
||||
设置谁可以使用,这会保存到 `[[bot.connections]]` 或 `[bot.qq].access`。
|
||||
旧的全局 `[bot.allowlist]` 仍然作为兼容兜底,适用于旧配置或没有启用单
|
||||
Bot access 的连接。你可以有意设置 `allow_all = true`,也可以为单个 Bot
|
||||
启用 `pairing_enabled`,或全局启用 `[bot.pairing]`,让未知私聊用户先收到
|
||||
一次性配对码。配对码需要在本机执行 `reasonix bot pairing approve <code>`
|
||||
后才会放行;如果该请求带有连接 ID,批准后会把发送者加入对应 Bot 自己的
|
||||
access 名单。列在 `admins` / `approvers` 或旧的 `*_admins` /
|
||||
`*_approvers` 里的用户也会获得基础 bot 准入,不需要再重复写进 `users` /
|
||||
`*_users`。群聊不会因为私聊配对或角色准入自动开放,群 ID 仍是额外收窄
|
||||
条件。常用配对管理命令:
|
||||
|
||||
```sh
|
||||
reasonix bot pairing list
|
||||
reasonix bot pairing approve CODE
|
||||
reasonix bot pairing reject CODE
|
||||
```
|
||||
|
||||
如果配置了 `qq_admins`、`feishu_admins`、`weixin_admins` 或对应
|
||||
`*_approvers`,`/yolo`、`/mode` 等运行模式命令只允许 admin 使用;
|
||||
`/projects`、`/use project`、`/sessions`、`/attach session` 和
|
||||
`/search all` 也只允许 admin 使用。`/approve` 和 `/deny` 只允许 approver
|
||||
或 admin 使用。没有配置角色列表时,为兼容旧配置,已允许的用户保持原有
|
||||
命令能力。远端用户进入的是同一个 Reasonix controller、权限策略、工具
|
||||
审批模式和沙盒边界,和本地桌面端或 CLI 回合一致。
|
||||
|
||||
```toml
|
||||
[bot.allowlist]
|
||||
enabled = true
|
||||
feishu_users = ["ou_member"]
|
||||
feishu_admins = ["ou_admin"]
|
||||
feishu_approvers = ["ou_approver"]
|
||||
```
|
||||
|
||||
默认启用 `ignore_self_messages = true`。网关会记录刚发出的平台
|
||||
`message_id`,并忽略平台回传的同 ID 消息;如果某个平台不会稳定回传同一
|
||||
消息 ID,可以在 `[bot.self_user_ids]` 里配置 bot 自己的用户 ID 作为第二层
|
||||
回声防护。`/status` 会显示当前会话队列模式和各连接的健康状态,例如
|
||||
`feishu-lark=running` 或 `weixin-weixin=degraded`。
|
||||
|
||||
可选的 `[bot.control]` 提供本机 loopback HTTP API,默认关闭。启用后必须
|
||||
设置 `token_env` 对应的环境变量,所有请求都需要
|
||||
`Authorization: Bearer <token>`;地址只能绑定 `localhost`、`127.0.0.1`
|
||||
或 `::1`。当前接口包括 `GET /status`(会话与连接健康快照)和
|
||||
`GET /metrics`(Prometheus 文本指标)以及 `POST /send`(向指定连接发送
|
||||
文本或媒体消息)。
|
||||
|
||||
示例:
|
||||
|
||||
```sh
|
||||
export REASONIX_BOT_CONTROL_TOKEN="change-me"
|
||||
|
||||
curl -H "Authorization: Bearer $REASONIX_BOT_CONTROL_TOKEN" \
|
||||
http://127.0.0.1:37913/status
|
||||
|
||||
curl -X POST http://127.0.0.1:37913/send \
|
||||
-H "Authorization: Bearer $REASONIX_BOT_CONTROL_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"connection_id": "feishu-lark",
|
||||
"domain": "lark",
|
||||
"chat_id": "oc_xxx",
|
||||
"chat_type": "dm",
|
||||
"text": "hello from local control API"
|
||||
}'
|
||||
```
|
||||
|
||||
## 使用流程
|
||||
|
||||
@@ -140,7 +242,7 @@ Bot。`--dir` 用来把远端消息绑定到某个项目工作区,`--model`
|
||||
sequenceDiagram
|
||||
participant U as "用户"
|
||||
participant IM as "飞书 / Lark / 微信 / QQ"
|
||||
participant R as "Reasonix 桌面端"
|
||||
participant R as "Reasonix 桌面端或 bot start"
|
||||
participant T as "本机工具与模型"
|
||||
|
||||
U->>IM: "发送需求"
|
||||
@@ -180,8 +282,8 @@ sequenceDiagram
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 飞书 | 扫码创建 PersonalAgent | 交互卡片按钮,也可用命令 | 交互卡片按钮,也可用命令 | 国内飞书工作流、群聊或个人助手 |
|
||||
| Lark | 扫码创建 PersonalAgent | 交互卡片按钮,也可用命令 | 交互卡片按钮,也可用命令 | 国际版 Lark 工作流 |
|
||||
| 微信 | 微信扫码登录 | 回复 `1` / `2` 或命令 | 单选可直接回复编号,也可用命令 | 微信个人测试、轻量移动触发 |
|
||||
| QQ | 手动配置(App ID + App Secret) | 内联键盘按钮、数字回复或命令 | 单选可直接回复编号,也可用命令 | QQ 群聊、个人会话和官方 QQ Bot 平台 |
|
||||
| 微信 | 微信扫码登录 | 回复 `1` / `2` 或命令 | 回复普通文本、选项编号或命令 | 微信个人测试、轻量移动触发 |
|
||||
| QQ | 手动配置(App ID + App Secret) | 内联键盘按钮、数字回复或命令 | 回复普通文本、选项编号或命令 | QQ 群聊、个人会话和官方 QQ Bot 平台 |
|
||||
|
||||
飞书和 Lark 的卡片按钮会在后台转换为命令,例如 `/approve <id>`、
|
||||
`/deny <id>` 或 `/answer <id> <选项>`。QQ 的审批按钮也是如此。
|
||||
@@ -195,7 +297,7 @@ sequenceDiagram
|
||||
| 命令 | 作用 | 示例 |
|
||||
| --- | --- | --- |
|
||||
| `/help` | 查看可用命令 | `/help` |
|
||||
| `/status` | 查看活跃任务、保留会话数和工具审批模式 | `/status` |
|
||||
| `/status` | 查看活跃任务、队列、工具审批模式和连接健康 | `/status` |
|
||||
| `/stop` | 停止当前任务 | `/stop` |
|
||||
| `/new` | 开始新会话 | `/new` |
|
||||
| `/reset` | 重置当前会话 | `/reset` |
|
||||
@@ -210,13 +312,59 @@ sequenceDiagram
|
||||
| `/mode yolo` | 切换到 YOLO | `/mode yolo` |
|
||||
| `/mode ask` | 切换到询问模式 | `/mode ask` |
|
||||
| `/mode auto` | 切换到自动模式 | `/mode auto` |
|
||||
| `/queue status` | 查看当前队列模式 | `/queue status` |
|
||||
| `/queue steer` | 运行中消息作为当前任务补充 | `/queue steer` |
|
||||
| `/queue followup` | 运行中消息排队为后续回合 | `/queue followup` |
|
||||
| `/queue collect` | 合并排队消息为一个后续回合 | `/queue collect` |
|
||||
| `/queue interrupt` | 取消当前任务并处理最新消息 | `/queue interrupt` |
|
||||
| `/projects [关键词]` | 查看已索引项目工作区 | `/projects reasonix` |
|
||||
| `/use project <id\|名称>` | 将当前远端会话路由到已索引项目 | `/use project p1` |
|
||||
| `/use project default` | 清除项目覆盖,恢复配置路由 | `/use project default` |
|
||||
| `/sessions search <关键词>` | 搜索已索引桌面/bot 会话 | `/sessions search 发布 bug` |
|
||||
| `/attach session <id\|关键词>` | 从已索引 `path:` transcript 继续当前远端会话 | `/attach session s1` |
|
||||
| `/search all <关键词>` | 跨已索引项目检索文件内容 | `/search all TODO` |
|
||||
|
||||
快捷回复:
|
||||
|
||||
- 有待审批操作时,回复 `1` 表示批准,回复 `2` 表示拒绝。
|
||||
- 有单选 Ask 问题时,可以直接回复选项编号。
|
||||
- 有待处理 Ask 问题时,可以直接回复任意普通非 slash 文本;选择题仍然可以
|
||||
直接回复选项编号。
|
||||
- `/stop`、`/mode`、`/answer ...` 等 slash 命令不会被 Ask 快捷回复截获。
|
||||
- 如果没有待处理操作,`1` / `2` 会被当作普通消息或收到提示。
|
||||
|
||||
默认队列模式是 `steer`:同一会话正在运行时,新消息会作为当前任务的
|
||||
mid-turn guidance 注入,而不是等完整回合结束。`queue_cap` 和 `queue_drop`
|
||||
可以在配置里限制排队堆积;`reasonix bot doctor --deep` 会显示当前队列、
|
||||
配对和角色诊断信息。
|
||||
|
||||
队列模式:
|
||||
|
||||
- `steer`:运行中的消息会尽量作为当前回合的补充指导。
|
||||
- `followup`:运行中的消息排队成为后续回合。
|
||||
- `collect`:把排队消息合并成一个后续回合。
|
||||
- `interrupt`:取消当前回合,并保留最新消息作为下一回合。
|
||||
|
||||
项目与会话跳转:
|
||||
|
||||
- `/projects [关键词]` 会列出来自 bot route、连接工作区、活跃 bot 会话和
|
||||
已保存 session mapping 的工作区。
|
||||
- `/use project <id|名称>` 会把当前远端会话固定到某个已索引项目;
|
||||
`/use project default` 会清除覆盖。
|
||||
- `/sessions search <关键词>` 搜索已索引桌面端和 bot 会话元数据。
|
||||
- `/attach session <id|关键词>` 从已索引 `path:` transcript 继续当前远端
|
||||
会话。
|
||||
- `/search all <关键词>` 跨已索引项目根目录检索文件内容;有 `rg` 时优先用
|
||||
`rg`,否则使用带边界的 Go fallback scanner。
|
||||
|
||||
这些跳转命令不会接受 IM 中临时输入的任意本地路径,只能跳到索引内目标;
|
||||
配置了角色列表时,这些命令需要 admin 权限。
|
||||
|
||||
当适配器提供媒体 URL 时,gateway 会把文件下载到当前工作区的
|
||||
`.reasonix/attachments`,并以 `@.reasonix/attachments/...` 形式传给
|
||||
Reasonix。保存失败的附件会在 IM 中提示,文本内容仍会继续处理。内置
|
||||
Feishu、Weixin、QQ 适配器当前仍以文本事件为主,普通 IM 附件抽取可以继续在
|
||||
适配器层补齐。
|
||||
|
||||
## 审批与 YOLO
|
||||
|
||||
Reasonix 的机器人沿用桌面端权限系统。默认是询问模式:写文件、执行命令等
|
||||
@@ -277,11 +425,11 @@ YOLO 的边界很重要:
|
||||
| 现象 | 可以检查 |
|
||||
| --- | --- |
|
||||
| 扫码提示链接失效 | 回到设置页重新生成二维码;二维码有有效期(飞书、Lark、微信;QQ 不使用扫码,请检查手动配置)。 |
|
||||
| 已连接但没有回复 | 确认桌面端 Reasonix 正在运行,Bot 连接已开启,用户 ID 在白名单内或允许所有人。 |
|
||||
| 已连接但没有回复 | 确认桌面端 bot runtime 或 `reasonix bot start` 进程正在运行,Bot 连接已开启,用户 ID 在白名单内、已配对或允许所有人。 |
|
||||
| 飞书或 Lark 按钮提示失败 | 直接发送卡片里的命令,例如 `/approve <id>` 或 `/deny <id>`。 |
|
||||
| QQ 按钮提示失败 | 与飞书/Lark 相同 —— 直接发送卡片里的命令,例如 `/approve <id>` 或 `/deny <id>`。 |
|
||||
| 微信回复 `1` 没反应 | 只有存在待审批或单选 Ask 时数字快捷回复才生效;也可以使用完整命令。 |
|
||||
| QQ 回复 `1` 没反应 | 与微信相同 —— 只有存在待审批或单选 Ask 时数字快捷回复才生效;也可以使用完整命令。 |
|
||||
| 微信回复 `1` 没反应 | 只有存在待审批或 Ask 时数字快捷回复才生效;也可以使用完整命令。 |
|
||||
| QQ 回复 `1` 没反应 | 与微信相同 —— 只有存在待审批或 Ask 时数字快捷回复才生效;也可以使用完整命令。 |
|
||||
| 想确认当前模式 | 发送 `/status` 或 `/yolo status`。 |
|
||||
| 想重新开始上下文 | 发送 `/new` 或 `/reset`。 |
|
||||
| 想停止当前任务 | 发送 `/stop`。 |
|
||||
|
||||
@@ -117,6 +117,11 @@ complete chat request URL, set `chat_url`; Reasonix will use it directly and wil
|
||||
not append `/chat/completions`. If model discovery needs a separate address, set
|
||||
`models_url`.
|
||||
|
||||
If a gateway requires vendor-specific top-level request body fields, set
|
||||
`extra_body`, for example `extra_body = { enable_thinking = true }`. These values
|
||||
are merged into the OpenAI-compatible chat JSON request body without allowing
|
||||
core fields such as `model`, `messages`, `tools`, or `stream` to be overridden.
|
||||
|
||||
## Global `.env`
|
||||
|
||||
`<Reasonix home>/.env` is the single runtime source for provider API keys saved
|
||||
|
||||
@@ -117,7 +117,7 @@ Hooks 让 Reasonix 在会话、用户输入、工具调用、模型返回、压
|
||||
| `UserPromptSubmit` | 用户输入提交后、本轮模型调用前 | 是 | 无特殊作用 |
|
||||
| `Stop` | 一轮对话结束后 | 否 | 无特殊作用 |
|
||||
| `PostLLMCall` | 模型流式返回完成后,reasoning 入库前 | 否 | exit 0 且 stdout 非空时,用 stdout 替换展示的 reasoning |
|
||||
| `SessionStart` | 会话第一次变为活跃,或 `/new`、清空后新会话开始 | 否 | 无特殊作用 |
|
||||
| `SessionStart` | 会话第一次变为活跃,或 `/new`、清空后新会话开始 | 否 | stdout 会作为下一轮模型上下文注入 |
|
||||
| `SessionEnd` | 会话关闭、切换、`/new`、清空或控制器释放时 | 否 | 无特殊作用 |
|
||||
| `SubagentStop` | 前台 `task` 子代理完成后 | 否 | 无特殊作用 |
|
||||
| `Notification` | 需要用户注意时,例如等待工具审批 | 否 | 无特殊作用 |
|
||||
@@ -166,9 +166,37 @@ stdout 和 stderr 会被捕获、去掉首尾空白,并限制单路输出最
|
||||
特殊 stdout 行为:
|
||||
|
||||
- `PostLLMCall`:exit 0 且 stdout 非空时,stdout 会替换用户看到的 reasoning。若 provider 的 reasoning 带签名,Reasonix 会保留原始 signed reasoning 用于后续请求,同时仍展示 hook 转换后的文本。
|
||||
- `SessionStart`:exit 0 且 stdout 非空时,stdout 会作为一次性 `<hook-context event="SessionStart">` 注入下一轮真实用户输入。纯文本 stdout 会原样作为上下文;也可以输出 Claude Code / Codex 兼容 JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "SessionStart",
|
||||
"additionalContext": "Load the workspace conventions before editing."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`hookEventName` 必须与当前事件一致。该上下文不会写入 system prompt、工具 schema 或项目记忆;它只影响下一轮模型请求。单个 hook 上下文最多保留约 10000 字符,总量最多约 20000 字符,超出会截断并标记。
|
||||
- `PreCompact`:所有非空 stdout 会按换行拼接,作为本次压缩摘要的额外指导。
|
||||
- 其它事件:stdout 只在非通过结果中作为提示文本使用,不会自动进入模型上下文。
|
||||
|
||||
## 示例:SessionStart 注入启动上下文
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"command": "printf '%s\\n' '{\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"additionalContext\":\"Before coding, check the available skills and follow matching workflows.\"}}'"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这适合把插件或工作流的 bootstrap 说明带入会话。比如 Superpowers 不需要内置到 Reasonix;可以让它自己的 `hooks/session-start-codex` 在 `SessionStart` 输出 `additionalContext`,或让插件根目录 `CLAUDE.md` 被插件包兼容层直接作为 `SessionStart` 上下文读取,Reasonix 会在下一轮把这段说明注入模型上下文。插件包兼容层也会读取 `.claude/settings.json` 里的 command hooks,并按同名事件映射到 Reasonix hooks。Reasonix 默认允许 `max_subagent_depth = 2`,因此 Superpowers 的父会话或第一层 workflow subagent 可以再派发 reviewer/implementer subagent;第二层不会继续获得递归委派工具。若要恢复旧的单层边界,设 `agent.max_subagent_depth = 1`。这会改变子代理可见工具面,可能影响子代理请求的 prompt cache,但不会把 Superpowers 写进 Reasonix 的稳定 system prompt。
|
||||
|
||||
## 示例:阻止危险 bash 命令
|
||||
|
||||
`.reasonix/settings.json`:
|
||||
|
||||
+141
-12
@@ -17,6 +17,7 @@
|
||||
- [Configuration paths](./CONFIG_PATHS.md)
|
||||
- [Reasoning language](./REASONING_LANGUAGE.md)
|
||||
- [Custom OpenAI-compatible providers](#custom-openai-compatible-providers)
|
||||
- [Desktop hooks](#desktop-hooks)
|
||||
- [Keyboard shortcuts](#keyboard-shortcuts)
|
||||
- [Permissions & sandbox](#permissions--sandbox)
|
||||
- [Plugins (MCP)](#plugins-mcp)
|
||||
@@ -60,9 +61,11 @@ planner_max_steps = 0 # user/global only; planner read-only tool-call
|
||||
reasoning_language = "auto" # visible reasoning text: auto|zh|en
|
||||
# plan_mode_allowed_tools = ["custom_reader"] # extra read-only custom tools only;
|
||||
# # does not unlock blocked tools or unsafe bash
|
||||
# plan_mode_read_only_commands = ["gh issue view", "gh pr diff"] # extra read-only shell prefixes for planning
|
||||
# planner_model = "deepseek-pro" # optional low-frequency planner
|
||||
# subagent_model = "deepseek-pro" # optional default for runAs=subagent skills
|
||||
# subagent_models = { review = "deepseek-pro", security_review = "deepseek-pro" }
|
||||
# max_subagent_depth = 2 # nested delegation depth; set 1 for the old single-layer boundary
|
||||
auto_plan = "off" # user-level only; off|on; off keeps plan mode manual
|
||||
# auto_plan_classifier = "deepseek-flash" # optional; only borderline tasks call it
|
||||
tool_result_snip_ratio = 0.6 # shorten stale tool output before summary compaction
|
||||
@@ -125,6 +128,18 @@ compatibility escape valve. It never unlocks known blocked plan-mode tools such
|
||||
as `bash`, `task`, writers, installers, or memory mutation tools, and it never
|
||||
bypasses bash's plan-mode safety checks.
|
||||
|
||||
Use `[agent].plan_mode_read_only_commands` when plan-mode research needs a
|
||||
specific shell command that Reasonix cannot classify but you know is read-only,
|
||||
such as `gh issue view` or an internal query CLI. Entries are concrete command
|
||||
prefixes, not tool names: `["gh issue view"]` permits `gh issue view 4572`, while
|
||||
`bash`, `sh`, and other shell interpreters are ignored. Shell operators,
|
||||
redirection, command substitution, background execution, and unsafe built-in
|
||||
command flags remain blocked while planning. In interactive plan mode, Reasonix
|
||||
can also ask you to trust a concrete unknown query prefix the first time it is
|
||||
needed; the persistent choice writes the same
|
||||
`[agent].plan_mode_read_only_commands` entry. Auto/YOLO approval never answers
|
||||
this trust prompt.
|
||||
|
||||
### Environment variables
|
||||
|
||||
Most day-to-day settings belong in `config.toml` or the global Reasonix `.env`
|
||||
@@ -203,7 +218,29 @@ user-global `default_model`.
|
||||
|
||||
In the desktop app, open **Settings -> Model -> Access -> Add model service ->
|
||||
Custom provider** for proxies, aggregators, or self-hosted services that speak
|
||||
the OpenAI-compatible chat API.
|
||||
the OpenAI-compatible chat API or Anthropic-compatible Messages API.
|
||||
|
||||
For common providers, choose **Add model service -> Recommended preset** instead.
|
||||
Reasonix can prefill editable custom-provider entries for Kimi CN, Kimi Global,
|
||||
Kimi Coding Plan, MiMo API, MiMo Anthropic, MiMo Token Plan CN/SGP/AMS and their
|
||||
Anthropic-compatible variants, MiniMax CN/Global API, MiniMax CN/Global
|
||||
Anthropic, GLM CN, Z.AI Global, GLM/Z.AI Coding Plan OpenAI-compatible and
|
||||
Anthropic-compatible endpoints, OpenCode Go, OpenCode Go Anthropic, OpenCode Zen
|
||||
Anthropic, Qwen/DashScope CN/Global, Qwen Coding Plan CN/Global
|
||||
OpenAI-compatible and Anthropic-compatible endpoints, StepFun OpenAI-compatible
|
||||
and Anthropic-compatible endpoints, NovitaAI, GMI Cloud, Vercel AI Gateway,
|
||||
HuggingFace Router, NVIDIA NIM, KiloCode, and Ollama Cloud. Plan names describe
|
||||
the access/payment route; they include CN/Global only when the provider exposes
|
||||
distinct regional endpoints. Kimi Coding Plan is therefore a dedicated plan
|
||||
endpoint, while Kimi direct API is split into CN and Global. The preset path
|
||||
usually needs only the provider API key: the key value is stored in Reasonix home
|
||||
`.env`, while `config.toml` stores the endpoint, model list, key
|
||||
environment-variable name, context window, vision model metadata, proxy bypass
|
||||
for China-only endpoints, MiniMax `reasoning_split`, GLM/MiniMax thinking
|
||||
heuristics, Anthropic-compatible Bearer auth where needed, Ollama Cloud
|
||||
max-effort support, and OpenCode Go per-model reasoning overrides. After adding
|
||||
a preset, open its provider card if you need to change models, headers,
|
||||
endpoint, or compatibility settings.
|
||||
|
||||
Fill **API address** with the provider endpoint that should receive the standard
|
||||
chat path. In this mode Reasonix previews and sends chat requests to:
|
||||
@@ -219,13 +256,90 @@ preview under the field shows the exact request URL that will be used.
|
||||
|
||||
Model discovery uses the API address to try likely model-list URLs such as
|
||||
`/models` and `/v1/models`. If the gateway requires a separate model-list
|
||||
endpoint, open **Advanced settings** and set `models_url`, for example
|
||||
endpoint, open **Compatibility settings** and set `models_url`, for example
|
||||
`https://gateway.example.com/v1/models`. If discovery is not available, fill the
|
||||
model list manually.
|
||||
|
||||
**Full URL** still uses the OpenAI-compatible chat request body. It does not
|
||||
switch the request schema to the OpenAI Responses API.
|
||||
|
||||
### Compatibility settings
|
||||
|
||||
The **Compatibility settings (usually leave unchanged)** section is for gateways
|
||||
whose authentication, model-list endpoint, or reasoning/thinking request shape
|
||||
differs from the normal OpenAI-compatible defaults. Leave these fields at their
|
||||
defaults unless the provider documentation or a proxy error tells you otherwise.
|
||||
For Anthropic-compatible services, such as some coding-plan endpoints, choose
|
||||
**Anthropic-compatible** as the connection protocol before saving.
|
||||
|
||||
| Field | What it controls | When to change it |
|
||||
| --- | --- | --- |
|
||||
| `api_key_env` | The environment-variable name used for this provider's API key. Desktop-saved key values are stored in Reasonix home `.env` under this name; the TOML config stores only the name. | Change it when several providers need distinct keys, or leave it blank for a service that does not require an API key. |
|
||||
| `models_url` | The URL used only for model discovery. Chat requests still use the API address or Full URL above. | Set it when `/models` or `/v1/models` is not where the gateway exposes its model list. |
|
||||
| Extra request headers | Static HTTP headers, one `Header: value` per line. | Use for gateways such as OpenRouter that require `HTTP-Referer`, `X-Title`, or similar site headers. Keep bearer/API keys in the key field instead of duplicating them here. |
|
||||
| Extra request body | A JSON object merged into the top-level chat request body. | Use only for provider-specific flags such as `{"enable_thinking": true}`. Reasonix still owns core fields such as `model`, `messages`, `tools`, `stream`, and `thinking`, and null values are rejected. |
|
||||
| Authorization: Bearer | For Anthropic-compatible providers, sends the saved API key as `Authorization: Bearer <key>` instead of `x-api-key`. | Enable it only when the gateway documents Bearer auth, such as MiniMax Global or Vercel AI Gateway. |
|
||||
| Model capability mode | Which reasoning request protocol Reasonix should use for this provider. | Keep **Auto-detect** unless the gateway is misdetected or the model docs require a specific reasoning format. |
|
||||
| Thinking override | Provider-specific override for `thinking.type`. | Keep **Auto** unless the backend documents `enabled`, `disabled`, or `adaptive`. Unsupported values can make some OpenAI-compatible gateways reject the request. |
|
||||
| Balance URL | Optional endpoint for wallet/balance lookup. | Set it when the provider exposes a balance endpoint and you want the desktop status bar to show it. |
|
||||
| Context window | The maximum number of tokens this provider keeps in context. `0` means provider default. | Set it when the model's real context size differs from Reasonix's default or built-in metadata. |
|
||||
|
||||
Model capability mode options:
|
||||
|
||||
| Option | Effect |
|
||||
| --- | --- |
|
||||
| Auto-detect (recommended) | Reasonix chooses the request shape from model capability metadata and endpoint detection. |
|
||||
| DeepSeek thinking | Uses DeepSeek-style thinking control, including `thinking.type` and DeepSeek-supported reasoning depth. |
|
||||
| OpenAI reasoning | Uses the standard OpenAI-compatible `reasoning_effort` levels. |
|
||||
| Plain chat | Sends no reasoning or thinking control fields. Use this for text-only proxies that reject reasoning parameters. |
|
||||
|
||||
Thinking override options:
|
||||
|
||||
| Option | Effect |
|
||||
| --- | --- |
|
||||
| Auto (provider default) | Does not write an explicit provider-level `thinking` override. Reasonix uses the provider/model default behavior. |
|
||||
| Enabled | Sends `thinking.type = "enabled"` for compatible providers. |
|
||||
| Disabled | Sends `thinking.type = "disabled"` for compatible providers. On DeepSeek-style providers this also avoids sending a reasoning depth hint. |
|
||||
| Adaptive (self-adjusting) | Sends or preserves `thinking.type = "adaptive"` only for providers that document adaptive thinking, such as MiniMax-M3-style endpoints. |
|
||||
|
||||
Some OpenAI-compatible gateways require non-standard top-level request body
|
||||
fields. Add them with `extra_body` on the provider entry:
|
||||
|
||||
```toml
|
||||
[[providers]]
|
||||
name = "spark"
|
||||
kind = "openai"
|
||||
base_url = "https://maas-coding-api.cn-huabei-1.xf-yun.com/v2"
|
||||
models = ["xopglm52"]
|
||||
api_key_env = "SPARK_API_KEY"
|
||||
extra_body = { enable_thinking = true }
|
||||
```
|
||||
|
||||
`extra_body` is merged into the chat JSON request body. Reasonix keeps core
|
||||
fields such as `model`, `messages`, `tools`, `stream`, and `thinking` under its
|
||||
own control.
|
||||
|
||||
## Desktop hooks
|
||||
|
||||
Desktop hooks run local commands at lifecycle events such as `SessionStart`,
|
||||
`UserPromptSubmit`, `PreToolUse`, and `PreCompact`. A successful `SessionStart`
|
||||
hook may write plain text to stdout, or return JSON with
|
||||
`hookSpecificOutput.additionalContext`; Reasonix injects that text once into the
|
||||
next real user turn as `<hook-context event="SessionStart">...</hook-context>`.
|
||||
This is intended for plugin or workflow bootstrap context, including
|
||||
Superpowers-style startup instructions, without baking that workflow into
|
||||
Reasonix's system prompt.
|
||||
|
||||
Plugin packages can provide this startup context through
|
||||
`hooks/session-start-codex` or a plugin-root `CLAUDE.md`. Claude-style
|
||||
`.claude/settings.json` command hooks are also mapped to matching Reasonix hook
|
||||
events.
|
||||
|
||||
The injected hook context is dynamic current-turn context. It does not change
|
||||
the stable system prompt, memory prefix, or tool schema, though dynamic content
|
||||
can still reduce cache reuse for that turn. The detailed desktop hook schema and
|
||||
trust model are documented in [the Chinese desktop hooks guide](./DESKTOP_HOOKS.zh-CN.md).
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
Shortcuts are documented by client because users usually look for the keys that
|
||||
@@ -302,7 +416,8 @@ Chat and transcript shortcuts:
|
||||
| `Ctrl+L` or `/cls` | Clears only the visible transcript | The LLM context, session file, tools, memory, and plugins stay loaded. Use `/clear` when you want to discard the conversation context. |
|
||||
| `Esc` | Backs out of the current action | It un-sends a just-submitted turn before any reply, cancels a running turn, or clears non-empty input. |
|
||||
| Double `Esc` on an empty idle composer | Opens the rewind picker | Same entry point as `/rewind`. |
|
||||
| Transcript text selection | Copies transcript text | The full-screen TUI enables mouse reporting, so drag in the transcript to select text in-app, then press `Ctrl+C`, `Super+C`, `Meta+C`, or right-click the active selection to copy it. |
|
||||
| Transcript text selection | Copies transcript text | The full-screen TUI enables mouse reporting, so drag in the transcript to select text in-app; releasing the mouse copies it automatically, and `Ctrl+C`/`Super+C`/`Meta+C` or right-clicking the active selection copy it again. |
|
||||
| `/mouse` | Toggles in-app mouse capture | Off hands the mouse back to your terminal, restoring its native click-drag selection and right-click context menu, at the cost of in-app drag-select, the transcript scrollbar, and wheel-scroll. Set `REASONIX_DISABLE_MOUSE=1` to start every session with it off. |
|
||||
| `Ctrl+C` | Copies, cancels, clears, or quits | Copies an active transcript selection first. Otherwise it cancels a running turn, clears non-empty input, or quits on a second empty-composer press. |
|
||||
| `Ctrl+D` | Quits the TUI | Immediate quit. |
|
||||
| `Ctrl+V`, `Ctrl+Shift+V`, `Meta+V`, or `Super+V` | Pastes clipboard content | The CLI tries an image first, then falls back to text or file references. |
|
||||
@@ -363,13 +478,14 @@ edits stay in the project), resolving symlinks and `..` so a link can't tunnel
|
||||
out. `forbid_read` optionally hides sensitive directories from the agent's
|
||||
read/list/search tools; use absolute paths or `${HOME}` / `${VAR}` references,
|
||||
not `~`, because config expansion is environment-variable based. `bash` is
|
||||
itself jailed on macOS by default (`[sandbox] bash`, Seatbelt): commands may
|
||||
write only those same roots (plus temp and toolchain caches), cannot read
|
||||
configured `forbid_read` roots while the OS sandbox is active, and reach the
|
||||
network only when `[sandbox] network` is set. Other platforms fall back to
|
||||
running unconfined when no OS sandbox is available (see
|
||||
itself jailed by default when an OS sandbox is available (`[sandbox] bash`,
|
||||
Seatbelt on macOS and bubblewrap on Linux): commands may write only those same
|
||||
roots (plus temp and toolchain caches), cannot read configured `forbid_read`
|
||||
roots while the OS sandbox is active, and reach the network only when
|
||||
`[sandbox] network` is set. When no OS sandbox is available, `bash = "enforce"`
|
||||
refuses bash execution instead of running unconfined (see
|
||||
[`SPEC.md` §9](./SPEC.md#9-roadmap-not-in-current-scope) for the escape-prompt and
|
||||
Linux support still to come).
|
||||
broader OS support still to come).
|
||||
|
||||
## Plugins (MCP)
|
||||
|
||||
@@ -472,8 +588,10 @@ archives, and saved facts on demand instead of injecting that dynamic state into
|
||||
the stable system prompt. `/forget <name>` archives a saved fact rather than
|
||||
deleting it permanently; the CLI/TUI and desktop memory panel can show those
|
||||
archived files for traceability, but they are not searched as active memory.
|
||||
Agent-initiated `remember` and `forget` calls always ask for fresh approval and
|
||||
show a compact preview of the saved or archived memory before they run.
|
||||
Agent-initiated `remember` and `forget` calls always ask for fresh human approval
|
||||
and show a compact preview of the saved or archived memory before they run.
|
||||
Guardian review cannot answer for the user; non-interactive runs refuse these
|
||||
tools instead of auto-approving them.
|
||||
Retrieval keeps the top BM25 result while trimming weak common-word matches, and
|
||||
0-result responses suggest narrower, more distinctive follow-up searches.
|
||||
Memory v5 is enabled by default across the CLI/TUI, `reasonix serve`, and the
|
||||
@@ -611,11 +729,22 @@ Subagent skills inherit the executor model by default. Set `subagent_model` to
|
||||
run them on another configured model, or use `subagent_models` to override only
|
||||
specific skills such as `review` or `security_review`.
|
||||
|
||||
Subagents may delegate one more layer by default: the root session is depth 0,
|
||||
first-layer subagents are depth 1, and the maximum `max_subagent_depth = 2`
|
||||
means a depth-1 workflow can dispatch a depth-2 reviewer or implementer. Depth-2
|
||||
subagents do not receive recursive agent/skill tools. Set
|
||||
`agent.max_subagent_depth = 1` to restore the old single-layer boundary. This is
|
||||
intended for workflows such as Superpowers where a workflow skill may dispatch a
|
||||
reviewer subagent, while still avoiding unbounded recursion and background
|
||||
fanout.
|
||||
|
||||
Use `read_only_task` when planning needs isolated, deeper research without
|
||||
granting write-capable delegation. Use `read_only_skill` when the same need is
|
||||
best expressed through an existing skill. Both run ephemeral read-only
|
||||
subagents with only read-only research tools plus safe foreground bash, return
|
||||
only the final answer, and do not create resumable subagent transcripts. In
|
||||
only the final answer, and do not create resumable subagent transcripts.
|
||||
Read-only nested delegation may be available until `max_subagent_depth` is
|
||||
reached, but writer-capable `task` / `run_skill` remain unavailable. In
|
||||
token economy mode, connect only this narrow surface with
|
||||
`connect_tool_source(source="read_only_skill")`; the full `skills` source still
|
||||
enables writer-capable skill tools and remains blocked in plan mode.
|
||||
|
||||
+79
-4
@@ -37,6 +37,8 @@ Reasonix 全局 `<Reasonix home>/.env`。项目 `.env`、home `.env`、继承的
|
||||
|
||||
桌面端和 CLI 端的可见思考语言设置,见 [思考语言](./REASONING_LANGUAGE.zh-CN.md)。
|
||||
桌面端 Hooks 的 JSON 配置、事件 key 和 payload 字段,见 [桌面端 Hooks](./DESKTOP_HOOKS.zh-CN.md)。
|
||||
`SessionStart` hook 可通过 stdout 或 `hookSpecificOutput.additionalContext` 把插件/工作流 bootstrap 内容一次性注入下一轮真实用户输入上下文,而不是写入稳定 system prompt。
|
||||
插件包可通过 `hooks/session-start-codex` 或插件根目录 `CLAUDE.md` 提供该启动上下文;Claude 风格 `.claude/settings.json` command hooks 也会按同名事件映射到 Reasonix hooks。
|
||||
|
||||
```toml
|
||||
default_model = "deepseek-flash" # 执行器;设 [agent].planner_model 可加规划器
|
||||
@@ -52,9 +54,11 @@ planner_max_steps = 0 # 仅用户/全局;规划器只读工具调
|
||||
reasoning_language = "auto" # 可见思考过程语言:auto|zh|en
|
||||
# plan_mode_allowed_tools = ["custom_reader"] # 仅声明额外只读自定义工具;
|
||||
# # 不能解锁被计划模式阻断的工具或 unsafe bash
|
||||
# plan_mode_read_only_commands = ["gh issue view", "gh pr diff"] # 计划模式额外只读 shell 前缀
|
||||
# planner_model = "deepseek-pro" # 可选的低频规划器
|
||||
# subagent_model = "deepseek-pro" # runAs=subagent skill 的默认模型
|
||||
# subagent_models = { review = "deepseek-pro", security_review = "deepseek-pro" }
|
||||
# max_subagent_depth = 2 # 子代理嵌套委派深度;设为 1 可恢复旧的单层边界
|
||||
auto_plan = "off" # 仅用户级生效;off|on;off 表示计划模式仅手动开启
|
||||
# auto_plan_classifier = "deepseek-flash" # 可选;只在边界任务上调用
|
||||
tool_result_snip_ratio = 0.6 # 在摘要 compaction 前先缩短旧工具输出
|
||||
@@ -113,6 +117,14 @@ planner / read-only research 可用的可信只读工具。优先使用 MCP 只
|
||||
再在 plugin 上写 `trusted_read_only_tools`,`plan_mode_allowed_tools` 保留为兼容逃生阀。它不再解锁 `bash`、`task`、
|
||||
写文件工具、安装器、记忆变更工具等计划模式已知阻断项,也不会绕过 bash 在计划模式下的安全检查。
|
||||
|
||||
当计划阶段需要运行 Reasonix 尚不能自动分类、但你确认只读的 shell 查询命令时,使用
|
||||
`[agent].plan_mode_read_only_commands`,例如 `gh issue view`、`gh pr diff` 或内部只读查询 CLI。
|
||||
这里声明的是具体命令前缀,不是工具名:`["gh issue view"]` 会允许 `gh issue view 4572`,
|
||||
但 `bash`、`sh` 等 shell 解释器前缀会被忽略。shell 操作符、重定向、命令替换、后台进程、
|
||||
以及内置命令里的写能力参数在计划模式下仍会被阻断。交互式计划模式第一次需要某个未知查询前缀时,
|
||||
Reasonix 也可以提示你确认是否信任它为只读;选择持久信任会写入同一个
|
||||
`[agent].plan_mode_read_only_commands` 配置。Auto/YOLO 审批不会替用户回答这个信任提示。
|
||||
|
||||
### 环境变量
|
||||
|
||||
多数日常设置应写在 `config.toml` 或前文提到的 Reasonix 全局 `.env` 中。下面这些变量是进程级高级开关;
|
||||
@@ -178,7 +190,25 @@ Goal、由 `todo_write` 工具驱动的实时 Todo 面板,以及已配置 prov
|
||||
## 自定义 OpenAI-compatible provider
|
||||
|
||||
在桌面端打开 **设置 -> 模型 -> 接入 -> 添加模型服务 -> 自定义供应商**,用于接入代理、
|
||||
聚合平台或自建 OpenAI-compatible chat API 服务。
|
||||
聚合平台或自建 OpenAI-compatible chat API / Anthropic-compatible Messages API 服务。
|
||||
|
||||
常用服务优先使用 **添加模型服务 -> 推荐预设**。Reasonix 可以预填可编辑的自定义 provider:
|
||||
Kimi CN、Kimi Global、Kimi Coding Plan、MiMo API、MiMo Anthropic、MiMo Token Plan
|
||||
CN/SGP/AMS 及其 Anthropic-compatible 变体、MiniMax CN/Global API、MiniMax
|
||||
CN/Global Anthropic、GLM CN、Z.AI Global、GLM/Z.AI Coding Plan 的
|
||||
OpenAI-compatible 与 Anthropic-compatible 端点、OpenCode Go、OpenCode Go
|
||||
Anthropic、OpenCode Zen Anthropic、Qwen/DashScope CN/Global、Qwen Coding Plan
|
||||
CN/Global 的 OpenAI-compatible 与 Anthropic-compatible 端点、StepFun
|
||||
OpenAI-compatible 与 Anthropic-compatible 端点、NovitaAI、GMI Cloud、Vercel AI
|
||||
Gateway、HuggingFace Router、NVIDIA NIM、KiloCode 和 Ollama Cloud。Plan 表示
|
||||
访问/付费形态;只有服务商确实提供不同区域端点时,预设名才同时带 CN/Global。
|
||||
因此 Kimi Coding Plan 是独立 plan 端点,Kimi 直连 API 才拆成 CN 和 Global。
|
||||
预设路径通常只需要填写服务商 API Key:真实 key 会写入 Reasonix home `.env`,
|
||||
`config.toml` 只保存端点、模型列表、key 环境变量名、上下文窗口、视觉模型元数据、
|
||||
中国区端点直连、MiniMax `reasoning_split`、GLM/MiniMax thinking heuristic、
|
||||
Anthropic-compatible 网关需要的 Bearer 认证、Ollama Cloud max-effort 支持,
|
||||
以及 OpenCode Go 的每模型 reasoning 覆盖。添加后仍然可以打开 provider 卡片,
|
||||
继续修改模型、请求头、端点或兼容设置。
|
||||
|
||||
**API 地址** 填写服务端点。默认模式下,Reasonix 会预览并把聊天请求发送到:
|
||||
|
||||
@@ -191,12 +221,49 @@ Goal、由 `todo_write` 工具驱动的实时 Todo 面板,以及已配置 prov
|
||||
输入框下方的预览就是最终请求地址。
|
||||
|
||||
模型发现会基于 API 地址尝试 `/models`、`/v1/models` 等候选地址。如果网关要求单独的
|
||||
模型列表端点,在 **高级设置** 中填写 `models_url`,例如
|
||||
模型列表端点,在 **兼容设置** 中填写 `models_url`,例如
|
||||
`https://gateway.example.com/v1/models`。如果接口不支持模型发现,也可以手动填写模型列表。
|
||||
|
||||
**完整 URL** 仍使用 OpenAI-compatible chat 请求体;它不会切换成 OpenAI Responses API
|
||||
的请求 schema。
|
||||
|
||||
### 兼容设置
|
||||
|
||||
**兼容设置(通常不用改)** 用于处理认证变量、模型发现地址、请求头、以及 reasoning/thinking
|
||||
请求格式和普通 OpenAI-compatible 默认行为不一致的网关。除非服务商文档明确要求,或代理报错说明
|
||||
不兼容,否则保持默认值即可。Kimi Coding Plan、MiniMax CN/Global Anthropic 这类 Anthropic-compatible 服务,
|
||||
保存前在基础区域把接入协议切到 **Anthropic-compatible**。
|
||||
|
||||
| 字段 | 作用 | 什么时候改 |
|
||||
| --- | --- | --- |
|
||||
| `api_key_env` | 该 provider 使用的 API key 环境变量名。桌面端保存的真实 key 会写入 Reasonix home `.env` 的同名变量;TOML 配置里只保存变量名。 | 多个 provider 需要不同 key 时改名;服务不需要 API key 时可以留空。 |
|
||||
| `models_url` | 只用于自动发现模型列表的 URL。聊天请求仍使用上方的 API 地址或完整 URL。 | `/models` 或 `/v1/models` 不是该网关模型列表地址时填写。 |
|
||||
| 额外请求头 | 静态 HTTP header,一行一个 `Header: value`。 | OpenRouter 等网关要求 `HTTP-Referer`、`X-Title` 或类似站点来源 header 时使用。API key 仍放在上方密钥字段,不要重复写到这里。 |
|
||||
| 额外请求体 | 合并到聊天请求体顶层的 JSON 对象。 | 仅用于服务商专用开关,例如 `{"enable_thinking": true}`。`model`、`messages`、`tools`、`stream`、`thinking` 等核心字段仍由 Reasonix 控制,且不接受 `null` 值。 |
|
||||
| Authorization: Bearer | 对 Anthropic-compatible provider,把已保存的 API key 用 `Authorization: Bearer <key>` 发送,而不是 `x-api-key`。 | MiniMax Global、Vercel AI Gateway 等网关文档明确要求 Bearer 认证时开启。 |
|
||||
| 模型能力模式 | 指定 Reasonix 对该 provider 使用哪种 reasoning 请求协议。 | 默认用“自动识别”。只有网关被误判,或模型文档要求特定 reasoning 格式时再切换。 |
|
||||
| Thinking 覆盖 | provider 专用的 `thinking.type` 覆盖项。 | 默认用 Auto。只有后端文档明确支持 `enabled`、`disabled` 或 `adaptive` 时再手动指定;不支持的值可能让中转站拒绝请求。 |
|
||||
| 余额查询 URL | 可选的钱包余额查询接口。 | 服务商提供余额接口,且希望桌面端状态栏显示余额时填写。 |
|
||||
| 上下文窗口 | 该 provider 可保留的最大上下文 token 数。`0` 表示使用模型服务默认值。 | 模型实际上下文大小和 Reasonix 默认值或内置元数据不一致时填写。 |
|
||||
|
||||
模型能力模式选项:
|
||||
|
||||
| 选项 | 作用 |
|
||||
| --- | --- |
|
||||
| 自动识别(推荐) | Reasonix 根据模型能力元数据和端点自动选择请求格式。 |
|
||||
| DeepSeek 思考 | 使用 DeepSeek 风格的 thinking 控制,包括 `thinking.type` 和 DeepSeek 支持的推理深度。 |
|
||||
| OpenAI reasoning | 使用标准 OpenAI-compatible 的 `reasoning_effort` 档位。 |
|
||||
| 普通聊天(不发送思考参数) | 不发送 reasoning 或 thinking 控制字段。适合会拒绝 reasoning 参数的普通文本代理。 |
|
||||
|
||||
Thinking 覆盖选项:
|
||||
|
||||
| 选项 | 作用 |
|
||||
| --- | --- |
|
||||
| Auto(使用服务默认) | 不写 provider 级 `thinking` 覆盖,让 Reasonix 使用 provider/model 默认行为。 |
|
||||
| Enabled(开启) | 对兼容 provider 发送 `thinking.type = "enabled"`。 |
|
||||
| Disabled(关闭) | 对兼容 provider 发送 `thinking.type = "disabled"`。DeepSeek 风格 provider 下还会避免继续发送推理深度提示。 |
|
||||
| Adaptive(自适应) | 仅在服务文档明确支持 adaptive thinking 时使用,例如 MiniMax-M3 风格端点;语义是发送或保留 `thinking.type = "adaptive"`。 |
|
||||
|
||||
## 快捷键
|
||||
|
||||
这里按使用端来写,因为用户通常是先知道“我现在在桌面端/CLI”,再找对应按键。
|
||||
@@ -409,7 +476,8 @@ headers = { Authorization = "Bearer ${STRIPE_KEY}" }
|
||||
compaction archive 和已保存事实;这些动态内容不会被塞进稳定的 system prompt 前缀。
|
||||
`/forget <name>` 会把已保存事实归档而不是永久删除;CLI/TUI 和桌面记忆面板能显示归档文件用于追溯,
|
||||
但它们不会作为 active memory 被检索。检索会保留 BM25 最强命中,同时裁掉弱的泛词命中;
|
||||
agent 发起的 `remember` 和 `forget` 每次都会要求新的人工确认,并在执行前展示将保存或归档的记忆摘要。
|
||||
agent 发起的 `remember` 和 `forget` 每次都会要求新的人工确认,并在执行前展示将保存或归档的记忆摘要;
|
||||
Guardian 审查不能代替用户批准,非交互运行会拒绝这类工具而不是自动批准。
|
||||
0 结果会提示 agent 改用更少、更有区分度的词继续查。
|
||||
Memory v5 在 CLI/TUI、`reasonix serve` 和桌面端默认开启,因为这些入口共用同一套本地
|
||||
controller。它会把本地、按项目隔离的执行轨迹和编译器状态写在 Reasonix home 下,并且只有
|
||||
@@ -512,10 +580,17 @@ Planner 会看到已加载的 `REASONIX.md` / `AGENTS.md` 记忆,并拿到一
|
||||
Subagent skills 默认继承执行器模型。设置 `subagent_model` 可让它们统一走另一个已配置
|
||||
模型;设置 `subagent_models` 则只覆盖 `review`、`security_review` 等指定 skill。
|
||||
|
||||
Subagent 默认允许再委派一层:根会话是 depth 0,第一层 subagent 是 depth 1,
|
||||
`max_subagent_depth = 2` 表示 depth 1 的 workflow 可以再派 depth 2 的 reviewer
|
||||
或 implementer;depth 2 不再拿到递归 agent/skill 工具。设
|
||||
`agent.max_subagent_depth = 1` 可恢复旧的单层边界。这主要用于 Superpowers 这类
|
||||
workflow skill 派发 reviewer subagent 的场景,同时避免无限递归和后台 fanout。
|
||||
|
||||
当计划阶段需要隔离上下文做更深的调研时,用 `read_only_task`,而不是放开可写的
|
||||
`task`。如果这类调研更适合复用已有 skill,用 `read_only_skill`。两者都会启动
|
||||
ephemeral 只读 subagent,只暴露只读研究工具和安全前台 bash,只返回最终答案,不创建
|
||||
可续接的 subagent transcript。在 token economy 模式下,只用
|
||||
可续接的 subagent transcript。只读嵌套委派会在 `max_subagent_depth` 内可用,但
|
||||
可写的 `task` / `run_skill` 仍不可用。在 token economy 模式下,只用
|
||||
`connect_tool_source(source="read_only_skill")` 连接这条窄入口;完整的 `skills`
|
||||
source 仍会启用可写 skill 工具,plan mode 下继续阻断。
|
||||
|
||||
|
||||
+17
-8
@@ -103,14 +103,23 @@ and DeepSeek prefix-cache–oriented design.
|
||||
external tools**: `[agent].plan_mode_allowed_tools` now only declares extra
|
||||
read-only custom/external tools. It no longer unlocks known blocked plan-mode
|
||||
tools such as `bash`, `task`, writers, installers, or memory mutation tools, and
|
||||
unsafe bash commands still remain blocked. An MCP/plugin tool whose read-only
|
||||
status comes from the server's untrusted `readOnlyHint` is confirmed the first
|
||||
time an interactive plan-mode run needs it; choose the persistent option to
|
||||
write the plugin-level `trusted_read_only_tools` raw-name list. Auto/YOLO tool
|
||||
approval does not answer this trust prompt, although a session or persistent
|
||||
trust choice prevents repeat prompts for the same MCP tool. Non-interactive
|
||||
runs still fail closed, so pre-seed `trusted_read_only_tools` or declare a
|
||||
concrete `mcp__<server>__<tool>` when no user can approve. In the desktop MCP
|
||||
unsafe bash commands still remain blocked. To migrate old
|
||||
`plan_mode_allowed_tools = ["bash", ...]` configs, move concrete read-only
|
||||
shell prefixes such as `gh issue view` or internal query CLIs to
|
||||
`[agent].plan_mode_read_only_commands`; do not declare shell interpreters or
|
||||
writer-capable commands there. Interactive plan-mode runs can also ask you to
|
||||
trust a concrete unknown query prefix once, and the persistent choice writes
|
||||
the same `plan_mode_read_only_commands` entry. Auto/YOLO tool approval does
|
||||
not answer this bash trust prompt. Use `read_only_task` / `read_only_skill`
|
||||
instead of trying to unlock `task` / `run_skill` while planning. An MCP/plugin tool
|
||||
whose read-only status comes from the server's untrusted `readOnlyHint` is
|
||||
confirmed the first time an interactive plan-mode run needs it; choose the
|
||||
persistent option to write the plugin-level `trusted_read_only_tools` raw-name
|
||||
list. Auto/YOLO tool approval does not answer this trust prompt, although a
|
||||
session or persistent trust choice prevents repeat prompts for the same MCP
|
||||
tool. Non-interactive runs still fail closed, so pre-seed
|
||||
`trusted_read_only_tools` or declare a concrete `mcp__<server>__<tool>` when no
|
||||
user can approve. In the desktop MCP
|
||||
panel, expand a server and use **Pre-trust read-only** for currently listed
|
||||
`readOnlyHint` tools, per-tool **Pre-trust** for audited readers, or
|
||||
**Untrust** to remove a tool; those actions write the same
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
# Reasonix Plugin Packages
|
||||
|
||||
Reasonix plugin packages bundle skills, hooks, and MCP servers behind one
|
||||
installable unit.
|
||||
|
||||
## CLI Mode
|
||||
|
||||
Use `reasonix plugin` when installing or managing plugin packages from a
|
||||
terminal. Plugin packages are installed globally under the Reasonix home
|
||||
directory.
|
||||
|
||||
### Install From CLI
|
||||
|
||||
`install` accepts one source:
|
||||
|
||||
- A GitHub repository, such as `git:github.com/obra/superpowers` or
|
||||
`https://github.com/obra/superpowers`.
|
||||
- A GitHub branch or subdirectory URL, such as
|
||||
`https://github.com/owner/repo/tree/main/path/to/plugin`.
|
||||
- A local directory that contains `reasonix-plugin.json` or
|
||||
`.codex-plugin/plugin.json`.
|
||||
|
||||
Preview the install plan without writing files:
|
||||
|
||||
```bash
|
||||
reasonix plugin install git:github.com/obra/superpowers --dry-run
|
||||
```
|
||||
|
||||
Install a plugin after reviewing the plan:
|
||||
|
||||
```bash
|
||||
reasonix plugin install git:github.com/obra/superpowers --yes
|
||||
```
|
||||
|
||||
Install with an explicit name or replace an installed plugin with the same name:
|
||||
|
||||
```bash
|
||||
reasonix plugin install git:github.com/obra/superpowers --name superpowers --replace --yes
|
||||
```
|
||||
|
||||
Use a local directory in developer mode:
|
||||
|
||||
```bash
|
||||
reasonix plugin install /path/to/plugin --link --replace --yes
|
||||
```
|
||||
|
||||
CLI install flags:
|
||||
|
||||
- `--dry-run` plans and validates the install without writing files.
|
||||
- `--yes` is required for any install that writes files.
|
||||
- `--replace` allows the source to replace an installed plugin with the same
|
||||
name.
|
||||
- `--name <name>` or `--name=<name>` overrides the name from the plugin
|
||||
manifest for this install.
|
||||
- `--link` links a local plugin directory instead of copying it into Reasonix's
|
||||
plugin storage. Moving or deleting that directory breaks the linked plugin.
|
||||
|
||||
Running `reasonix plugin install <source>` without `--dry-run` or `--yes`
|
||||
refuses to write files and prints a reminder to rerun with one of those flags.
|
||||
Install and remove commands print the structured JSON response from the same
|
||||
install-source backend used by the desktop UI.
|
||||
|
||||
Installed plugin state is stored in:
|
||||
|
||||
```text
|
||||
~/.reasonix/plugin-packages.json
|
||||
~/.reasonix/plugins/<name>/
|
||||
```
|
||||
|
||||
### Manage From CLI
|
||||
|
||||
List installed plugins:
|
||||
|
||||
```bash
|
||||
reasonix plugin list
|
||||
```
|
||||
|
||||
Show one plugin's metadata, root, source, and exported capability counts:
|
||||
|
||||
```bash
|
||||
reasonix plugin show superpowers
|
||||
```
|
||||
|
||||
`show` also prints the concrete capability inventory when available:
|
||||
|
||||
- **skills** include suggested `/<skill>` invocations and descriptions.
|
||||
- **hooks** list lifecycle events, matchers, and commands or context files.
|
||||
- **mcpServers** list server names, transports, and launch targets.
|
||||
|
||||
Check that the manifest and skill roots are readable:
|
||||
|
||||
```bash
|
||||
reasonix plugin doctor superpowers
|
||||
```
|
||||
|
||||
Enable or disable a plugin without uninstalling it:
|
||||
|
||||
```bash
|
||||
reasonix plugin disable superpowers
|
||||
reasonix plugin enable superpowers
|
||||
```
|
||||
|
||||
Remove a plugin:
|
||||
|
||||
```bash
|
||||
reasonix plugin remove superpowers --yes
|
||||
```
|
||||
|
||||
`remove` also accepts `uninstall` as an alias. It requires `--yes` because it
|
||||
writes state and removes copied plugin content. For linked local plugins, the
|
||||
external source directory is left in place.
|
||||
|
||||
### Use Installed Plugins From CLI
|
||||
|
||||
Installed plugins do not open a separate chat surface. When a plugin is enabled,
|
||||
Reasonix loads its capabilities into normal interactive sessions:
|
||||
|
||||
- Run `/plugins` inside an interactive session to list installed plugin
|
||||
packages. Run `/plugins show <name>` to inspect a plugin's exported skills,
|
||||
hooks, MCP servers, and usage hints without leaving the chat.
|
||||
- **Skills** appear in `/skills`. Invoke a skill with `/<skill> [args]`, or ask
|
||||
naturally and let the agent choose a matching skill by description.
|
||||
- **Hooks** run automatically at their configured lifecycle events, such as
|
||||
`SessionStart`, `UserPromptSubmit`, `PreToolUse`, or `PostToolUse`.
|
||||
- **MCP servers** join the normal MCP/tool flow. Ask for the task you want done;
|
||||
Reasonix can call the plugin's tools when they are relevant.
|
||||
|
||||
After installing, enabling, disabling, or updating a plugin from a separate
|
||||
terminal while a session is already running, start a new `reasonix` session or
|
||||
reopen `/skills` to verify the current session sees the expected skills.
|
||||
|
||||
## Desktop Settings
|
||||
|
||||
Open **Settings -> Plugins** to install and manage plugin packages without using
|
||||
the CLI.
|
||||
|
||||
### Install Plugins
|
||||
|
||||
The installer has two modes:
|
||||
|
||||
- **Local folder**: click **Choose plugin folder** and select a plugin directory
|
||||
on disk. The selected path is shown next to the button.
|
||||
- **Git repository**: enter a Git source such as
|
||||
`git:github.com/obra/superpowers`. **Install name (optional)** can override
|
||||
the plugin manifest name for this install or overwrite.
|
||||
|
||||
Use the action buttons after choosing the source and options:
|
||||
|
||||
- **Preview** validates the source and shows the planned install actions without
|
||||
writing files.
|
||||
- **Install plugin** installs the selected source using the current options.
|
||||
- **Refresh plugins** reloads the installed-plugin list from disk and config.
|
||||
|
||||
Installer options:
|
||||
|
||||
- **Overwrite same-name plugin** allows the current source to replace an
|
||||
installed plugin with the same name. Leave it off when duplicate-name installs
|
||||
should fail instead of replacing existing content.
|
||||
- **Developer mode: link source folder** appears for **Local folder** installs.
|
||||
It links the selected directory instead of copying it into Reasonix's plugin
|
||||
storage. Use it while developing or debugging a plugin. Moving or deleting the
|
||||
selected directory will break the linked plugin.
|
||||
|
||||
Preview is the safest first step for a new Git source or local plugin directory.
|
||||
|
||||
### Manage Installed Plugins
|
||||
|
||||
The installed-plugin list shows each plugin package and its exported skills,
|
||||
hooks, and MCP servers. Use **Refresh plugins** after editing plugin files or
|
||||
changing config outside the app.
|
||||
|
||||
Expand a plugin row to manage it:
|
||||
|
||||
- Enable or disable the plugin.
|
||||
- Read **How to use** for the plugin's exported skills, hooks, and MCP servers.
|
||||
- **Update** pulls or refreshes an installed plugin when an update source is
|
||||
available.
|
||||
- **Doctor** checks the plugin manifest and reports warnings or diagnostics.
|
||||
- **Remove plugin** uninstalls the package after confirmation.
|
||||
|
||||
### Use Installed Plugins From Desktop
|
||||
|
||||
The desktop settings page uses the same runtime model as the CLI:
|
||||
|
||||
- Expand an installed plugin to see its **How to use** section.
|
||||
- In any desktop session, type `/plugins` to list installed plugins, or
|
||||
`/plugins show <name>` to see the same usage details from the chat surface.
|
||||
- Skills are shown with suggested direct commands such as `/plan`; they are also
|
||||
discoverable from `/skills` in a session.
|
||||
- Hooks and MCP servers are listed for transparency. They do not need a manual
|
||||
"run" button: enabled hooks trigger automatically, and MCP tools are available
|
||||
through ordinary tool use.
|
||||
- If a currently open session does not reflect a plugin change, refresh the
|
||||
plugin list and open a new session.
|
||||
|
||||
## Native Manifest
|
||||
|
||||
Reasonix plugins can declare `reasonix-plugin.json` at the plugin root:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "example",
|
||||
"version": "1.0.0",
|
||||
"description": "Example plugin",
|
||||
"skills": "skills",
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"command": "hooks/session-start",
|
||||
"description": "Load startup context"
|
||||
}
|
||||
]
|
||||
},
|
||||
"mcpServers": {
|
||||
"helper": {
|
||||
"command": "bin/helper"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Relative paths are resolved inside the plugin root. Reasonix does not run
|
||||
third-party install scripts during plugin installation.
|
||||
|
||||
## Codex Compatibility
|
||||
|
||||
Reasonix also reads Codex plugin manifests at `.codex-plugin/plugin.json`.
|
||||
For packages such as Superpowers and Claude-style skill packs, Reasonix maps:
|
||||
|
||||
- `skills` to Reasonix skill roots.
|
||||
- `hooks/session-start-codex` to the Reasonix `SessionStart` hook when present.
|
||||
- A plugin-root `CLAUDE.md` file to a built-in `SessionStart` context hook. The
|
||||
file is read directly by Reasonix, without spawning a shell command.
|
||||
- `.claude/settings.json` command hooks to Reasonix hook events when the event
|
||||
names match. Claude's `matcher` field maps to Reasonix `match`; hook commands
|
||||
run as shell commands with the plugin root as `cwd`; Claude `timeout` values
|
||||
are interpreted as seconds.
|
||||
|
||||
Unsupported Claude hook item types are skipped with a warning. Reasonix does not
|
||||
run third-party install scripts or implement marketplace-specific install
|
||||
protocols.
|
||||
|
||||
Plugin hooks receive these environment variables:
|
||||
|
||||
- `REASONIX_PLUGIN_ROOT`
|
||||
- `REASONIX_PLUGIN_NAME`
|
||||
- `REASONIX_PLUGIN_VERSION`
|
||||
- `REASONIX_HOME`
|
||||
- `REASONIX_WORKSPACE_ROOT`
|
||||
- `CLAUDE_PROJECT_DIR`
|
||||
|
||||
## Desktop Backend Methods
|
||||
|
||||
Desktop exposes plugin package operations through Wails methods:
|
||||
|
||||
- `Plugins`
|
||||
- `PlanPluginInstall`
|
||||
- `InstallPlugin`
|
||||
- `RemovePlugin`
|
||||
- `SetPluginEnabled`
|
||||
- `UpdatePlugin`
|
||||
- `PluginDoctor`
|
||||
@@ -0,0 +1,242 @@
|
||||
# Reasonix 插件包
|
||||
|
||||
Reasonix 插件包把 skills、hooks 和 MCP servers 组织成一个可安装单元。
|
||||
|
||||
## CLI 模式
|
||||
|
||||
在终端里使用 `reasonix plugin` 安装和管理插件包。插件包当前按全局范围安装,
|
||||
写入 Reasonix home 目录。
|
||||
|
||||
### 通过 CLI 安装
|
||||
|
||||
`install` 接收一个来源:
|
||||
|
||||
- GitHub 仓库,例如 `git:github.com/obra/superpowers` 或
|
||||
`https://github.com/obra/superpowers`。
|
||||
- GitHub 分支或子目录 URL,例如
|
||||
`https://github.com/owner/repo/tree/main/path/to/plugin`。
|
||||
- 本地目录,目录内需要包含 `reasonix-plugin.json` 或
|
||||
`.codex-plugin/plugin.json`。
|
||||
|
||||
只预览安装计划,不写文件:
|
||||
|
||||
```bash
|
||||
reasonix plugin install git:github.com/obra/superpowers --dry-run
|
||||
```
|
||||
|
||||
确认计划后安装:
|
||||
|
||||
```bash
|
||||
reasonix plugin install git:github.com/obra/superpowers --yes
|
||||
```
|
||||
|
||||
指定安装名称,或覆盖已安装的同名插件:
|
||||
|
||||
```bash
|
||||
reasonix plugin install git:github.com/obra/superpowers --name superpowers --replace --yes
|
||||
```
|
||||
|
||||
以开发模式使用本地目录:
|
||||
|
||||
```bash
|
||||
reasonix plugin install /path/to/plugin --link --replace --yes
|
||||
```
|
||||
|
||||
CLI 安装参数:
|
||||
|
||||
- `--dry-run` 只规划和校验安装,不写文件。
|
||||
- `--yes` 用于确认执行会写文件的安装。
|
||||
- `--replace` 允许当前来源替换已安装的同名插件。
|
||||
- `--name <name>` 或 `--name=<name>` 覆盖插件 manifest 里的名称,
|
||||
作为本次安装名称。
|
||||
- `--link` 链接本地插件目录,而不是复制到 Reasonix 的插件存储目录。
|
||||
移动或删除该目录会导致这个链接插件失效。
|
||||
|
||||
如果运行 `reasonix plugin install <source>` 时既没有 `--dry-run`,
|
||||
也没有 `--yes`,CLI 会拒绝写文件,并提示使用其中一个参数重新运行。
|
||||
安装和移除命令会输出结构化 JSON,来源于桌面端同一套 install-source 后端。
|
||||
|
||||
插件状态和内容写入:
|
||||
|
||||
```text
|
||||
~/.reasonix/plugin-packages.json
|
||||
~/.reasonix/plugins/<name>/
|
||||
```
|
||||
|
||||
### 通过 CLI 管理
|
||||
|
||||
列出已安装插件:
|
||||
|
||||
```bash
|
||||
reasonix plugin list
|
||||
```
|
||||
|
||||
查看某个插件的元数据、根目录、来源以及导出的能力数量:
|
||||
|
||||
```bash
|
||||
reasonix plugin show superpowers
|
||||
```
|
||||
|
||||
如果能读取到能力明细,`show` 也会输出具体清单:
|
||||
|
||||
- **skills** 会展示建议的 `/<skill>` 调用方式和描述。
|
||||
- **hooks** 会展示生命周期事件、matcher、命令或上下文文件。
|
||||
- **mcpServers** 会展示服务器名称、传输方式和启动目标。
|
||||
|
||||
检查 manifest 和 skill roots 是否可读:
|
||||
|
||||
```bash
|
||||
reasonix plugin doctor superpowers
|
||||
```
|
||||
|
||||
在不卸载的情况下启用或禁用插件:
|
||||
|
||||
```bash
|
||||
reasonix plugin disable superpowers
|
||||
reasonix plugin enable superpowers
|
||||
```
|
||||
|
||||
移除插件:
|
||||
|
||||
```bash
|
||||
reasonix plugin remove superpowers --yes
|
||||
```
|
||||
|
||||
`remove` 也可以写成 `uninstall`。它需要 `--yes`,
|
||||
因为会写入状态并删除复制安装的插件内容。如果是链接模式安装的本地插件,
|
||||
外部源目录会保留。
|
||||
|
||||
### 在 CLI 中使用已安装插件
|
||||
|
||||
已安装插件不会打开一个独立聊天界面。插件启用后,Reasonix 会把它的能力加载到普通交互会话里:
|
||||
|
||||
- 在交互会话里运行 `/plugins` 可以列出已安装插件包。
|
||||
运行 `/plugins show <name>` 可以在不离开聊天的情况下查看该插件导出的
|
||||
skills、hooks、MCP servers 和使用提示。
|
||||
- **Skills** 会出现在 `/skills` 中。可以用 `/<skill> [args]` 直接调用,
|
||||
也可以自然描述任务,让 agent 按 description 选择匹配的 skill。
|
||||
- **Hooks** 会在配置的生命周期事件里自动运行,例如 `SessionStart`、
|
||||
`UserPromptSubmit`、`PreToolUse` 或 `PostToolUse`。
|
||||
- **MCP servers** 会进入正常 MCP/工具流程。用户只需要描述任务,
|
||||
Reasonix 会在相关时调用插件提供的工具。
|
||||
|
||||
如果是在另一个终端里安装、启用、禁用或更新插件,而当前已有 `reasonix` 会话正在运行,
|
||||
建议开启新会话,或重新打开 `/skills` 确认当前会话能看到预期技能。
|
||||
|
||||
## 桌面端设置
|
||||
|
||||
打开 **设置 -> 插件**,可以不用 CLI 直接安装和管理插件包。
|
||||
|
||||
### 安装插件
|
||||
|
||||
安装区有两种模式:
|
||||
|
||||
- **本地目录**:点击 **选择插件目录**,从磁盘选择一个插件目录。
|
||||
选中路径会显示在按钮右侧。
|
||||
- **Git 仓库**:填写 Git 来源,例如 `git:github.com/obra/superpowers`。
|
||||
**安装名称(可选)** 可覆盖插件 manifest 声明的名称,用于本次安装或覆盖。
|
||||
|
||||
选择来源和选项后,再使用操作按钮:
|
||||
|
||||
- **预检** 校验来源并展示计划安装动作,不写入文件。
|
||||
- **安装插件** 按当前来源和选项执行安装。
|
||||
- **刷新插件** 从磁盘和配置重新读取已安装插件列表。
|
||||
|
||||
安装选项:
|
||||
|
||||
- **覆盖同名插件** 允许当前来源替换已安装的同名插件。关闭时,同名安装会失败,
|
||||
而不是覆盖已有内容。
|
||||
- **开发模式:链接源目录** 只在 **本地目录** 模式出现。它不会复制插件,
|
||||
而是直接链接所选目录;适合开发或调试插件。移动或删除该目录会导致这个链接插件失效。
|
||||
|
||||
对新的 Git 来源或本地插件目录,建议先点 **预检**。
|
||||
|
||||
### 管理已安装插件
|
||||
|
||||
已安装插件列表会展示每个插件包以及它导出的 skills、hooks 和 MCP servers。
|
||||
通过应用外编辑插件文件或配置后,可点 **刷新插件** 重新读取。
|
||||
|
||||
展开插件行后可以:
|
||||
|
||||
- 启用或禁用插件。
|
||||
- 查看 **使用方法**,了解该插件导出的 skills、hooks 和 MCP servers。
|
||||
- 使用 **更新** 拉取或刷新具备更新来源的插件。
|
||||
- 使用 **诊断** 检查插件 manifest,并查看警告或诊断信息。
|
||||
- 使用 **移除插件**,确认后卸载该插件包。
|
||||
|
||||
### 在桌面端使用已安装插件
|
||||
|
||||
桌面端设置页和 CLI 使用同一套运行模型:
|
||||
|
||||
- 展开已安装插件,可以看到 **使用方法** 区域。
|
||||
- 在任意桌面会话里输入 `/plugins` 可以列出已安装插件;
|
||||
输入 `/plugins show <name>` 可以直接从聊天界面查看同一套使用详情。
|
||||
- Skills 会展示建议的直接命令,例如 `/plan`;在会话中也可以通过 `/skills` 浏览。
|
||||
- Hooks 和 MCP servers 作为透明能力清单展示。它们不需要单独的“运行”按钮:
|
||||
启用的 hooks 会自动触发,MCP 工具会通过普通工具调用流程可用。
|
||||
- 如果当前打开的会话没有反映插件变更,刷新插件列表并开启新会话。
|
||||
|
||||
## 原生 Manifest
|
||||
|
||||
Reasonix 原生插件在根目录声明 `reasonix-plugin.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "example",
|
||||
"version": "1.0.0",
|
||||
"description": "Example plugin",
|
||||
"skills": "skills",
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"command": "hooks/session-start",
|
||||
"description": "Load startup context"
|
||||
}
|
||||
]
|
||||
},
|
||||
"mcpServers": {
|
||||
"helper": {
|
||||
"command": "bin/helper"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
相对路径都按插件根目录解析。Reasonix 安装插件时不会执行第三方安装脚本。
|
||||
|
||||
## Codex 兼容
|
||||
|
||||
Reasonix 也会读取 `.codex-plugin/plugin.json`。对于 Superpowers 和 Claude 风格
|
||||
skill 包,Reasonix 会映射:
|
||||
|
||||
- `skills` 到 Reasonix skill root。
|
||||
- 如果存在 `hooks/session-start-codex`,映射为 Reasonix `SessionStart` hook。
|
||||
- 插件根目录的 `CLAUDE.md` 会映射为内置的 `SessionStart` 上下文 hook。
|
||||
Reasonix 会直接读取该文件,不通过 shell 命令。
|
||||
- `.claude/settings.json` 里的 command hooks 会按同名事件映射到 Reasonix hooks。
|
||||
Claude 的 `matcher` 字段会映射到 Reasonix `match`;hook 命令以插件根目录作为
|
||||
`cwd` 执行;Claude `timeout` 按秒解析。
|
||||
|
||||
不支持的 Claude hook item type 会跳过并产生 warning。Reasonix 不会执行第三方安装脚本,
|
||||
也不会实现 marketplace 专用安装协议。
|
||||
|
||||
插件 hook 会收到这些环境变量:
|
||||
|
||||
- `REASONIX_PLUGIN_ROOT`
|
||||
- `REASONIX_PLUGIN_NAME`
|
||||
- `REASONIX_PLUGIN_VERSION`
|
||||
- `REASONIX_HOME`
|
||||
- `REASONIX_WORKSPACE_ROOT`
|
||||
- `CLAUDE_PROJECT_DIR`
|
||||
|
||||
## 桌面端后端方法
|
||||
|
||||
Desktop 通过 Wails 方法暴露插件包操作:
|
||||
|
||||
- `Plugins`
|
||||
- `PlanPluginInstall`
|
||||
- `InstallPlugin`
|
||||
- `RemovePlugin`
|
||||
- `SetPluginEnabled`
|
||||
- `UpdatePlugin`
|
||||
- `PluginDoctor`
|
||||
@@ -19,7 +19,9 @@ explicit without changing the stable system prompt or tool definitions.
|
||||
|
||||
The setting is intentionally small:
|
||||
|
||||
- `auto` follows the conversation language and injects no extra instruction.
|
||||
- `auto` anchors visible reasoning to Chinese when the raw user prompt is
|
||||
clearly Chinese, ignoring injected reference context such as `@file` contents;
|
||||
English and ambiguous turns add no extra instruction.
|
||||
- `zh` asks visible reasoning to prefer Simplified Chinese.
|
||||
- `en` asks visible reasoning to prefer English.
|
||||
|
||||
@@ -89,11 +91,15 @@ argument.
|
||||
|
||||
## Cache Behavior
|
||||
|
||||
`auto` is the cache-friendliest choice. It injects nothing and relies on the
|
||||
existing stable language policy.
|
||||
`auto` is still cache-friendly. When the raw user prompt clearly looks Chinese,
|
||||
Reasonix adds the same small transient `<reasoning-language>` block for that
|
||||
turn; English and ambiguous turns inject nothing and rely on the existing stable
|
||||
language policy. Injected reference context such as `@file` contents is ignored
|
||||
for this auto decision.
|
||||
|
||||
When set to `zh` or `en`, Reasonix adds a small transient
|
||||
`<reasoning-language>` block to the user turn. It does not change:
|
||||
When set to `zh` or `en`, Reasonix always adds a small transient
|
||||
`<reasoning-language>` block to the user turn. In all modes, this does not
|
||||
change:
|
||||
|
||||
- the system prompt
|
||||
- tool schema bytes or ordering
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
取值只有三种:
|
||||
|
||||
- `auto`:跟随当前对话语言,不额外注入语言指令。
|
||||
- `auto`:用户原始提问明显为中文时锚定中文,并忽略 `@file` 等注入的引用内容;英文或不确定时不额外注入语言指令。
|
||||
- `zh`:可见思考过程优先使用简体中文。
|
||||
- `en`:可见思考过程优先使用英文。
|
||||
|
||||
@@ -79,9 +79,9 @@ reasoning_language = "auto" # auto|zh|en
|
||||
|
||||
## 缓存影响
|
||||
|
||||
`auto` 对缓存最友好。它不会注入任何额外内容,只复用已有的稳定语言策略。
|
||||
`auto` 仍然对缓存友好。用户原始提问明显是中文时,Reasonix 会为这一轮加入同样很小的 `<reasoning-language>` 临时 block;英文或信号不明确时不注入,只复用已有的稳定语言策略。`@file` 等注入的引用内容不会参与这个自动判断。
|
||||
|
||||
当设置为 `zh` 或 `en` 时,Reasonix 会把一个很小的 `<reasoning-language>` 临时 block 放进本次 user turn。它不会改变:
|
||||
当设置为 `zh` 或 `en` 时,Reasonix 总是会把一个很小的 `<reasoning-language>` 临时 block 放进本次 user turn。所有模式下,它都不会改变:
|
||||
|
||||
- system prompt
|
||||
- 工具 schema 的字节或顺序
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Reasoning controls by provider
|
||||
|
||||
Reasonix exposes a single `/effort` knob (and the per-provider `effort` /
|
||||
`thinking` config fields), but OpenAI-compatible backends disagree on *how*
|
||||
chain-of-thought is requested on the wire. The `openai` provider adapts the
|
||||
request shape per backend; this table is the reference for which protocol each
|
||||
known backend uses and which parameters it honours or ignores.
|
||||
|
||||
## Auto-detected backends
|
||||
|
||||
These are recognised by base URL (see `internal/provider/openai/host.go`) and
|
||||
get a tailored request shape automatically — no extra config needed.
|
||||
|
||||
| Provider | Base URL | Reasoning control | `/effort` levels | Notes |
|
||||
|----------|----------|-------------------|------------------|-------|
|
||||
| DeepSeek | `api.deepseek.com`, `*.deepseek.com` | `thinking.type` + `reasoning_effort` (depth) | `auto`, `disabled`, `high`, `max` | Thinking on by default; `disabled` turns it off via `thinking.type=disabled`. |
|
||||
| MiniMax M3 | `api.minimaxi.com`, `*.minimaxi.com` | `thinking.type` (`adaptive`\|`disabled`) | `auto`, `adaptive`, `disabled` | No depth scale; `reasoning_effort` is omitted. |
|
||||
| Zhipu GLM | `open.bigmodel.cn` / `*.bigmodel.cn`, `api.z.ai` / `*.z.ai` | `thinking.type` (`enabled`\|`disabled`) | `auto`, `enabled`, `disabled` | **`reasoning_effort` is silently ignored** by the endpoint, so reasoning is driven purely through `thinking.type`. |
|
||||
|
||||
## Everything else (standard `reasoning_effort`)
|
||||
|
||||
Any other OpenAI-compatible backend falls through to the standard
|
||||
`reasoning_effort` scale (`low`\|`medium`\|`high`). Surveyed popular providers
|
||||
that need **no special handling** because they already follow this convention:
|
||||
|
||||
Qwen (`dashscope.aliyuncs.com`), Moonshot/Kimi (`api.moonshot.cn`), Yi
|
||||
(`api.01.ai`), SiliconFlow (`api.siliconflow.cn`), Stepfun (`api.stepfun.com`),
|
||||
Groq (`api.groq.com`), Together (`api.together.xyz`), OpenRouter
|
||||
(`openrouter.ai`), Perplexity (`api.perplexity.ai`), xAI (`api.x.ai`).
|
||||
|
||||
For a backend that uses a binary `thinking.type` toggle but is **not**
|
||||
auto-detected, set the vendor-agnostic `thinking` field on the provider entry:
|
||||
|
||||
```toml
|
||||
[[providers]]
|
||||
name = "my-glm-proxy"
|
||||
kind = "openai"
|
||||
base_url = "https://my-gateway.example.com/v1"
|
||||
model = "glm-4.6"
|
||||
api_key_env = "MY_API_KEY"
|
||||
thinking = "disabled" # enabled | disabled — emits thinking.type
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If a model keeps thinking when you asked it not to (or vice versa):
|
||||
|
||||
1. Check the table above — a backend may **ignore** the parameter you set
|
||||
(e.g. Zhipu ignores `reasoning_effort`; use `thinking`/`/effort` instead).
|
||||
2. If the backend isn't auto-detected, set the explicit `thinking` field.
|
||||
3. If the backend uses a non-OpenAI protocol entirely (e.g. Baidu Wenxin), the
|
||||
`openai` kind cannot drive its thinking mode — that needs a dedicated
|
||||
provider kind.
|
||||
|
||||
Distinguishing "provider ignores the field" from a Reasonix bug starts here:
|
||||
the request shape Reasonix emits is fixed per the table, so a mismatch between
|
||||
the table and observed behaviour is the provider's, not Reasonix's.
|
||||
+8
-3
@@ -18,7 +18,7 @@ provides the pre-release buffer instead of a long-lived branch.
|
||||
| Surface | Stable | Pre-release buffer |
|
||||
|---|---|---|
|
||||
| npm | `latest` (0.x), `next` (1.x) | `canary` (`npm i reasonix@canary`) |
|
||||
| Desktop | R2 `latest/` pointer | R2 `canary/` pointer (R2-only — never on the GitHub releases page) |
|
||||
| Desktop | R2 `latest/` pointer + release gateway | R2 `canary/` pointer + release gateway proxy (never on the GitHub releases page) |
|
||||
|
||||
A canary build is isolated: it **never** moves `latest` / `next` / desktop `latest/`.
|
||||
Testers opt in explicitly. (Desktop builds carry `-X main.channel=canary`; npm versions
|
||||
@@ -68,8 +68,13 @@ the `release` environment deployment.
|
||||
- Canary version numbers use the workflow `run_number`, so the desktop and CLI canary
|
||||
numbers differ (e.g. `canary.11` vs `canary.2`). Only monotonicity per channel matters.
|
||||
- A stable `-rc` tag (e.g. `npm-v1.4.0-rc.1`) still ships under `next`, not `canary`.
|
||||
- Desktop in-app updates use R2 first. Stable has a GitHub release fallback; canary is
|
||||
R2-only and never appears on the GitHub releases page.
|
||||
- Desktop in-app updates use R2 first, then the `crash.reasonix.io` desktop release
|
||||
gateway. The gateway resolves the `desktop-v*` release line directly and never uses
|
||||
GitHub's repository-wide `/releases/latest`, because plain `v*` tags are the CLI
|
||||
release line. Stable CLI releases also carry a compatibility `latest.json` asset so
|
||||
older desktop builds that still use GitHub `latest` do not 404.
|
||||
- Canary uses R2 plus the same gateway proxy for the `canary/` pointer; it never
|
||||
appears on the GitHub releases page.
|
||||
- Windows and Linux apply downloaded, minisign-verified artifacts in place. macOS
|
||||
applies in-app only for Developer ID signed and notarized builds; ad-hoc/local
|
||||
builds fall back to the download page.
|
||||
@@ -169,8 +169,11 @@ time.
|
||||
The controller treats these tools like plan approval:
|
||||
|
||||
- Auto approval and YOLO/full-access mode do not bypass them.
|
||||
- Guardian/safety review cannot allow them on the user's behalf.
|
||||
- Session grants and persistent allow rules are not created for them.
|
||||
- Pending memory approvals are not drained when the user toggles auto approval.
|
||||
- Non-interactive headless runs and sub-agents refuse them instead of treating
|
||||
`Ask` as autonomous allow.
|
||||
|
||||
The approval subject is generated from the tool arguments before the
|
||||
`ApprovalRequest` event is emitted:
|
||||
|
||||
+33
-17
@@ -226,8 +226,11 @@ Long tasks eventually fill the model's context window. Reasonix manages this wit
|
||||
history when exact original wording or tool output matters.
|
||||
- Agent-initiated `remember` and `forget` calls require a fresh human approval
|
||||
each time, even when tool auto-approval or YOLO/full-access mode is enabled.
|
||||
The approval request includes a compact preview of the memory being saved or
|
||||
archived, while external notification hooks only receive the tool name.
|
||||
Guardian/safety review cannot answer these prompts on the user's behalf. In
|
||||
non-interactive headless runs or sub-agents, these tools are refused rather
|
||||
than auto-approved. The approval request includes a compact preview of the
|
||||
memory being saved or archived, while external notification hooks only receive
|
||||
the tool name.
|
||||
User-initiated memory edits in the local UI are already explicit user actions.
|
||||
See [`SESSION_MEMORY_RETRIEVAL.md`](SESSION_MEMORY_RETRIEVAL.md) for the
|
||||
detailed implementation contract.
|
||||
@@ -313,8 +316,16 @@ func (p Policy) Decide(toolName string, readOnly bool, args json.RawMessage) Dec
|
||||
decision: `auto`, `yolo`, and the approved-plan execution window do not answer
|
||||
it, but an explicit session grant still prevents repeat prompts for the same
|
||||
tool. Non-interactive sessions and declined approvals remain fail-closed.
|
||||
Writers, installers, memory mutation, process
|
||||
control, and `complete_step` (read-only yet post-approval only, so it
|
||||
Bash is gated separately: built-in read-only commands and concrete prefixes
|
||||
declared in `[agent].plan_mode_read_only_commands` may run. Interactive
|
||||
controllers may also ask once before running an unknown query-shaped prefix
|
||||
and may remember a persistent approval as the same
|
||||
`plan_mode_read_only_commands` entry. This bash trust prompt is also a fresh
|
||||
user decision: `auto`, `yolo`, and the approved-plan execution window do not
|
||||
answer it, while explicit session/persistent trust prevents repeat prompts for
|
||||
that prefix. Shell operators, background execution, shell interpreters, and
|
||||
unsafe arguments stay blocked while planning. Writers, installers, memory
|
||||
mutation, process control, and `complete_step` (read-only yet post-approval only, so it
|
||||
self-reports plan-unsafe) are refused; the enforced invariant is
|
||||
PlanSafe ⇒ ReadOnly. An untrusted read-only MCP/plugin tool is therefore
|
||||
blocked until the user approves or pre-trusts it, and it is excluded from
|
||||
@@ -520,6 +531,7 @@ memory_compiler = { enabled = true, verbosity = "observe" } # user/global only
|
||||
reasoning_language = "auto" # visible reasoning text: auto|zh|en
|
||||
# plan_mode_allowed_tools = ["custom_reader"] # extra read-only declarations for custom tools;
|
||||
# # cannot unlock known blocked tools or unsafe bash
|
||||
# plan_mode_read_only_commands = ["gh issue view", "gh pr diff"] # extra read-only shell prefixes for plan mode
|
||||
# planner_model = "deepseek-pro" # optional: two-model collaboration (low-frequency planner)
|
||||
# subagent_model = "deepseek-pro" # optional default for runAs=subagent skills
|
||||
# subagent_models = { review = "deepseek-pro", security_review = "deepseek-pro" }
|
||||
@@ -632,12 +644,14 @@ by default (root = cwd), so edits stay in the project while the agent can still
|
||||
update its own global config. `forbid_read` lists directories the agent should
|
||||
not read, list, or search; entries support `${VAR}` / `${VAR:-default}` expansion
|
||||
and should be absolute, or use `${HOME}` for home-relative secrets such as
|
||||
`${HOME}/.ssh`. `bash` is itself jailed on macOS by default
|
||||
(`[sandbox] bash = "enforce"`, Seatbelt): each command runs under sandbox-exec
|
||||
allowed to write only the same roots (+ temp and toolchain caches), denied reads
|
||||
under `forbid_read`, and allowed to reach the network only when `network = true`.
|
||||
Unsupported platforms fall back to running unconfined when no OS sandbox is
|
||||
available. The escape-prompt and Linux support are Phase 1's remainder (§9).
|
||||
`${HOME}/.ssh`. `bash` is itself jailed by default when an OS sandbox is
|
||||
available (`[sandbox] bash = "enforce"`: Seatbelt on macOS, bubblewrap on
|
||||
Linux): each command is allowed to write only the same roots (+ temp and
|
||||
toolchain caches), denied reads under `forbid_read`, and allowed to reach the
|
||||
network only when `network = true`.
|
||||
When no OS sandbox is available, `bash = "enforce"` refuses bash execution
|
||||
instead of running unconfined. The escape-prompt and broader OS support are Phase
|
||||
1's remainder (§9).
|
||||
|
||||
## 6. Error Handling
|
||||
|
||||
@@ -664,13 +678,15 @@ available. The escape-prompt and Linux support are Phase 1's remainder (§9).
|
||||
## 9. Roadmap (not in current scope)
|
||||
|
||||
- Sandbox Phase 1: an OS-level jail for `bash` so commands — not just the
|
||||
file-writer built-ins (Phase 0) — are confined to the workspace. **macOS
|
||||
(Seatbelt via `sandbox-exec`) ships, on by default** (see §5). Remaining: (a)
|
||||
the escape-prompt — detect a sandbox-denied failure and offer to re-run the
|
||||
command unconfined via the permission gate (in `reasonix run`, the command just
|
||||
fails and the model adapts), which completes the "allow inside the box, prompt
|
||||
at its edge" model; (b) Linux (bubblewrap / landlock). Shells out to OS tooling
|
||||
so the binary stays dependency-free; Windows is out of scope. With this in
|
||||
file-writer built-ins (Phase 0) — are confined to the workspace. **Seatbelt on
|
||||
macOS and bubblewrap on Linux ship, on by default when available** (see §5).
|
||||
Remaining: (a)
|
||||
the escape-prompt — detect sandbox-unavailable or sandbox-denied failures and
|
||||
offer an explicit, permission-gated unconfined rerun (in `reasonix run`, the
|
||||
command just fails and the model adapts), which completes the "allow inside the
|
||||
box, prompt at its edge" model; (b) broader OS sandbox support beyond current
|
||||
Seatbelt/bubblewrap hosts. Shells out to OS tooling so the binary stays
|
||||
dependency-free. With this in
|
||||
place, "always allow" rule persistence becomes optional rather than load-bearing.
|
||||
- MCP long tail (deferred deliberately — no consumer / no foundation yet): OAuth
|
||||
2.0 + `headersHelper` auth for remote servers; the remaining `.mcp.json` scopes
|
||||
|
||||
@@ -49,7 +49,7 @@ Reasonix 桌面端输入框下方的“询问 / 自动 / Yolo”控制的是工
|
||||
- 显式 `deny` 规则仍然阻止对应工具或命令。
|
||||
- 显式 `ask` 规则仍然会弹出审批。
|
||||
- 计划模式的“开始执行”确认仍然需要你选择。
|
||||
- `remember` / `forget` 等记忆写入或删除操作仍需要新鲜审批。
|
||||
- `remember` / `forget` 等记忆写入或删除操作仍需要新鲜人工审批,Guardian/安全审查不能代替用户批准。
|
||||
- ask 问题仍然等待你回答,不会由自动模式代选。
|
||||
|
||||
### 建议使用场景
|
||||
@@ -86,7 +86,7 @@ Yolo 模式用于最大化连续执行。开启后,普通工具审批会被跳
|
||||
|
||||
- Yolo 只跳过工具权限审批,不会跳过计划确认和 ask 问题。
|
||||
- Yolo 不会绕过后端的 `deny` 规则、系统权限、工作区限制或沙箱限制。
|
||||
- 记忆写入/删除、计划确认等强制新鲜审批仍然会等待你处理。
|
||||
- 记忆写入/删除、计划确认等强制新鲜审批仍然会等待你处理;非交互运行和子代理会拒绝这类工具,而不是自动批准。
|
||||
- 不建议在生产环境、敏感目录、密钥文件、发布流程或需求不清楚的任务里开启。
|
||||
|
||||
## 与协作方式的组合
|
||||
|
||||
Loaded 100 of 399 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user