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 | ||
|
|
ef1d287fc0 | ||
|
|
5b81febdab | ||
|
|
c58805afea | ||
|
|
44876d2607 | ||
|
|
8ecdf392ad |
No files matched your search
@@ -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
|
||||
|
||||
+115
-27
@@ -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
|
||||
@@ -663,6 +668,7 @@ func (a *App) shutdown(context.Context) {
|
||||
slog.Warn("desktop: shutdown snapshot failed", "tab", t.ID, "err", err)
|
||||
}
|
||||
t.Ctrl.Close()
|
||||
t.releaseSessionLease()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1430,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()
|
||||
@@ -1474,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 {
|
||||
@@ -1505,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()
|
||||
@@ -1901,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)
|
||||
}
|
||||
@@ -1926,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 {
|
||||
@@ -2293,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)
|
||||
@@ -2402,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 {
|
||||
@@ -2607,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()
|
||||
@@ -2806,6 +2844,7 @@ func (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string
|
||||
}
|
||||
} else {
|
||||
ctrl.Close()
|
||||
tab.releaseSessionLease()
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
@@ -4996,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"},
|
||||
@@ -5062,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)
|
||||
@@ -5092,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
|
||||
@@ -5174,6 +5218,7 @@ func (a *App) Capabilities() CapabilitiesView {
|
||||
Servers: a.MCPServers(),
|
||||
Skills: skills.Skills,
|
||||
SkillRoots: skills.SkillRoots,
|
||||
Plugins: a.Plugins(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5605,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 {
|
||||
@@ -6563,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
|
||||
@@ -6575,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
|
||||
}
|
||||
|
||||
@@ -6665,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
|
||||
@@ -6679,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
|
||||
}
|
||||
|
||||
@@ -6743,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
|
||||
@@ -6760,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"
|
||||
@@ -173,6 +174,229 @@ func TestAutosaveFailureRetriesAndRecoversOnNextTurnDone(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+612
-3
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -38,7 +38,7 @@ 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";
|
||||
@@ -1030,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);
|
||||
@@ -2588,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("");
|
||||
@@ -2848,6 +2882,13 @@ 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>) => {
|
||||
@@ -3399,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">
|
||||
@@ -3538,6 +3586,7 @@ export default function App() {
|
||||
retry={state.retry}
|
||||
transientDismissSignal={transientOverlayDismissSignal}
|
||||
sessionKey={composerSessionKey}
|
||||
fileRefRefreshKey={composerFileRefRefreshKey}
|
||||
guidanceConsumedKey={latestGuidanceConsumed?.key}
|
||||
guidanceConsumedText={latestGuidanceConsumed?.text}
|
||||
guidanceQueuePreviewItems={guidanceQueueMockItems}
|
||||
@@ -3656,6 +3705,7 @@ export default function App() {
|
||||
onPreviewModeChange={handleWorkspacePreviewModeChange}
|
||||
onAddToChat={addWorkspaceTextToComposer}
|
||||
onRequestPanelWidth={ensureWorkspacePanelWidth}
|
||||
onFileTreeRefresh={refreshComposerFileRefs}
|
||||
refreshKey={dockRefreshKey}
|
||||
initialViewMode={rightDockMode === "changed" ? "changed" : "files"}
|
||||
showViewTabs={false}
|
||||
|
||||
@@ -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),
|
||||
@@ -276,6 +282,13 @@ for (const selector of [
|
||||
);
|
||||
}
|
||||
|
||||
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`);
|
||||
@@ -225,5 +237,48 @@ console.log("\napproval modal file references");
|
||||
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");
|
||||
|
||||
{
|
||||
@@ -739,5 +749,156 @@ console.log("\ncomposer goal toggle");
|
||||
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 { cacheHitTone, contextBreakdown, contextCostDisplay, contextSourceRows, contextWindowStatus, 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;
|
||||
@@ -130,6 +130,28 @@ 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");
|
||||
|
||||
const sourceRows = contextSourceRows({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,25 +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 = {
|
||||
@@ -181,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) => {
|
||||
@@ -195,13 +205,25 @@ 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;
|
||||
@@ -280,6 +302,10 @@ ok(controller?.state.items.some((item) => item.kind === "user" && item.text ===
|
||||
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);
|
||||
@@ -288,12 +314,69 @@ await act(async () => {
|
||||
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 () => {
|
||||
@@ -382,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();
|
||||
@@ -389,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);
|
||||
@@ -58,6 +58,7 @@ export function AppChrome({
|
||||
}: AppChromeProps) {
|
||||
const t = useT();
|
||||
const darwinChrome = platform === "darwin";
|
||||
const titlebarDragRail = darwinChrome || platform === "windows";
|
||||
const chromeClassName = [
|
||||
"app-chrome",
|
||||
"app-chrome--tabs",
|
||||
@@ -90,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",
|
||||
|
||||
@@ -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,6 +52,9 @@ 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;
|
||||
@@ -93,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);
|
||||
};
|
||||
|
||||
@@ -108,7 +116,7 @@ export function ApprovalModal({
|
||||
playAttentionChime();
|
||||
}, [approval.id, isPlanApproval, showToolDetailsByDefault]);
|
||||
|
||||
const actionCount = isPlanApproval ? 3 : 4;
|
||||
const actionCount = isPlanApproval ? 3 : isFreshHumanApproval ? 2 : 4;
|
||||
const selectedIndexRef = useRef(selectedIndex);
|
||||
selectedIndexRef.current = selectedIndex;
|
||||
|
||||
@@ -129,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();
|
||||
@@ -138,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) {
|
||||
@@ -291,7 +300,7 @@ export function ApprovalModal({
|
||||
barRef={cardRef}
|
||||
titleId="tool-approval-title"
|
||||
title={t("approval.toolPending")}
|
||||
badges={<PromptBadge>{approval.tool}</PromptBadge>}
|
||||
badges={<PromptBadge>{toolLabel}</PromptBadge>}
|
||||
meta={toolMeta}
|
||||
headerActions={
|
||||
<>
|
||||
@@ -308,9 +317,15 @@ export function ApprovalModal({
|
||||
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} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,6 +58,7 @@ 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;
|
||||
@@ -69,6 +71,11 @@ type PendingGuidance = {
|
||||
submitText: string;
|
||||
};
|
||||
|
||||
type FileRefSearchCacheEntry = {
|
||||
entries: DirEntry[];
|
||||
cachedAt: number;
|
||||
};
|
||||
|
||||
type ComposerDraft = {
|
||||
text: string;
|
||||
attachments: Attachment[];
|
||||
@@ -444,6 +451,7 @@ export function Composer({
|
||||
retry,
|
||||
transientDismissSignal,
|
||||
sessionKey,
|
||||
fileRefRefreshKey,
|
||||
guidanceConsumedKey,
|
||||
guidanceConsumedText,
|
||||
guidanceQueuePreviewItems,
|
||||
@@ -486,6 +494,7 @@ export function Composer({
|
||||
retry?: { attempt: number; max: number };
|
||||
transientDismissSignal?: number;
|
||||
sessionKey?: string;
|
||||
fileRefRefreshKey?: number | string;
|
||||
guidanceConsumedKey?: string;
|
||||
guidanceConsumedText?: string;
|
||||
guidanceQueuePreviewItems?: readonly string[];
|
||||
@@ -496,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[]>([]);
|
||||
@@ -743,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([]);
|
||||
@@ -763,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([]);
|
||||
@@ -793,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 [];
|
||||
@@ -2362,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")}
|
||||
@@ -2409,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) => {
|
||||
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -77,6 +77,22 @@ export function formatCacheHitRate(hitTokens: number, missTokens: number): strin
|
||||
|
||||
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;
|
||||
@@ -287,6 +303,8 @@ export function ContextPanel({
|
||||
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;
|
||||
@@ -311,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
|
||||
@@ -331,10 +353,12 @@ export function ContextPanel({
|
||||
: usage?.totalTokens && usage.totalTokens > 0
|
||||
? usage.totalTokens
|
||||
: promptTokens + completionTokens;
|
||||
const reasoningTokens = hasPanelUsage ? info?.reasoningTokens ?? 0 : usage?.reasoningTokens ?? 0;
|
||||
const reasoningTokens = usage?.reasoningTokens ?? info?.reasoningTokens ?? 0;
|
||||
// Session-cumulative values for the top summary.
|
||||
const sessionCacheHit = info?.sessionCacheHitTokens ?? usage?.sessionCacheHitTokens ?? context?.cacheHitTokens ?? 0;
|
||||
const sessionCacheMiss = info?.sessionCacheMissTokens ?? usage?.sessionCacheMissTokens ?? context?.cacheMissTokens ?? 0;
|
||||
// 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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1140,7 +1140,15 @@ export function ProjectTree({
|
||||
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");
|
||||
@@ -1217,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}` : ""}${unread ? " project-tree__topic--unread" : ""}${!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}
|
||||
>
|
||||
@@ -1271,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">
|
||||
@@ -1294,6 +1307,11 @@ export function ProjectTree({
|
||||
{meta}
|
||||
</span>
|
||||
)}
|
||||
{compactTopics && recovered && (
|
||||
<span className="sr-only">
|
||||
{t("recovery.branch")}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{unread && <span className="project-tree__topic-unread-dot" aria-hidden="true" />}
|
||||
{projectTreeShouldRenderTopicActions(isSessionNode, compactTopics, unread) && (
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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")}
|
||||
|
||||
@@ -48,13 +48,18 @@ import type {
|
||||
Mode,
|
||||
ModelInfo,
|
||||
NetworkView,
|
||||
PluginInstallOptions,
|
||||
PluginView,
|
||||
ProjectNode,
|
||||
PromptHistoryEntry,
|
||||
PromptHistoryResult,
|
||||
ProviderPresetView,
|
||||
ProviderView,
|
||||
QuestionAnswer,
|
||||
ServerView,
|
||||
SessionMeta,
|
||||
SessionRecoveryFailedEvent,
|
||||
SessionRecoveryEvent,
|
||||
SettingsView,
|
||||
SkillsSettingsView,
|
||||
SkillRootView,
|
||||
@@ -207,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>;
|
||||
@@ -216,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>;
|
||||
@@ -277,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>;
|
||||
@@ -289,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>;
|
||||
@@ -571,17 +588,31 @@ 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))
|
||||
@@ -695,11 +726,140 @@ function mockScenario(): "demo" | "fresh" | "running" | "guidance" {
|
||||
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 guidanceMock = scenario === "guidance";
|
||||
const runningMock = scenario === "running" || guidanceMock;
|
||||
const mockAttachmentDataURLs = new Map<string, string>();
|
||||
let cancelled = false;
|
||||
let pendingAskPreview = false;
|
||||
let pendingApprovalPreview = false;
|
||||
@@ -788,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);
|
||||
@@ -870,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",
|
||||
@@ -927,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: "",
|
||||
@@ -961,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",
|
||||
@@ -1004,7 +1194,7 @@ function makeMockApp(): AppBindings {
|
||||
metrics: true,
|
||||
memoryCompilerEnabled: true,
|
||||
configPath: "~/projects/reasonix/reasonix.toml",
|
||||
providerKinds: ["openai"],
|
||||
providerKinds: ["openai", "anthropic"],
|
||||
autoApproveTools: false,
|
||||
bypass: false,
|
||||
};
|
||||
@@ -2155,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 },
|
||||
];
|
||||
@@ -2164,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() {
|
||||
@@ -2175,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({
|
||||
@@ -2281,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)) {
|
||||
@@ -2451,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;
|
||||
@@ -2485,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];
|
||||
@@ -2687,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;
|
||||
},
|
||||
@@ -2701,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}`);
|
||||
@@ -2710,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) {
|
||||
@@ -2748,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;
|
||||
@@ -2823,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" : "",
|
||||
@@ -2971,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>
|
||||
|
||||
@@ -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;
|
||||
@@ -683,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
|
||||
@@ -791,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 {
|
||||
@@ -808,6 +877,8 @@ export interface ProviderView {
|
||||
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
|
||||
@@ -816,11 +887,29 @@ 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;
|
||||
@@ -859,6 +948,8 @@ export interface SandboxView {
|
||||
network: boolean;
|
||||
workspaceRoot: string;
|
||||
allowWrite: string[];
|
||||
effectiveWorkspaceRoot: string;
|
||||
effectiveWriteRoots: string[];
|
||||
shell: string; // "auto" | "bash" | "powershell" | "pwsh"
|
||||
}
|
||||
|
||||
@@ -881,6 +972,7 @@ export interface AgentView {
|
||||
temperature: number;
|
||||
maxSteps: number;
|
||||
plannerMaxSteps: number;
|
||||
maxSubagentDepth: number;
|
||||
systemPrompt: string;
|
||||
coldResumePrune: boolean;
|
||||
reasoningLanguage: string; // "auto" | "zh" | "en"
|
||||
@@ -892,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 {
|
||||
@@ -955,6 +1097,7 @@ export interface BotConnectionView {
|
||||
model: string;
|
||||
toolApprovalMode: ToolApprovalMode | "" | string;
|
||||
workspaceRoot: string;
|
||||
access: BotAccessView;
|
||||
credential: BotConnectionCredentialView;
|
||||
sessionMappings: BotConnectionSessionMappingView[];
|
||||
lastError: string;
|
||||
@@ -968,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;
|
||||
@@ -1043,6 +1194,7 @@ export interface SettingsView {
|
||||
autoPlan: string;
|
||||
providers: ProviderView[];
|
||||
officialProviders: ProviderView[];
|
||||
providerPresets: ProviderPresetView[];
|
||||
permissions: PermissionsView;
|
||||
sandbox: SandboxView;
|
||||
network: NetworkView;
|
||||
|
||||
@@ -168,6 +168,7 @@ type RuntimeMetaSnapshot = {
|
||||
running: boolean;
|
||||
pendingPrompt?: boolean;
|
||||
backgroundJobs?: number;
|
||||
cancelRequested?: boolean;
|
||||
cancellable?: boolean;
|
||||
};
|
||||
|
||||
@@ -354,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 } {
|
||||
@@ -1192,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[]));
|
||||
@@ -1216,12 +1245,14 @@ export function useController() {
|
||||
confirmBackendActiveTab(active.id);
|
||||
dispatchTo(active.id, { type: "optimistic_meta", meta: metaFromTab(active, statesRef.current.get(active.id)?.meta) });
|
||||
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,
|
||||
@@ -1233,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;
|
||||
@@ -1259,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);
|
||||
@@ -1422,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");
|
||||
@@ -1731,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);
|
||||
}
|
||||
@@ -1758,7 +1781,7 @@ 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> => {
|
||||
@@ -1772,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;
|
||||
@@ -1820,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);
|
||||
@@ -1838,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);
|
||||
@@ -1856,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);
|
||||
@@ -1876,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);
|
||||
@@ -1901,9 +1954,12 @@ 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 {
|
||||
|
||||
@@ -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",
|
||||
@@ -432,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",
|
||||
@@ -671,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",
|
||||
@@ -844,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",
|
||||
@@ -859,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",
|
||||
@@ -940,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",
|
||||
@@ -962,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",
|
||||
@@ -1069,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",
|
||||
@@ -1084,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.",
|
||||
@@ -1140,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",
|
||||
@@ -1159,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",
|
||||
@@ -1191,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",
|
||||
@@ -1222,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+)",
|
||||
@@ -1233,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",
|
||||
@@ -1340,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.",
|
||||
@@ -1354,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",
|
||||
@@ -1366,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",
|
||||
@@ -1406,6 +1638,12 @@ export const en = {
|
||||
"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.",
|
||||
@@ -1425,10 +1663,16 @@ export const en = {
|
||||
"settings.newProviderDraftName": "new provider",
|
||||
"settings.reasoningProtocol": "Model capability mode",
|
||||
"settings.reasoningProtocol.auto": "Auto-detect (recommended)",
|
||||
"settings.reasoningProtocol.deepseek": "DeepSeek thinking",
|
||||
"settings.reasoningProtocol.openai": "OpenAI reasoning",
|
||||
"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",
|
||||
@@ -1543,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",
|
||||
@@ -1624,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",
|
||||
|
||||
@@ -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} 個缺失",
|
||||
@@ -309,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": "工作區引用",
|
||||
@@ -514,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": "置頂",
|
||||
@@ -629,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": "網路",
|
||||
@@ -642,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": "代理與連線",
|
||||
@@ -698,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": "詢問",
|
||||
@@ -720,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",
|
||||
@@ -807,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": "強調色",
|
||||
@@ -847,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": "寫操作模式與細粒度工具權限規則。",
|
||||
@@ -859,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": "設定",
|
||||
@@ -871,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",
|
||||
@@ -911,6 +1047,12 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"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。",
|
||||
@@ -928,10 +1070,16 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.newProviderDraftName": "新供應商",
|
||||
"settings.reasoningProtocol": "模型能力模式",
|
||||
"settings.reasoningProtocol.auto": "自動識別(推薦)",
|
||||
"settings.reasoningProtocol.deepseek": "DeepSeek 思考",
|
||||
"settings.reasoningProtocol.openai": "OpenAI reasoning",
|
||||
"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": "金鑰",
|
||||
@@ -985,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": "跳到底部",
|
||||
@@ -1451,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": "總開關",
|
||||
@@ -1466,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。",
|
||||
@@ -1522,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",
|
||||
@@ -1541,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",
|
||||
@@ -1573,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+)",
|
||||
@@ -1745,7 +1991,7 @@ 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.windowStatusHealthy": "上下文充足",
|
||||
"context.windowStatusWatch": "即將壓縮",
|
||||
|
||||
@@ -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} 个缺失",
|
||||
@@ -433,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": "拖放文件附加失败",
|
||||
@@ -672,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": "置顶",
|
||||
@@ -846,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": "快捷键",
|
||||
@@ -861,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": "按键与帮助",
|
||||
@@ -942,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": "询问",
|
||||
@@ -964,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",
|
||||
@@ -1071,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": "总开关",
|
||||
@@ -1086,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。",
|
||||
@@ -1142,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",
|
||||
@@ -1161,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",
|
||||
@@ -1193,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": "写操作模式",
|
||||
@@ -1224,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+)",
|
||||
@@ -1235,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": "视觉风格",
|
||||
@@ -1342,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": "自定义键盘快捷键,并让帮助表保持同步。",
|
||||
@@ -1356,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": "配置",
|
||||
@@ -1368,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",
|
||||
@@ -1408,6 +1640,12 @@ export const zh: Record<DictKey, string> = {
|
||||
"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。",
|
||||
@@ -1427,10 +1665,16 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.newProviderDraftName": "新供应商",
|
||||
"settings.reasoningProtocol": "模型能力模式",
|
||||
"settings.reasoningProtocol.auto": "自动识别(推荐)",
|
||||
"settings.reasoningProtocol.deepseek": "DeepSeek 思考",
|
||||
"settings.reasoningProtocol.openai": "OpenAI reasoning",
|
||||
"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": "密钥",
|
||||
@@ -1545,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": "跳到底部",
|
||||
@@ -1626,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": "检查更新",
|
||||
|
||||
+1872
-136
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=
|
||||
@@ -88,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"},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+719
-105
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)
|
||||
|
||||
@@ -443,6 +544,7 @@ func TestSaveProviderPreservesHiddenProviderFields(t *testing.T) {
|
||||
Thinking: "adaptive",
|
||||
Effort: "high",
|
||||
VisionDetail: "low",
|
||||
ExtraBody: map[string]any{"enable_thinking": true},
|
||||
NoProxy: true,
|
||||
}}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
@@ -463,6 +565,9 @@ func TestSaveProviderPreservesHiddenProviderFields(t *testing.T) {
|
||||
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)
|
||||
@@ -485,6 +590,9 @@ func TestSaveProviderPreservesHiddenProviderFields(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
@@ -721,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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+627
-94
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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+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` 规则、系统权限、工作区限制或沙箱限制。
|
||||
- 记忆写入/删除、计划确认等强制新鲜审批仍然会等待你处理。
|
||||
- 记忆写入/删除、计划确认等强制新鲜审批仍然会等待你处理;非交互运行和子代理会拒绝这类工具,而不是自动批准。
|
||||
- 不建议在生产环境、敏感目录、密钥文件、发布流程或需求不清楚的任务里开启。
|
||||
|
||||
## 与协作方式的组合
|
||||
|
||||
+5
-5
@@ -207,17 +207,17 @@ reasonix"><span data-en>Copy</span><span data-zh>复制</span></button><pre><spa
|
||||
</div>
|
||||
<div class="panel" data-panel="desk">
|
||||
<div class="dls">
|
||||
<a class="dl" href="https://pub-147fb53b9c1e4bbf891a257968619ea7.r2.dev/latest/Reasonix-darwin-arm64.zip" download>
|
||||
<a class="dl" href="https://dl.reasonix.io/latest/Reasonix-darwin-arm64.zip" download>
|
||||
<span class="os">macOS</span><span class="fn">Reasonix-darwin-arm64.zip</span><span class="ar" data-en>universal · Intel + Apple Silicon</span><span class="ar" data-zh>通用 · Intel + Apple Silicon</span><span class="go">↓</span>
|
||||
</a>
|
||||
<a class="dl" href="https://pub-147fb53b9c1e4bbf891a257968619ea7.r2.dev/latest/Reasonix-windows-amd64-installer.exe" download>
|
||||
<a class="dl" href="https://dl.reasonix.io/latest/Reasonix-windows-amd64-installer.exe" download>
|
||||
<span class="os">Windows</span><span class="fn">Reasonix-windows-amd64-installer.exe</span><span class="ar" data-en>installer · x64</span><span class="ar" data-zh>安装器 · x64</span><span class="go">↓</span>
|
||||
</a>
|
||||
<a class="dl" href="https://pub-147fb53b9c1e4bbf891a257968619ea7.r2.dev/latest/Reasonix-linux-amd64.tar.gz" download>
|
||||
<a class="dl" href="https://dl.reasonix.io/latest/Reasonix-linux-amd64.tar.gz" download>
|
||||
<span class="os">Linux</span><span class="fn">Reasonix-linux-amd64.tar.gz</span><span class="ar" data-en>x64</span><span class="ar" data-zh>x64</span><span class="go">↓</span>
|
||||
</a>
|
||||
</div>
|
||||
<p class="undertext"><span data-en>Served from the R2 CDN (fast worldwide). Or browse <a href="https://github.com/esengine/DeepSeek-Reasonix/releases/latest">all releases on GitHub</a>.</span><span data-zh>经 R2 CDN 分发(全球加速)。或在 <a href="https://github.com/esengine/DeepSeek-Reasonix/releases/latest">GitHub releases</a> 浏览全部。</span></p>
|
||||
<p class="undertext"><span data-en>Served from the R2 CDN (fast worldwide). Or browse <a href="https://github.com/esengine/DeepSeek-Reasonix/releases">all releases on GitHub</a>.</span><span data-zh>经 R2 CDN 分发(全球加速)。或在 <a href="https://github.com/esengine/DeepSeek-Reasonix/releases">GitHub releases</a> 浏览全部。</span></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -258,7 +258,7 @@ reasonix"><span data-en>Copy</span><span data-zh>复制</span></button><pre><spa
|
||||
<a href="https://github.com/esengine/DeepSeek-Reasonix">GitHub</a>
|
||||
<a href="https://www.npmjs.com/package/reasonix">npm</a>
|
||||
<a href="https://platform.deepseek.com" target="_blank" rel="noreferrer">DeepSeek</a>
|
||||
<a href="https://github.com/esengine/DeepSeek-Reasonix/releases/latest"><span data-en>Releases</span><span data-zh>下载</span></a>
|
||||
<a href="https://github.com/esengine/DeepSeek-Reasonix/releases"><span data-en>Releases</span><span data-zh>下载</span></a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -8,13 +8,23 @@ require (
|
||||
charm.land/bubbles/v2 v2.1.0
|
||||
charm.land/bubbletea/v2 v2.0.7
|
||||
charm.land/lipgloss/v2 v2.0.4
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3
|
||||
github.com/BurntSushi/toml v1.6.0
|
||||
github.com/alecthomas/chroma/v2 v2.27.0
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/aymanbagabas/go-udiff v0.4.1
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0
|
||||
github.com/charmbracelet/x/ansi v0.11.7
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.9.5
|
||||
github.com/mattn/go-runewidth v0.0.24
|
||||
github.com/rivo/uniseg v0.4.7
|
||||
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
|
||||
github.com/tree-sitter/go-tree-sitter v0.25.0
|
||||
github.com/tree-sitter/tree-sitter-javascript v0.25.0
|
||||
github.com/tree-sitter/tree-sitter-python v0.25.0
|
||||
github.com/tree-sitter/tree-sitter-rust v0.24.2
|
||||
github.com/tree-sitter/tree-sitter-typescript v0.23.2
|
||||
github.com/yuin/goldmark v1.8.2
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
go.uber.org/goleak v1.3.0
|
||||
@@ -25,6 +35,8 @@ require (
|
||||
golang.org/x/sys v0.46.0
|
||||
golang.org/x/term v0.44.0
|
||||
golang.org/x/text v0.38.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
mvdan.cc/sh/v3 v3.13.1
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -37,12 +49,13 @@ require (
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/danieljoos/wincred v1.2.3 // indirect
|
||||
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/gorilla/websocket v1.5.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||
github.com/mattn/go-pointer v0.0.1 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
)
|
||||
@@ -4,6 +4,8 @@ charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0=
|
||||
charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs=
|
||||
charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q=
|
||||
charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik=
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
|
||||
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/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
@@ -18,6 +20,8 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
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/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260601155805-6cf7526a1b3f h1:vKsPSlO4g4jKfJ9enESgNZ45BkbHngTIq3UxNOzic74=
|
||||
@@ -43,20 +47,34 @@ 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/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
|
||||
github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||
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/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
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/larksuite/oapi-sdk-go/v3 v3.9.5 h1:2axP/8ME2YZNdgvX+10lWeRiAKvXx5SjVKRyePmPE3Q=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.9.5/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
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/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
@@ -65,6 +83,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
@@ -73,6 +93,34 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
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/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
@@ -110,6 +158,7 @@ golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||
@@ -127,6 +176,10 @@ golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8T
|
||||
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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/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=
|
||||
@@ -251,8 +251,6 @@ func (s *updateSink) requestPermission(ctx context.Context, a event.Approval) {
|
||||
allow = true
|
||||
case OptAllowAlways:
|
||||
allow, session = true, true
|
||||
case OptAllowPersistent:
|
||||
allow, session, persist = true, true, true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -331,18 +329,16 @@ func (s *updateSink) requestAskQuestion(ctx context.Context, askID string, q eve
|
||||
return label, ok
|
||||
}
|
||||
|
||||
func approvalOptionNames(tool, subject string) (session, persistent string) {
|
||||
func approvalSessionOptionName(tool, subject string) string {
|
||||
sessionRule := permission.SessionGrantRuleForScope(tool, subject)
|
||||
persistentRule := permission.RememberRuleForScope(tool, subject)
|
||||
return "Allow " + sessionRule + " for this session", "Always allow " + persistentRule + " (save to config)"
|
||||
return "Allow " + sessionRule + " for this session"
|
||||
}
|
||||
|
||||
func approvalOptions(tool, subject string) []PermissionOption {
|
||||
allowSessionName, allowPersistentName := approvalOptionNames(tool, subject)
|
||||
allowSessionName := approvalSessionOptionName(tool, subject)
|
||||
options := []PermissionOption{
|
||||
{OptionID: string(OptAllowOnce), Name: "Allow", Kind: OptAllowOnce},
|
||||
{OptionID: string(OptAllowAlways), Name: allowSessionName, Kind: OptAllowAlways},
|
||||
{OptionID: string(OptAllowPersistent), Name: allowPersistentName, Kind: OptAllowPersistent},
|
||||
{OptionID: string(OptRejectOnce), Name: "Reject", Kind: OptRejectOnce},
|
||||
}
|
||||
return options
|
||||
|
||||
@@ -189,6 +189,31 @@ type approveCall struct {
|
||||
persist bool
|
||||
}
|
||||
|
||||
func invalidACPv1PermissionOptionKind(options []PermissionOption) (PermissionOption, bool) {
|
||||
// ACP v1 schema only accepts these four PermissionOptionKind values. ACP hosts
|
||||
// own cross-session persistence, so Reasonix-specific persistent approvals must
|
||||
// not appear in session/request_permission options.
|
||||
valid := map[PermissionOptionKind]bool{
|
||||
OptAllowOnce: true,
|
||||
OptAllowAlways: true,
|
||||
OptRejectOnce: true,
|
||||
OptRejectAlways: true,
|
||||
}
|
||||
for _, opt := range options {
|
||||
if !valid[opt.Kind] {
|
||||
return opt, true
|
||||
}
|
||||
}
|
||||
return PermissionOption{}, false
|
||||
}
|
||||
|
||||
func assertACPv1PermissionOptionKinds(t *testing.T, options []PermissionOption) {
|
||||
t.Helper()
|
||||
if opt, ok := invalidACPv1PermissionOptionKind(options); ok {
|
||||
t.Fatalf("permission option %q uses non-ACP-v1 kind %q", opt.OptionID, opt.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSinkApprovalAllowAlways(t *testing.T) {
|
||||
fn := &fakeNotifier{onReq: func(method string, params any) (json.RawMessage, error) {
|
||||
if method != "session/request_permission" {
|
||||
@@ -208,6 +233,7 @@ func TestUpdateSinkApprovalAllowAlways(t *testing.T) {
|
||||
if p.ToolCall.ToolCallID != "gate-9" {
|
||||
t.Errorf("toolCallId = %q, want gate-9", p.ToolCall.ToolCallID)
|
||||
}
|
||||
assertACPv1PermissionOptionKinds(t, p.Options)
|
||||
res, _ := json.Marshal(PermissionRequestResult{
|
||||
Outcome: PermissionOutcome{Outcome: "selected", OptionID: string(OptAllowAlways)},
|
||||
})
|
||||
@@ -236,22 +262,30 @@ func TestUpdateSinkApprovalBashPrefix(t *testing.T) {
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
t.Fatalf("permission params: %v", err)
|
||||
}
|
||||
// Bash with a safe prefix now uses the same standard options as any
|
||||
// other tool (allow_always / allow_persistent); the old prefix-specific
|
||||
// OptAllowPrefix/OptPersistPrefix are gone.
|
||||
var hasSession, hasPersistent bool
|
||||
// ACP permission options stay within the official spec kinds, and ACP
|
||||
// mode leaves cross-session persistence to the host.
|
||||
assertACPv1PermissionOptionKinds(t, p.Options)
|
||||
var hasOnce, hasSession, hasReject bool
|
||||
for _, opt := range p.Options {
|
||||
hasSession = hasSession || opt.OptionID == string(OptAllowAlways)
|
||||
hasPersistent = hasPersistent || opt.OptionID == string(OptAllowPersistent)
|
||||
switch opt.OptionID {
|
||||
case string(OptAllowOnce):
|
||||
hasOnce = opt.Kind == OptAllowOnce
|
||||
case string(OptAllowAlways):
|
||||
hasSession = opt.Kind == OptAllowAlways
|
||||
case string(OptRejectOnce):
|
||||
hasReject = opt.Kind == OptRejectOnce
|
||||
default:
|
||||
t.Fatalf("unexpected ACP permission option %+v in %+v", opt, p.Options)
|
||||
}
|
||||
}
|
||||
if !hasSession || !hasPersistent {
|
||||
t.Fatalf("options = %+v, want standard session and persistent choices", p.Options)
|
||||
if !hasOnce || !hasSession || !hasReject {
|
||||
t.Fatalf("options = %+v, want allow once, session, reject", p.Options)
|
||||
}
|
||||
if len(p.Options) != 4 {
|
||||
t.Fatalf("options = %+v, want allow once, session, persistent, reject", p.Options)
|
||||
if len(p.Options) != 3 {
|
||||
t.Fatalf("options = %+v, want allow once, session, reject", p.Options)
|
||||
}
|
||||
res, _ := json.Marshal(PermissionRequestResult{
|
||||
Outcome: PermissionOutcome{Outcome: "selected", OptionID: string(OptAllowPersistent)},
|
||||
Outcome: PermissionOutcome{Outcome: "selected", OptionID: string(OptAllowAlways)},
|
||||
})
|
||||
return res, nil
|
||||
}}
|
||||
@@ -263,7 +297,7 @@ func TestUpdateSinkApprovalBashPrefix(t *testing.T) {
|
||||
|
||||
select {
|
||||
case c := <-got:
|
||||
want := approveCall{id: "10", allow: true, session: true, persist: true}
|
||||
want := approveCall{id: "10", allow: true, session: true, persist: false}
|
||||
if c != want {
|
||||
t.Errorf("approve = %+v, want %+v", c, want)
|
||||
}
|
||||
@@ -328,6 +362,7 @@ func TestUpdateSinkAskRequestUsesPermissionChoices(t *testing.T) {
|
||||
if len(p.Options) != 3 {
|
||||
t.Fatalf("options = %+v, want two answers plus cancel", p.Options)
|
||||
}
|
||||
assertACPv1PermissionOptionKinds(t, p.Options)
|
||||
if p.Options[0].Name != "Tests - Run the suite" || p.Options[0].Kind != OptAllowOnce {
|
||||
t.Fatalf("first option = %+v", p.Options[0])
|
||||
}
|
||||
|
||||
@@ -504,6 +504,10 @@ func TestE2EApprovalRoundTrip(t *testing.T) {
|
||||
var pr PermissionRequestParams
|
||||
json.Unmarshal(req.Params, &pr)
|
||||
reqSeen <- pr
|
||||
if _, ok := invalidACPv1PermissionOptionKind(pr.Options); ok {
|
||||
client.replyError(req.ID, ErrInvalidParams, "Invalid params")
|
||||
return
|
||||
}
|
||||
client.reply(req.ID, PermissionRequestResult{
|
||||
Outcome: PermissionOutcome{Outcome: "selected", OptionID: string(OptAllowOnce)},
|
||||
})
|
||||
@@ -522,6 +526,7 @@ func TestE2EApprovalRoundTrip(t *testing.T) {
|
||||
if !strings.Contains(pr.ToolCall.Title, "writeit") {
|
||||
t.Errorf("permission title = %q, want it to mention writeit", pr.ToolCall.Title)
|
||||
}
|
||||
assertACPv1PermissionOptionKinds(t, pr.Options)
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("no permission request was raised")
|
||||
}
|
||||
|
||||
@@ -490,15 +490,16 @@ type SessionCancelParams struct {
|
||||
|
||||
// --- session/request_permission (agent → client request) ---
|
||||
|
||||
// PermissionOptionKind classifies an option for host UI styling. Matches main.
|
||||
// PermissionOptionKind classifies an option for host UI styling. It is an ACP v1
|
||||
// wire enum, so host-visible permission choices must stay within the official
|
||||
// protocol values.
|
||||
type PermissionOptionKind string
|
||||
|
||||
const (
|
||||
OptAllowOnce PermissionOptionKind = "allow_once"
|
||||
OptAllowAlways PermissionOptionKind = "allow_always"
|
||||
OptAllowPersistent PermissionOptionKind = "allow_persistent"
|
||||
OptRejectOnce PermissionOptionKind = "reject_once"
|
||||
OptRejectAlways PermissionOptionKind = "reject_always"
|
||||
OptAllowOnce PermissionOptionKind = "allow_once"
|
||||
OptAllowAlways PermissionOptionKind = "allow_always"
|
||||
OptRejectOnce PermissionOptionKind = "reject_once"
|
||||
OptRejectAlways PermissionOptionKind = "reject_always"
|
||||
)
|
||||
|
||||
// PermissionOption is one choice offered to the user for a permission request.
|
||||
|
||||
@@ -331,6 +331,10 @@ func (c *rpcClient) reply(id *json.RawMessage, result any) {
|
||||
c.send(map[string]any{"jsonrpc": "2.0", "id": id, "result": result})
|
||||
}
|
||||
|
||||
func (c *rpcClient) replyError(id *json.RawMessage, code int, message string) {
|
||||
c.send(map[string]any{"jsonrpc": "2.0", "id": id, "error": rpcError{Code: code, Message: message}})
|
||||
}
|
||||
|
||||
func startServer(t *testing.T, factory Factory) (*rpcClient, func()) {
|
||||
t.Helper()
|
||||
inR, inW := io.Pipe()
|
||||
|
||||
+235
-43
@@ -11,6 +11,8 @@ import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"mvdan.cc/sh/v3/syntax"
|
||||
|
||||
"reasonix/internal/diff"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/evidence"
|
||||
@@ -21,6 +23,7 @@ import (
|
||||
"reasonix/internal/nilutil"
|
||||
"reasonix/internal/planmode"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/shellparse"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
@@ -58,6 +61,7 @@ type Asker interface {
|
||||
// callContextKey carries the executing tool call's identity into Execute.
|
||||
type callContextKey struct{}
|
||||
type parentSessionContextKey struct{}
|
||||
type subagentDepthContextKey struct{}
|
||||
type userImagesContextKey struct{}
|
||||
|
||||
// callContext is the per-call context a tool can read. parentID is the call being
|
||||
@@ -108,6 +112,24 @@ func ParentSession(ctx context.Context) string {
|
||||
return strings.TrimSpace(parentSession)
|
||||
}
|
||||
|
||||
// WithSubagentDepth carries the current subagent depth through nested tool calls.
|
||||
// The root agent runs at depth 0; each spawned subagent increments by one.
|
||||
func WithSubagentDepth(ctx context.Context, depth int) context.Context {
|
||||
if depth < 0 {
|
||||
depth = 0
|
||||
}
|
||||
return context.WithValue(ctx, subagentDepthContextKey{}, depth)
|
||||
}
|
||||
|
||||
// SubagentDepth returns the current subagent depth carried by a turn context.
|
||||
func SubagentDepth(ctx context.Context) int {
|
||||
depth, _ := ctx.Value(subagentDepthContextKey{}).(int)
|
||||
if depth < 0 {
|
||||
return 0
|
||||
}
|
||||
return depth
|
||||
}
|
||||
|
||||
// WithUserImages carries the data URLs of images the user attached to this turn,
|
||||
// resolved by the controller (which owns attachments) since the agent must not
|
||||
// depend on it. Run embeds them on the user message; the provider sends them only
|
||||
@@ -132,19 +154,25 @@ type Gate interface {
|
||||
Check(ctx context.Context, toolName string, args json.RawMessage, readOnly bool) (allow bool, reason string, err error)
|
||||
}
|
||||
|
||||
// PlanModeReadOnlyTrustRequest describes an external read-only hint that plan
|
||||
// mode will not trust without a user decision. ToolName is the provider-visible
|
||||
// name; ServerName and RawToolName are the MCP identifiers persisted in config.
|
||||
const PlanModeReadOnlyCommandApprovalTool = "plan_mode_read_only_command"
|
||||
|
||||
// PlanModeReadOnlyTrustRequest describes a read-only claim that plan mode will
|
||||
// not trust without a user decision. For MCP, ServerName and RawToolName are the
|
||||
// identifiers persisted in config. For bash, Command is the concrete attempted
|
||||
// command and Prefix is the command prefix to trust for planning.
|
||||
type PlanModeReadOnlyTrustRequest struct {
|
||||
ToolName string
|
||||
ServerName string
|
||||
RawToolName string
|
||||
Command string
|
||||
Prefix string
|
||||
Args json.RawMessage
|
||||
}
|
||||
|
||||
// PlanModeReadOnlyTrustGate optionally confirms an MCP server's self-reported
|
||||
// read-only hint at execution time. It is separate from Gate because the
|
||||
// plan-mode check runs before ordinary permission policy.
|
||||
// PlanModeReadOnlyTrustGate optionally confirms MCP read-only hints and
|
||||
// user-approved bash read-only command prefixes at execution time. It is
|
||||
// separate from Gate because the plan-mode check runs before ordinary permission
|
||||
// policy.
|
||||
type PlanModeReadOnlyTrustGate interface {
|
||||
CheckPlanModeReadOnlyTrust(ctx context.Context, req PlanModeReadOnlyTrustRequest) (allow bool, reason string, err error)
|
||||
}
|
||||
@@ -154,6 +182,17 @@ const (
|
||||
MemoryCompilerVerbosityCompact = "compact"
|
||||
)
|
||||
|
||||
const DefaultMaxSubagentDepth = 2
|
||||
|
||||
// NormalizeMaxSubagentDepth applies the public config contract: values below 1
|
||||
// preserve the old single-delegation boundary.
|
||||
func NormalizeMaxSubagentDepth(depth int) int {
|
||||
if depth < 1 {
|
||||
return 1
|
||||
}
|
||||
return depth
|
||||
}
|
||||
|
||||
// ToolHooks fires user-configured shell hooks around each tool call. PreToolUse
|
||||
// runs before the call and may block it (block=true; message is the reason fed
|
||||
// back to the model); PostToolUse runs after and only surfaces output to the
|
||||
@@ -314,7 +353,13 @@ type Agent struct {
|
||||
// planModeAllowedTools declares extra custom tools that the centralized
|
||||
// plan-mode policy may treat as read-only. Known blocked tools still lose.
|
||||
// Populated from Options.PlanModeAllowedTools during construction.
|
||||
planModeAllowedTools []string
|
||||
planModeAllowedTools []string
|
||||
planModeReadOnlyCommands []string
|
||||
|
||||
// subagentDepth tracks the current agent's nesting depth. maxSubagentDepth
|
||||
// caps delegation; when reached, recursive agent/skill tools are excluded.
|
||||
subagentDepth int
|
||||
maxSubagentDepth int
|
||||
|
||||
// Context management: when a turn's prompt nears contextWindow, the older
|
||||
// middle of the session is summarized away, keeping a token-bounded recent
|
||||
@@ -533,7 +578,8 @@ func (a *Agent) SetGate(g Gate) {
|
||||
}
|
||||
|
||||
// SetPlanModeReadOnlyTrustGate installs the optional confirmation path for MCP
|
||||
// tools whose read-only flag comes from an external readOnlyHint.
|
||||
// tools whose read-only flag comes from an external readOnlyHint and bash
|
||||
// commands the user may trust as plan-mode read-only.
|
||||
func (a *Agent) SetPlanModeReadOnlyTrustGate(g PlanModeReadOnlyTrustGate) {
|
||||
if nilutil.IsNil(g) {
|
||||
g = nil
|
||||
@@ -746,6 +792,15 @@ type Options struct {
|
||||
// PlanModeAllowedTools names extra custom tools the plan-mode policy may treat
|
||||
// as read-only. It cannot unlock known blocked tools or unsafe bash commands.
|
||||
PlanModeAllowedTools []string
|
||||
// PlanModeReadOnlyCommands names concrete shell command prefixes that plan mode
|
||||
// may treat as read-only. Shell operators, background execution, and shell
|
||||
// interpreter prefixes remain blocked.
|
||||
PlanModeReadOnlyCommands []string
|
||||
|
||||
// SubagentDepth is the current nesting depth for this agent. Root sessions are
|
||||
// depth 0; child subagents are depth 1. MaxSubagentDepth caps delegation.
|
||||
SubagentDepth int
|
||||
MaxSubagentDepth int
|
||||
|
||||
// MemoryCompiler enables Memory v5 execution trace writeback and cache-safe
|
||||
// execution-contract compilation.
|
||||
@@ -801,33 +856,46 @@ func New(prov provider.Provider, tools *tool.Registry, session *Session, opts Op
|
||||
if strings.TrimSpace(maxStepsKey) == "" {
|
||||
maxStepsKey = "agent.max_steps"
|
||||
}
|
||||
maxSubagentDepth := opts.MaxSubagentDepth
|
||||
if maxSubagentDepth == 0 {
|
||||
maxSubagentDepth = DefaultMaxSubagentDepth
|
||||
} else {
|
||||
maxSubagentDepth = NormalizeMaxSubagentDepth(maxSubagentDepth)
|
||||
}
|
||||
subagentDepth := opts.SubagentDepth
|
||||
if subagentDepth < 0 {
|
||||
subagentDepth = 0
|
||||
}
|
||||
a := &Agent{
|
||||
prov: prov,
|
||||
tools: tools,
|
||||
session: session,
|
||||
maxSteps: opts.MaxSteps,
|
||||
maxStepsKey: maxStepsKey,
|
||||
temperature: opts.Temperature,
|
||||
pricing: opts.Pricing,
|
||||
usageSource: usageSourceOrDefault(opts.UsageSource, event.UsageSourceExecutor),
|
||||
sink: sink,
|
||||
gate: gate,
|
||||
planModeReadOnlyTrust: planModeReadOnlyTrust,
|
||||
hooks: hooks,
|
||||
jobs: opts.Jobs,
|
||||
evidence: evidence.NewLedger(),
|
||||
projectChecks: append([]instruction.VerifyCheck(nil), opts.ProjectChecks...),
|
||||
contextWindow: opts.ContextWindow,
|
||||
softCompactRatio: opts.SoftCompactRatio,
|
||||
toolResultSnipRatio: opts.ToolResultSnipRatio,
|
||||
compactRatio: opts.CompactRatio,
|
||||
compactForceRatio: opts.CompactForceRatio,
|
||||
recentKeep: opts.RecentKeep,
|
||||
archiveDir: opts.ArchiveDir,
|
||||
keepPolicy: opts.KeepPolicy,
|
||||
planModeAllowedTools: append([]string(nil), opts.PlanModeAllowedTools...),
|
||||
memoryCompiler: opts.MemoryCompiler,
|
||||
memoryCompilerVerbosity: normalizeMemoryCompilerVerbosity(opts.MemoryCompilerVerbosity),
|
||||
prov: prov,
|
||||
tools: tools,
|
||||
session: session,
|
||||
maxSteps: opts.MaxSteps,
|
||||
maxStepsKey: maxStepsKey,
|
||||
temperature: opts.Temperature,
|
||||
pricing: opts.Pricing,
|
||||
usageSource: usageSourceOrDefault(opts.UsageSource, event.UsageSourceExecutor),
|
||||
sink: sink,
|
||||
gate: gate,
|
||||
planModeReadOnlyTrust: planModeReadOnlyTrust,
|
||||
hooks: hooks,
|
||||
jobs: opts.Jobs,
|
||||
evidence: evidence.NewLedger(),
|
||||
projectChecks: append([]instruction.VerifyCheck(nil), opts.ProjectChecks...),
|
||||
contextWindow: opts.ContextWindow,
|
||||
softCompactRatio: opts.SoftCompactRatio,
|
||||
toolResultSnipRatio: opts.ToolResultSnipRatio,
|
||||
compactRatio: opts.CompactRatio,
|
||||
compactForceRatio: opts.CompactForceRatio,
|
||||
recentKeep: opts.RecentKeep,
|
||||
archiveDir: opts.ArchiveDir,
|
||||
keepPolicy: opts.KeepPolicy,
|
||||
planModeAllowedTools: append([]string(nil), opts.PlanModeAllowedTools...),
|
||||
planModeReadOnlyCommands: append([]string(nil), opts.PlanModeReadOnlyCommands...),
|
||||
subagentDepth: subagentDepth,
|
||||
maxSubagentDepth: maxSubagentDepth,
|
||||
memoryCompiler: opts.MemoryCompiler,
|
||||
memoryCompilerVerbosity: normalizeMemoryCompilerVerbosity(opts.MemoryCompilerVerbosity),
|
||||
}
|
||||
// 初始化分类器
|
||||
if opts.UseMemoryCompilerLLMClassification && prov != nil {
|
||||
@@ -1561,7 +1629,7 @@ func (a *Agent) stream(ctx context.Context, turn int) (string, string, string, [
|
||||
ch, err := a.prov.Stream(ctx, provider.Request{
|
||||
Messages: a.session.Messages,
|
||||
Tools: a.tools.Schemas(),
|
||||
Temperature: a.temperature,
|
||||
Temperature: provider.OptionalTemperature(a.temperature),
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", "", nil, nil, false, false, err
|
||||
@@ -2022,6 +2090,7 @@ func (a *Agent) executeOne(ctx context.Context, call provider.ToolCall) toolOutc
|
||||
errMsg: "blocked: fresh read required",
|
||||
}
|
||||
}
|
||||
planModeTrustedReadOnly := false
|
||||
if a.planMode.Load() {
|
||||
// Translate the tool's optional plan-mode self-report into the policy's
|
||||
// tri-state. Mirrors the t.(tool.Previewer) assertion precedent below.
|
||||
@@ -2039,10 +2108,18 @@ func (a *Agent) executeOne(ctx context.Context, call provider.ToolCall) toolOutc
|
||||
if u, ok := t.(tool.PlanModeUntrustedReadOnly); ok {
|
||||
untrusted = u.PlanModeUntrustedReadOnly()
|
||||
}
|
||||
if blocked, msg := a.planModeBlocked(call.Name, t.ReadOnly(), untrusted, safety, json.RawMessage(call.Arguments)); blocked {
|
||||
if decision := a.planModeDecision(call.Name, t.ReadOnly(), untrusted, safety, json.RawMessage(call.Arguments)); decision.Blocked {
|
||||
trustAllowed := false
|
||||
if t.ReadOnly() && untrusted && safety != planmode.PlanSafetyUnsafe {
|
||||
if allow, outcome, handled := a.checkPlanModeReadOnlyTrust(ctx, call, t); handled {
|
||||
if decision.ReadOnlyCommandTrust != nil {
|
||||
if allow, outcome, handled := a.checkPlanModeBashReadOnlyTrust(ctx, call, decision.ReadOnlyCommandTrust); handled {
|
||||
if !allow {
|
||||
return outcome
|
||||
}
|
||||
trustAllowed = true
|
||||
planModeTrustedReadOnly = true
|
||||
}
|
||||
} else if t.ReadOnly() && untrusted && safety != planmode.PlanSafetyUnsafe {
|
||||
if allow, outcome, handled := a.checkPlanModeMCPReadOnlyTrust(ctx, call, t); handled {
|
||||
if !allow {
|
||||
return outcome
|
||||
}
|
||||
@@ -2051,7 +2128,7 @@ func (a *Agent) executeOne(ctx context.Context, call provider.ToolCall) toolOutc
|
||||
}
|
||||
if !trustAllowed {
|
||||
return toolOutcome{
|
||||
output: msg,
|
||||
output: decision.Message,
|
||||
blocked: true,
|
||||
errMsg: "blocked: plan mode is read-only",
|
||||
}
|
||||
@@ -2059,7 +2136,8 @@ func (a *Agent) executeOne(ctx context.Context, call provider.ToolCall) toolOutc
|
||||
}
|
||||
}
|
||||
if a.gate != nil {
|
||||
allow, reason, err := a.gate.Check(ctx, call.Name, json.RawMessage(call.Arguments), t.ReadOnly())
|
||||
readOnly := t.ReadOnly() || planModeTrustedReadOnly
|
||||
allow, reason, err := a.gate.Check(ctx, call.Name, json.RawMessage(call.Arguments), readOnly)
|
||||
if err != nil {
|
||||
return toolOutcome{
|
||||
output: fmt.Sprintf("blocked: %s (%v)", reason, err),
|
||||
@@ -2101,6 +2179,7 @@ func (a *Agent) executeOne(ctx context.Context, call provider.ToolCall) toolOutc
|
||||
}
|
||||
}
|
||||
cctx := withCallContext(ctx, call.ID, a.sink, a.asker, a.planMode.Load())
|
||||
cctx = WithSubagentDepth(cctx, a.subagentDepth)
|
||||
if a.evidence != nil {
|
||||
cctx = evidence.WithLedger(cctx, a.evidence)
|
||||
cctx = evidence.WithSessionMessages(cctx, a.session.Snapshot())
|
||||
@@ -2172,7 +2251,7 @@ func (a *Agent) executeOne(ctx context.Context, call provider.ToolCall) toolOutc
|
||||
return toolOutcome{output: body, truncated: truncMsg != "", truncMsg: truncMsg}
|
||||
}
|
||||
|
||||
func (a *Agent) checkPlanModeReadOnlyTrust(ctx context.Context, call provider.ToolCall, t tool.Tool) (bool, toolOutcome, bool) {
|
||||
func (a *Agent) checkPlanModeMCPReadOnlyTrust(ctx context.Context, call provider.ToolCall, t tool.Tool) (bool, toolOutcome, bool) {
|
||||
if a.planModeReadOnlyTrust == nil {
|
||||
return false, toolOutcome{}, false
|
||||
}
|
||||
@@ -2207,6 +2286,37 @@ func (a *Agent) checkPlanModeReadOnlyTrust(ctx context.Context, call provider.To
|
||||
return true, toolOutcome{}, true
|
||||
}
|
||||
|
||||
func (a *Agent) checkPlanModeBashReadOnlyTrust(ctx context.Context, call provider.ToolCall, trust *planmode.ReadOnlyCommandTrust) (bool, toolOutcome, bool) {
|
||||
if a.planModeReadOnlyTrust == nil || trust == nil || strings.TrimSpace(trust.Prefix) == "" {
|
||||
return false, toolOutcome{}, false
|
||||
}
|
||||
req := PlanModeReadOnlyTrustRequest{
|
||||
ToolName: PlanModeReadOnlyCommandApprovalTool,
|
||||
Command: trust.Command,
|
||||
Prefix: trust.Prefix,
|
||||
Args: json.RawMessage(call.Arguments),
|
||||
}
|
||||
allow, reason, err := a.planModeReadOnlyTrust.CheckPlanModeReadOnlyTrust(ctx, req)
|
||||
if err != nil {
|
||||
return false, toolOutcome{
|
||||
output: fmt.Sprintf("blocked: plan-mode read-only command trust approval aborted (%v)", err),
|
||||
blocked: true,
|
||||
errMsg: fmt.Sprintf("blocked: %v", err),
|
||||
}, true
|
||||
}
|
||||
if !allow {
|
||||
if strings.TrimSpace(reason) == "" {
|
||||
reason = "the user declined to trust this bash command as read-only for plan mode — do not retry it; continue planning with other trusted read-only tools."
|
||||
}
|
||||
return false, toolOutcome{
|
||||
output: "blocked: " + reason,
|
||||
blocked: true,
|
||||
errMsg: "blocked by plan-mode bash read-only trust",
|
||||
}, true
|
||||
}
|
||||
return true, toolOutcome{}, true
|
||||
}
|
||||
|
||||
func planModeMCPTrustTarget(toolName string, t tool.Tool) (server, rawTool string, ok bool) {
|
||||
if meta, metaOK := t.(tool.MCPMetadata); metaOK {
|
||||
server = strings.TrimSpace(meta.MCPServerName())
|
||||
@@ -2220,14 +2330,21 @@ func planModeMCPTrustTarget(toolName string, t tool.Tool) (server, rawTool strin
|
||||
}
|
||||
|
||||
func (a *Agent) planModeBlocked(toolName string, readOnly, untrusted bool, safety planmode.PlanSafety, args json.RawMessage) (blocked bool, message string) {
|
||||
decision := planmode.Policy{AllowedTools: a.planModeAllowedTools}.Decide(planmode.Call{
|
||||
decision := a.planModeDecision(toolName, readOnly, untrusted, safety, args)
|
||||
return decision.Blocked, decision.Message
|
||||
}
|
||||
|
||||
func (a *Agent) planModeDecision(toolName string, readOnly, untrusted bool, safety planmode.PlanSafety, args json.RawMessage) planmode.Decision {
|
||||
return planmode.Policy{
|
||||
AllowedTools: a.planModeAllowedTools,
|
||||
ReadOnlyCommands: a.planModeReadOnlyCommands,
|
||||
}.Decide(planmode.Call{
|
||||
Name: toolName,
|
||||
ReadOnly: readOnly,
|
||||
Untrusted: untrusted,
|
||||
Safety: safety,
|
||||
Args: args,
|
||||
})
|
||||
return decision.Blocked, decision.Message
|
||||
}
|
||||
|
||||
func planModeBashBlocked(args json.RawMessage) (bool, string) {
|
||||
@@ -2327,6 +2444,9 @@ func canonicalToolArgs(raw string) string {
|
||||
}
|
||||
|
||||
func normalizeShellCommand(command string) string {
|
||||
if fields, malformed := shellparse.StaticFields(command); malformed == "" && len(fields) > 0 {
|
||||
return strings.Join(fields, " ")
|
||||
}
|
||||
return strings.Join(strings.Fields(command), " ")
|
||||
}
|
||||
|
||||
@@ -2362,6 +2482,78 @@ func shellPythonOpenWrites(lower string) bool {
|
||||
}
|
||||
|
||||
func hasShellWriteRedirect(command string) bool {
|
||||
file, err := shellparse.ParseBash(command)
|
||||
if err == nil {
|
||||
hasWrite := false
|
||||
syntax.Walk(file, func(node syntax.Node) bool {
|
||||
redir, ok := node.(*syntax.Redirect)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if bashRedirectWritesFile(command, redir) {
|
||||
hasWrite = true
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
return hasWrite
|
||||
}
|
||||
return hasShellWriteRedirectFallback(command)
|
||||
}
|
||||
|
||||
func bashRedirectWritesFile(source string, redir *syntax.Redirect) bool {
|
||||
if redir == nil {
|
||||
return false
|
||||
}
|
||||
switch redir.Op {
|
||||
case syntax.RdrOut, syntax.AppOut, syntax.RdrClob, syntax.AppClob,
|
||||
syntax.RdrAll, syntax.RdrAllClob, syntax.AppAll, syntax.AppAllClob,
|
||||
syntax.RdrInOut:
|
||||
return !redirectWordIsNullSink(source, redir.Word)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func redirectWordIsNullSink(source string, word *syntax.Word) bool {
|
||||
if word == nil {
|
||||
return false
|
||||
}
|
||||
if value, ok := shellparse.StaticWord(word); ok {
|
||||
if isNullSinkWord(strings.TrimSpace(value)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
value := strings.TrimSpace(redirectWordSource(source, word))
|
||||
if isNullSinkWord(value) {
|
||||
return true
|
||||
}
|
||||
if len(value) >= 2 && ((value[0] == '\'' && value[len(value)-1] == '\'') || (value[0] == '"' && value[len(value)-1] == '"')) {
|
||||
return isNullSinkWord(value[1 : len(value)-1])
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isNullSinkWord(value string) bool {
|
||||
if value == "/dev/null" {
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(value, "$null") || strings.EqualFold(value, "nul")
|
||||
}
|
||||
|
||||
func redirectWordSource(source string, word *syntax.Word) string {
|
||||
if word == nil || !word.Pos().IsValid() || !word.End().IsValid() {
|
||||
return ""
|
||||
}
|
||||
start := int(word.Pos().Offset())
|
||||
end := int(word.End().Offset())
|
||||
if start < 0 || end < start || end > len(source) {
|
||||
return ""
|
||||
}
|
||||
return source[start:end]
|
||||
}
|
||||
|
||||
func hasShellWriteRedirectFallback(command string) bool {
|
||||
var quote rune
|
||||
var prev rune
|
||||
for _, r := range command {
|
||||
|
||||
@@ -28,11 +28,18 @@ type BranchMeta struct {
|
||||
WorkspaceRoot string `json:"workspace_root,omitempty"`
|
||||
TopicID string `json:"topic_id,omitempty"`
|
||||
TopicTitle string `json:"topic_title,omitempty"`
|
||||
CustomTitle string `json:"custom_title,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
TokenMode string `json:"token_mode,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
ToolApprovalMode string `json:"tool_approval_mode,omitempty"`
|
||||
Goal string `json:"goal,omitempty"`
|
||||
Recovered bool `json:"recovered,omitempty"`
|
||||
RecoveryReason string `json:"recovery_reason,omitempty"`
|
||||
RecoveryDigest string `json:"recovery_digest,omitempty"`
|
||||
Revision int64 `json:"revision,omitempty"`
|
||||
ContentDigest string `json:"content_digest,omitempty"`
|
||||
WriterID string `json:"writer_id,omitempty"`
|
||||
// SchemaVersion records the BranchMeta version that last wrote the listing
|
||||
// fields (Turns/Preview) FROM the session's content. It is stamped only by the
|
||||
// writers that actually derive those counts — Controller.snapshot's
|
||||
@@ -147,6 +154,9 @@ func saveBranchMeta(sessionPath string, m BranchMeta, touchUpdated bool) error {
|
||||
if touchUpdated || m.UpdatedAt.IsZero() {
|
||||
m.UpdatedAt = now
|
||||
}
|
||||
if existing, ok, err := LoadBranchMeta(sessionPath); err == nil && ok {
|
||||
preserveBranchMetaPersistence(&m, existing)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(metaPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -176,6 +186,26 @@ func saveBranchMeta(sessionPath string, m BranchMeta, touchUpdated bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func preserveBranchMetaPersistence(next *BranchMeta, existing BranchMeta) {
|
||||
if next == nil {
|
||||
return
|
||||
}
|
||||
if existing.Revision > next.Revision {
|
||||
next.Revision = existing.Revision
|
||||
next.ContentDigest = existing.ContentDigest
|
||||
next.WriterID = existing.WriterID
|
||||
return
|
||||
}
|
||||
if existing.Revision == next.Revision {
|
||||
if strings.TrimSpace(next.ContentDigest) == "" {
|
||||
next.ContentDigest = existing.ContentDigest
|
||||
}
|
||||
if strings.TrimSpace(next.WriterID) == "" {
|
||||
next.WriterID = existing.WriterID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func EnsureBranchMeta(sessionPath string) (BranchMeta, error) {
|
||||
if sessionPath == "" {
|
||||
return BranchMeta{}, fmt.Errorf("empty session path")
|
||||
@@ -288,9 +318,10 @@ func ListBranches(dir string) ([]BranchInfo, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RenameSession updates the TopicTitle in the session's .jsonl.meta sidecar
|
||||
// file. If no meta file exists yet, one is created. This is used by the
|
||||
// /rename CLI command and desktop UI to give sessions human-readable names.
|
||||
// RenameSession updates the user-chosen display title in the session's
|
||||
// .jsonl.meta sidecar file. If no meta file exists yet, one is created. The
|
||||
// topic title remains a separate grouping label, so explicit session names do
|
||||
// not fight topic auto-titling.
|
||||
func RenameSession(sessionPath string, title string) error {
|
||||
if sessionPath == "" {
|
||||
return fmt.Errorf("empty session path")
|
||||
@@ -299,8 +330,8 @@ func RenameSession(sessionPath string, title string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.TopicTitle = title
|
||||
return SaveBranchMeta(sessionPath, m)
|
||||
m.CustomTitle = strings.TrimSpace(title)
|
||||
return SaveBranchMetaPreserveUpdated(sessionPath, m)
|
||||
}
|
||||
|
||||
// LoadSessionModel reads the canonical provider/model ref saved beside a
|
||||
|
||||
@@ -623,7 +623,7 @@ func (a *Agent) summarize(ctx context.Context, region []provider.Message, instru
|
||||
{Role: provider.RoleSystem, Content: sys},
|
||||
{Role: provider.RoleUser, Content: renderTranscript(region)},
|
||||
},
|
||||
Temperature: a.temperature,
|
||||
Temperature: provider.OptionalTemperature(a.temperature),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -281,7 +281,7 @@ func (c *Coordinator) plan(ctx context.Context, input string) (string, error) {
|
||||
|
||||
ch, err := c.planner.Stream(ctx, provider.Request{
|
||||
Messages: c.plannerSess.Messages,
|
||||
Temperature: c.temperature,
|
||||
Temperature: provider.OptionalTemperature(c.temperature),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -58,7 +58,7 @@ func TestRunPrefixesReasoningLanguageOnSyntheticRetry(t *testing.T) {
|
||||
}
|
||||
for i, req := range prov.requests {
|
||||
got := lastUser(req)
|
||||
if !strings.HasPrefix(got, "<reasoning-language>") || !strings.Contains(got, "Simplified Chinese") {
|
||||
if !strings.HasPrefix(got, "<reasoning-language>") || !strings.Contains(got, "简体中文") {
|
||||
t.Fatalf("request %d last user = %q, want reasoning-language prefix", i, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/jobs"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
// ParallelTasksTool dispatches multiple sub-agent tasks concurrently and
|
||||
// collects all results. Each sub-task runs as a foreground sub-agent in its
|
||||
// ParallelTasksTool dispatches multiple read-only sub-agent tasks concurrently
|
||||
// and collects all results. Each sub-task runs as a foreground sub-agent in its
|
||||
// own goroutine, emitting nested events so the frontend renders independent
|
||||
// cards for each sub-task.
|
||||
type ParallelTasksTool struct {
|
||||
@@ -34,7 +32,7 @@ func NewParallelTasksTool(taskTool *TaskTool, reg *tool.Registry) *ParallelTasks
|
||||
func (p *ParallelTasksTool) Name() string { return "parallel_tasks" }
|
||||
|
||||
func (p *ParallelTasksTool) Description() string {
|
||||
return "Dispatch multiple sub-agent tasks concurrently and collect their results. Each task runs in its own sub-agent in parallel. Blocks until all complete."
|
||||
return "Dispatch multiple read-only sub-agent tasks concurrently and collect their results. Each task runs in its own read-only sub-agent in parallel. Blocks until all complete."
|
||||
}
|
||||
|
||||
func (p *ParallelTasksTool) Schema() json.RawMessage {
|
||||
@@ -62,7 +60,9 @@ func (p *ParallelTasksTool) Schema() json.RawMessage {
|
||||
}`)
|
||||
}
|
||||
|
||||
func (p *ParallelTasksTool) ReadOnly() bool { return false }
|
||||
func (p *ParallelTasksTool) ReadOnly() bool { return true }
|
||||
|
||||
func (p *ParallelTasksTool) PlanModeSafe() bool { return true }
|
||||
|
||||
type parallelTaskItem struct {
|
||||
Prompt string `json:"prompt"`
|
||||
@@ -71,7 +71,6 @@ type parallelTaskItem struct {
|
||||
MaxSteps int `json:"max_steps"`
|
||||
Model string `json:"model"`
|
||||
Effort string `json:"effort"`
|
||||
DependsOn []int `json:"depends_on"`
|
||||
}
|
||||
|
||||
type parallelTaskStatus string
|
||||
@@ -88,7 +87,9 @@ func (p *ParallelTasksTool) Execute(ctx context.Context, args json.RawMessage) (
|
||||
var params struct {
|
||||
Tasks []parallelTaskItem `json:"tasks"`
|
||||
}
|
||||
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||
dec := json.NewDecoder(bytes.NewReader(args))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(¶ms); err != nil {
|
||||
return "", fmt.Errorf("invalid args: %w", err)
|
||||
}
|
||||
if len(params.Tasks) == 0 {
|
||||
@@ -100,11 +101,14 @@ func (p *ParallelTasksTool) Execute(ctx context.Context, args json.RawMessage) (
|
||||
if err := validateParallelTaskItems(params.Tasks); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if p.taskTool == nil {
|
||||
return "", fmt.Errorf("parallel_tasks is not configured")
|
||||
}
|
||||
|
||||
parentID, sink, _, ok := CallContext(ctx)
|
||||
if !ok || sink == nil {
|
||||
// Fallback: no event sink available, use background-jobs approach.
|
||||
return p.runAsBackgroundJobs(ctx, params.Tasks)
|
||||
parentID = "parallel_tasks"
|
||||
sink = event.Discard
|
||||
}
|
||||
|
||||
type subResult struct {
|
||||
@@ -115,63 +119,30 @@ func (p *ParallelTasksTool) Execute(ctx context.Context, args json.RawMessage) (
|
||||
|
||||
n := len(params.Tasks)
|
||||
|
||||
// Dependency state: remaining = number of deps not yet completed.
|
||||
remaining := make([]int, n)
|
||||
running := make([]bool, n)
|
||||
done := make([]bool, n)
|
||||
outputs := make([]string, n)
|
||||
taskErrs := make([]error, n)
|
||||
statuses := make([]parallelTaskStatus, n)
|
||||
for i, t := range params.Tasks {
|
||||
remaining[i] = len(t.DependsOn)
|
||||
for i := range params.Tasks {
|
||||
statuses[i] = parallelTaskPending
|
||||
}
|
||||
|
||||
doneCh := make(chan subResult, n)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
wisdomDir, _ := os.MkdirTemp("", "parallel-wisdom-*")
|
||||
if wisdomDir != "" {
|
||||
defer os.RemoveAll(wisdomDir)
|
||||
}
|
||||
makePrompt := func(t parallelTaskItem) string {
|
||||
var prefix strings.Builder
|
||||
if len(t.DependsOn) > 0 && wisdomDir != "" {
|
||||
entries, _ := os.ReadDir(wisdomDir)
|
||||
if len(entries) > 0 {
|
||||
fmt.Fprintf(&prefix, "Previous task results are available at %s. Read the relevant files with read_file before starting.\n\n", wisdomDir)
|
||||
}
|
||||
}
|
||||
prefix.WriteString(t.Prompt)
|
||||
return prefix.String()
|
||||
}
|
||||
makeLabel := func(t parallelTaskItem, idx int) string {
|
||||
if t.Description != "" {
|
||||
return t.Description
|
||||
}
|
||||
return fmt.Sprintf("task-%d", idx+1)
|
||||
}
|
||||
writeWisdom := func(r subResult) {
|
||||
if wisdomDir == "" {
|
||||
return
|
||||
}
|
||||
fname := filepath.Join(wisdomDir, fmt.Sprintf("task-%d.md", r.index+1))
|
||||
switch {
|
||||
case r.output != "":
|
||||
summary := fmt.Sprintf("# Task %d Result\n\n%s", r.index+1, strings.TrimSpace(r.output))
|
||||
_ = os.WriteFile(fname, []byte(summary), 0o644)
|
||||
case r.err != nil:
|
||||
summary := fmt.Sprintf("# Task %d Result\n\nFAILED: %s", r.index+1, r.err)
|
||||
_ = os.WriteFile(fname, []byte(summary), 0o644)
|
||||
}
|
||||
}
|
||||
startTask := func(idx int) {
|
||||
t := params.Tasks[idx]
|
||||
running[idx] = true
|
||||
prompt := makePrompt(t)
|
||||
label := makeLabel(t, idx)
|
||||
subID := fmt.Sprintf("%s/sub-%d", parentID, idx+1)
|
||||
dispatchArgs, _ := json.Marshal(map[string]string{"prompt": prompt, "description": label})
|
||||
dispatchArgs, _ := json.Marshal(map[string]string{"prompt": t.Prompt, "description": label})
|
||||
sink.Emit(event.Event{
|
||||
Kind: event.ToolDispatch,
|
||||
Tool: event.Tool{
|
||||
@@ -185,7 +156,16 @@ func (p *ParallelTasksTool) Execute(ctx context.Context, args json.RawMessage) (
|
||||
defer wg.Done()
|
||||
nested := subSinkFor(subID, sink)
|
||||
modelRef, effortRef := p.taskTool.effectiveProfile(t.Model, t.Effort)
|
||||
subReg := p.taskTool.buildSubReg(t.Tools)
|
||||
childDepth, depthErr := p.taskTool.nextSubagentDepth(ctx)
|
||||
if depthErr != nil {
|
||||
sink.Emit(event.Event{
|
||||
Kind: event.ToolResult,
|
||||
Tool: event.Tool{ID: subID, ParentID: parentID, Name: "task", Err: depthErr.Error()},
|
||||
})
|
||||
doneCh <- subResult{index: idx, err: depthErr}
|
||||
return
|
||||
}
|
||||
subReg := ReadOnlySubagentToolRegistryForDepth(p.taskTool.parentReg, t.Tools, childDepth, p.taskTool.maxDepth())
|
||||
|
||||
max := t.MaxSteps
|
||||
if max <= 0 {
|
||||
@@ -202,8 +182,8 @@ func (p *ParallelTasksTool) Execute(ctx context.Context, args json.RawMessage) (
|
||||
return
|
||||
}
|
||||
|
||||
sess := NewSession("")
|
||||
output, runErr := RunSubAgentWithSession(ctx, prov, subReg, sess, prompt, Options{
|
||||
sess := NewSession(DefaultReadOnlyTaskSystemPrompt)
|
||||
output, runErr := RunSubAgentWithSession(ctx, prov, subReg, sess, p.taskTool.withWorkspaceContext(t.Prompt), Options{
|
||||
MaxSteps: max,
|
||||
Temperature: p.taskTool.temperature,
|
||||
Pricing: pricing,
|
||||
@@ -217,6 +197,8 @@ func (p *ParallelTasksTool) Execute(ctx context.Context, args json.RawMessage) (
|
||||
CompactForceRatio: p.taskTool.compactForceRatio,
|
||||
ArchiveDir: p.taskTool.archiveDir,
|
||||
KeepPolicy: p.taskTool.keepPolicy,
|
||||
SubagentDepth: childDepth,
|
||||
MaxSubagentDepth: p.taskTool.maxDepth(),
|
||||
}, nested)
|
||||
|
||||
if ctx.Err() != nil && runErr == nil {
|
||||
@@ -242,16 +224,6 @@ func (p *ParallelTasksTool) Execute(ctx context.Context, args json.RawMessage) (
|
||||
}()
|
||||
}
|
||||
|
||||
startReady := func() {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
for i := range params.Tasks {
|
||||
if remaining[i] == 0 && !running[i] && !done[i] {
|
||||
startTask(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
markCancelled := func(err error) {
|
||||
for i := range params.Tasks {
|
||||
if done[i] {
|
||||
@@ -269,8 +241,10 @@ func (p *ParallelTasksTool) Execute(ctx context.Context, args json.RawMessage) (
|
||||
}
|
||||
|
||||
completed := 0
|
||||
startReady()
|
||||
processResult := func(r subResult, schedule bool) {
|
||||
for i := range params.Tasks {
|
||||
startTask(i)
|
||||
}
|
||||
processResult := func(r subResult) {
|
||||
if done[r.index] {
|
||||
return
|
||||
}
|
||||
@@ -286,32 +260,18 @@ func (p *ParallelTasksTool) Execute(ctx context.Context, args json.RawMessage) (
|
||||
default:
|
||||
statuses[r.index] = parallelTaskFailed
|
||||
}
|
||||
writeWisdom(r)
|
||||
|
||||
for i, t := range params.Tasks {
|
||||
if remaining[i] > 0 && !running[i] && !done[i] {
|
||||
for _, dep := range t.DependsOn {
|
||||
if dep == r.index {
|
||||
remaining[i]--
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if schedule {
|
||||
startReady()
|
||||
}
|
||||
}
|
||||
for completed < n {
|
||||
select {
|
||||
case r := <-doneCh:
|
||||
processResult(r, true)
|
||||
processResult(r)
|
||||
case <-ctx.Done():
|
||||
err := ctx.Err()
|
||||
drain:
|
||||
for {
|
||||
select {
|
||||
case r := <-doneCh:
|
||||
processResult(r, false)
|
||||
processResult(r)
|
||||
default:
|
||||
break drain
|
||||
}
|
||||
@@ -381,132 +341,11 @@ func formatParallelTasksAggregate(outputs []string, errs []error, statuses []par
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// runAsBackgroundJobs is the fallback path when no event sink is available.
|
||||
func (p *ParallelTasksTool) runAsBackgroundJobs(ctx context.Context, tasks []parallelTaskItem) (string, error) {
|
||||
jm, ok := jobs.FromContext(ctx)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("background jobs are not available in this context")
|
||||
}
|
||||
session := jobs.SessionFromContext(ctx)
|
||||
|
||||
if err := validateParallelTaskItems(tasks); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
type jobRef struct {
|
||||
id string
|
||||
label string
|
||||
idx int
|
||||
}
|
||||
var refs []jobRef
|
||||
|
||||
for i, t := range tasks {
|
||||
if strings.TrimSpace(t.Prompt) == "" {
|
||||
return "", fmt.Errorf("task %d: prompt is required", i+1)
|
||||
}
|
||||
label := t.Description
|
||||
if label == "" {
|
||||
label = fmt.Sprintf("task-%d", i+1)
|
||||
}
|
||||
|
||||
subArgs := map[string]interface{}{
|
||||
"prompt": t.Prompt,
|
||||
"description": label,
|
||||
"run_in_background": true,
|
||||
}
|
||||
if len(t.Tools) > 0 {
|
||||
subArgs["tools"] = t.Tools
|
||||
}
|
||||
if t.MaxSteps > 0 {
|
||||
subArgs["max_steps"] = t.MaxSteps
|
||||
}
|
||||
if t.Model != "" {
|
||||
subArgs["model"] = t.Model
|
||||
}
|
||||
if t.Effort != "" {
|
||||
subArgs["effort"] = t.Effort
|
||||
}
|
||||
|
||||
subJSON, err := json.Marshal(subArgs)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("task %d: marshal: %w", i+1, err)
|
||||
}
|
||||
|
||||
result, err := p.taskTool.Execute(ctx, subJSON)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("task %d dispatch: %w", i+1, err)
|
||||
}
|
||||
refs = append(refs, jobRef{id: extractJobID(result), label: label, idx: i})
|
||||
_ = result
|
||||
}
|
||||
|
||||
if len(refs) == 0 {
|
||||
return "", fmt.Errorf("no tasks were dispatched")
|
||||
}
|
||||
|
||||
jobIDs := make([]string, 0, len(refs))
|
||||
order := make(map[string]int)
|
||||
for _, r := range refs {
|
||||
jobIDs = append(jobIDs, r.id)
|
||||
order[r.id] = r.idx
|
||||
}
|
||||
|
||||
results := jm.WaitForSession(ctx, session, jobIDs, 0)
|
||||
if len(results) == 0 {
|
||||
return "No parallel task results available.", nil
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "Completed %d parallel tasks:\n", len(results))
|
||||
for _, r := range results {
|
||||
idx := order[r.ID]
|
||||
label := r.ID
|
||||
if r.Label != "" {
|
||||
label = r.Label
|
||||
}
|
||||
fmt.Fprintf(&b, "── %s ──\n[%s] %s\n%s", label, r.ID, r.Status, strings.TrimSpace(r.Output))
|
||||
_ = idx
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func validateParallelTaskItems(tasks []parallelTaskItem) error {
|
||||
for i, t := range tasks {
|
||||
if strings.TrimSpace(t.Prompt) == "" {
|
||||
return fmt.Errorf("task %d: prompt is required", i+1)
|
||||
}
|
||||
for j, dep := range t.DependsOn {
|
||||
if dep < 0 || dep >= len(tasks) {
|
||||
return fmt.Errorf("task %d: depends_on[%d] = %d out of range (0-%d)", i+1, j, dep, len(tasks)-1)
|
||||
}
|
||||
if dep == i {
|
||||
return fmt.Errorf("task %d: self-referencing depends_on", i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state := make([]int, len(tasks)) // 0 = unvisited, 1 = visiting, 2 = done
|
||||
var visit func(int) error
|
||||
visit = func(i int) error {
|
||||
switch state[i] {
|
||||
case 1:
|
||||
return fmt.Errorf("task dependency cycle detected at task %d", i+1)
|
||||
case 2:
|
||||
return nil
|
||||
}
|
||||
state[i] = 1
|
||||
for _, dep := range tasks[i].DependsOn {
|
||||
if err := visit(dep); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
state[i] = 2
|
||||
return nil
|
||||
}
|
||||
for i := range tasks {
|
||||
if err := visit(i); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -520,15 +359,3 @@ func resolveSubagentProvider(tt *TaskTool, modelRef, effortRef string) (provider
|
||||
// Use the task tool's own defaults.
|
||||
return tt.prov, tt.pricing, tt.contextWindow, nil
|
||||
}
|
||||
|
||||
func extractJobID(msg string) string {
|
||||
quote := strings.Index(msg, `"`)
|
||||
if quote < 0 {
|
||||
return ""
|
||||
}
|
||||
end := strings.Index(msg[quote+1:], `"`)
|
||||
if end < 0 {
|
||||
return ""
|
||||
}
|
||||
return msg[quote+1 : quote+1+end]
|
||||
}
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -13,9 +15,13 @@ import (
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
func TestParallelTasksToolIsWriteCapable(t *testing.T) {
|
||||
if (&ParallelTasksTool{}).ReadOnly() {
|
||||
t.Fatal("parallel_tasks must be write-capable because spawned sub-agents can invoke writer tools")
|
||||
func TestParallelTasksToolIsReadOnly(t *testing.T) {
|
||||
p := &ParallelTasksTool{}
|
||||
if !p.ReadOnly() {
|
||||
t.Fatal("parallel_tasks must be read-only because spawned sub-agents receive only read-only tools")
|
||||
}
|
||||
if !p.PlanModeSafe() {
|
||||
t.Fatal("parallel_tasks must be plan-mode safe because spawned sub-agents receive only read-only tools")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,22 +51,22 @@ func TestParallelTasksValidatesAllTasksBeforeRuntimeLookup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelTasksRejectsDependencyCyclesBeforeRuntimeLookup(t *testing.T) {
|
||||
func TestParallelTasksRejectsHiddenDependencyFieldBeforeRuntimeLookup(t *testing.T) {
|
||||
tool := &ParallelTasksTool{}
|
||||
_, err := tool.Execute(context.Background(), json.RawMessage(`{
|
||||
"tasks": [
|
||||
{"prompt": "first", "depends_on": [1]},
|
||||
{"prompt": "second", "depends_on": [0]}
|
||||
{"prompt": "second"}
|
||||
]
|
||||
}`))
|
||||
if err == nil {
|
||||
t.Fatal("Execute returned nil error for cyclic dependencies")
|
||||
t.Fatal("Execute returned nil error for a hidden dependency field")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cycle") {
|
||||
t.Fatalf("Execute error = %v, want dependency cycle validation", err)
|
||||
if !strings.Contains(err.Error(), "depends_on") {
|
||||
t.Fatalf("Execute error = %v, want hidden dependency field rejection", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "background jobs are not available") {
|
||||
t.Fatalf("Execute looked up background jobs before validating dependencies: %v", err)
|
||||
t.Fatalf("Execute looked up background jobs before rejecting hidden dependencies: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +104,50 @@ func TestParallelTasksForegroundCompletesAndClosesWorkers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelTasksInjectsWorkspaceContextIntoChildren(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
task := NewTaskTool(promptRoutingProvider{}, nil, tool.NewRegistry(), 20, 0, 0, 0, 0, 0, 0, 0.0, "", "sys", nil, 0, "", "", nil).
|
||||
WithTranscripts(NewSubagentStore(t.TempDir()), workspace, "base-model", "base-effort")
|
||||
parallel := NewParallelTasksTool(task, tool.NewRegistry())
|
||||
ctx := withCallContext(context.Background(), "parallel-call", event.Discard, nil, false)
|
||||
|
||||
out, err := parallel.Execute(ctx, json.RawMessage(`{"tasks":[{"prompt":"inspect one"},{"prompt":"inspect two"}]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "Current workspace: "+strconv.Quote(workspace)) ||
|
||||
!strings.Contains(out, `prefer "." or relative paths`) ||
|
||||
!strings.Contains(out, "inspect one ok") ||
|
||||
!strings.Contains(out, "inspect two ok") {
|
||||
t.Fatalf("parallel output = %q, want child workspace context and prompt", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelTasksDoesNotExposeWriterToolsToChildren(t *testing.T) {
|
||||
var writerCalls int32
|
||||
parentReg := tool.NewRegistry()
|
||||
parentReg.Add(fakeTool{name: "write_file", readOnly: false, calls: &writerCalls})
|
||||
task := newTestTaskTool(t, writerCallingProvider{}, parentReg, "sys", "", "", nil)
|
||||
parallel := NewParallelTasksTool(task, parentReg)
|
||||
ctx := withCallContext(context.Background(), "parallel-call", event.Discard, nil, false)
|
||||
|
||||
out, err := parallel.Execute(ctx, json.RawMessage(`{
|
||||
"tasks": [
|
||||
{"prompt": "try writer one"},
|
||||
{"prompt": "try writer two"}
|
||||
]
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatalf("Execute returned error: %v\n%s", err, out)
|
||||
}
|
||||
if got := atomic.LoadInt32(&writerCalls); got != 0 {
|
||||
t.Fatalf("writer tool was exposed to read-only sub-agents and called %d times", got)
|
||||
}
|
||||
if !strings.Contains(out, "Completed 2 parallel tasks") {
|
||||
t.Fatalf("missing aggregate output: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelTasksCancelReturnsPartialAggregate(t *testing.T) {
|
||||
task := newTestTaskTool(t, promptRoutingProvider{}, tool.NewRegistry(), "sys", "", "", nil)
|
||||
parallel := NewParallelTasksTool(task, tool.NewRegistry())
|
||||
@@ -170,6 +220,33 @@ func (promptRoutingProvider) Stream(_ context.Context, req provider.Request) (<-
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
type writerCallingProvider struct{}
|
||||
|
||||
func (writerCallingProvider) Name() string { return "writer-calling" }
|
||||
|
||||
func (writerCallingProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
|
||||
ch := make(chan provider.Chunk, 2)
|
||||
if !hasToolResult(req, "write_file") {
|
||||
ch <- toolCallChunk("write-1", "write_file", `{"path":"x","content":"y"}`)
|
||||
ch <- provider.Chunk{Type: provider.ChunkDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: "writer unavailable"}
|
||||
ch <- provider.Chunk{Type: provider.ChunkDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func hasToolResult(req provider.Request, name string) bool {
|
||||
for _, m := range req.Messages {
|
||||
if m.Role == provider.RoleTool && m.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type stringsError string
|
||||
|
||||
func (e stringsError) Error() string { return string(e) }
|
||||
@@ -152,6 +152,14 @@ func TestPlanModeAllowedToolsOverride(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanModeReadOnlyCommandsOverride(t *testing.T) {
|
||||
a := &Agent{planModeReadOnlyCommands: []string{"gh issue view"}}
|
||||
blocked, msg := a.planModeBlocked("bash", false, false, planmode.PlanSafetyUnknown, bashCommandArgs(t, "gh issue view 4572 --json title"))
|
||||
if blocked {
|
||||
t.Fatalf("command in planModeReadOnlyCommands should not be blocked: %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanModeGenericWriterBlocked(t *testing.T) {
|
||||
blocked, msg := (&Agent{}).planModeBlocked("some_writer_tool", false, false, planmode.PlanSafetyUnknown, nil)
|
||||
if !blocked {
|
||||
@@ -213,6 +221,18 @@ func (g *fakePlanModeReadOnlyTrustGate) CheckPlanModeReadOnlyTrust(ctx context.C
|
||||
return g.allow, g.reason, nil
|
||||
}
|
||||
|
||||
type readOnlyRecordingGate struct {
|
||||
readOnly []bool
|
||||
}
|
||||
|
||||
func (g *readOnlyRecordingGate) Check(ctx context.Context, toolName string, args json.RawMessage, readOnly bool) (bool, string, error) {
|
||||
g.readOnly = append(g.readOnly, readOnly)
|
||||
if !readOnly {
|
||||
return false, "expected read-only", nil
|
||||
}
|
||||
return true, "", nil
|
||||
}
|
||||
|
||||
// TestPlanModeUntrustedReadOnlyToolFailsClosed proves the gate does NOT trust an
|
||||
// MCP tool's self-reported readOnlyHint: a ReadOnly()==true external tool is
|
||||
// still fail-closed in plan mode, and runs only once declared in
|
||||
@@ -301,6 +321,74 @@ func TestPlanModeUnsafeUntrustedReadOnlyToolDoesNotAskForTrust(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanModeUnknownBashReadOnlyCommandCanAskForTrust(t *testing.T) {
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(fakeTool{name: "bash", readOnly: false})
|
||||
gate := &fakePlanModeReadOnlyTrustGate{allow: true}
|
||||
a := New(nil, reg, NewSession(""), Options{PlanModeReadOnlyTrustGate: gate}, event.Discard)
|
||||
a.SetPlanMode(true)
|
||||
|
||||
out := a.executeOne(context.Background(), provider.ToolCall{Name: "bash", Arguments: string(bashCommandArgs(t, "gh issue view 4572 --json title"))})
|
||||
if strings.HasPrefix(out.output, "blocked:") || !strings.Contains(out.output, "done") {
|
||||
t.Fatalf("trusted unknown bash query should run, got: %q", out.output)
|
||||
}
|
||||
if gate.calls != 1 {
|
||||
t.Fatalf("trust gate calls = %d, want 1", gate.calls)
|
||||
}
|
||||
if gate.req.ToolName != PlanModeReadOnlyCommandApprovalTool || gate.req.Command != "gh issue view 4572 --json title" || gate.req.Prefix != "gh issue view" {
|
||||
t.Fatalf("trust request = %+v, want bash command prefix request", gate.req)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanModeTrustedUnknownBashCommandReachesGateAsReadOnly(t *testing.T) {
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(fakeTool{name: "bash", readOnly: false})
|
||||
trustGate := &fakePlanModeReadOnlyTrustGate{allow: true}
|
||||
permissionGate := &readOnlyRecordingGate{}
|
||||
a := New(nil, reg, NewSession(""), Options{
|
||||
Gate: permissionGate,
|
||||
PlanModeReadOnlyTrustGate: trustGate,
|
||||
}, event.Discard)
|
||||
a.SetPlanMode(true)
|
||||
|
||||
out := a.executeOne(context.Background(), provider.ToolCall{Name: "bash", Arguments: string(bashCommandArgs(t, "gh issue view 4572"))})
|
||||
if strings.HasPrefix(out.output, "blocked:") || !strings.Contains(out.output, "done") {
|
||||
t.Fatalf("trusted unknown bash query should run through the normal gate as read-only, got: %q", out.output)
|
||||
}
|
||||
if len(permissionGate.readOnly) != 1 || !permissionGate.readOnly[0] {
|
||||
t.Fatalf("permission gate readOnly calls = %v, want [true]", permissionGate.readOnly)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanModeUnknownBashReadOnlyCommandDeclineBlocks(t *testing.T) {
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(fakeTool{name: "bash", readOnly: false})
|
||||
gate := &fakePlanModeReadOnlyTrustGate{allow: false, reason: "not read-only"}
|
||||
a := New(nil, reg, NewSession(""), Options{PlanModeReadOnlyTrustGate: gate}, event.Discard)
|
||||
a.SetPlanMode(true)
|
||||
|
||||
out := a.executeOne(context.Background(), provider.ToolCall{Name: "bash", Arguments: string(bashCommandArgs(t, "gh issue view 4572"))})
|
||||
if !strings.Contains(out.output, "not read-only") {
|
||||
t.Fatalf("declined bash trust should block with reason, got: %q", out.output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanModeUnsafeUnknownBashCommandDoesNotAskForTrust(t *testing.T) {
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(fakeTool{name: "bash", readOnly: false})
|
||||
gate := &fakePlanModeReadOnlyTrustGate{allow: true}
|
||||
a := New(nil, reg, NewSession(""), Options{PlanModeReadOnlyTrustGate: gate}, event.Discard)
|
||||
a.SetPlanMode(true)
|
||||
|
||||
out := a.executeOne(context.Background(), provider.ToolCall{Name: "bash", Arguments: string(bashCommandArgs(t, "gh issue view 4572 && rm -rf /"))})
|
||||
if !strings.HasPrefix(out.output, "blocked:") {
|
||||
t.Fatalf("unsafe shell syntax should remain blocked, got: %q", out.output)
|
||||
}
|
||||
if gate.calls != 0 {
|
||||
t.Fatalf("trust gate calls = %d, want 0 for unsafe shell syntax", gate.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// --- planModeBashBlocked tests ---
|
||||
|
||||
func TestPlanModeBashBlocked_SafeCommands(t *testing.T) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
var reTransientUserBlock = regexp.MustCompile(`(?s)^\s*<(?:response-language|reasoning-language|memory-update|background-jobs)>.*?</(?:response-language|reasoning-language|memory-update|background-jobs)>\s*\n?`)
|
||||
var reTransientUserBlock = regexp.MustCompile(`(?s)^\s*<(?:response-language|reasoning-language|memory-update|background-jobs|active-goal|hook-context|capability-route)(?:\s+[^>]*)?>.*?</(?:response-language|reasoning-language|memory-update|background-jobs|active-goal|hook-context|capability-route)>\s*\n?`)
|
||||
|
||||
const memoryCompilerExecutionOpen = "<memory-compiler-execution>"
|
||||
|
||||
|
||||
@@ -63,6 +63,28 @@ func TestStripTransientUserBlocksUnwrapsMemoryCompilerExecution(t *testing.T) {
|
||||
in: "just a normal prompt",
|
||||
want: "just a normal prompt",
|
||||
},
|
||||
{
|
||||
name: "hook context prefix is stripped",
|
||||
in: "<hook-context event=\"SessionStart\">\nLoad conventions.\n</hook-context>\n\nship it",
|
||||
want: "ship it",
|
||||
},
|
||||
{
|
||||
name: "active goal prefix is stripped",
|
||||
in: "<active-goal>\nFix all bugs\n</active-goal>\n\nfix the auth bug",
|
||||
want: "fix the auth bug",
|
||||
},
|
||||
{
|
||||
name: "active goal after other transient prefixes is stripped",
|
||||
in: "<reasoning-language>\nuse Chinese\n</reasoning-language>\n\n" +
|
||||
"<memory-update>\n- note\n</memory-update>\n\n" +
|
||||
"<active-goal>\nDo X\n</active-goal>\n\nhelp me",
|
||||
want: "help me",
|
||||
},
|
||||
{
|
||||
name: "capability route prefix is stripped",
|
||||
in: "<capability-route version=\"1\">\nRelevant capabilities:\n- skill:review prefer\n</capability-route>\n\nreview this",
|
||||
want: "review this",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
Loaded 100 of 333 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user