mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50b1bf4a50 | ||
|
|
8907573f09 | ||
|
|
512e3dd66e | ||
|
|
fccea10236 | ||
|
|
72e89ff7c3 | ||
|
|
d83a0bd630 | ||
|
|
b1f7d1ab15 | ||
|
|
bd98ef3fe3 | ||
|
|
621aad4d87 | ||
|
|
4e4faf2eae | ||
|
|
c0f2320224 | ||
|
|
b58c505c84 | ||
|
|
e57ca88473 | ||
|
|
e3b31be66c | ||
|
|
f3082b91d0 | ||
|
|
08eaac5ed4 | ||
|
|
744d1f93eb | ||
|
|
893c1c7f9f | ||
|
|
e95c93f2dc | ||
|
|
bdbafa7b7c | ||
|
|
868b61b4ac | ||
|
|
991ad37959 | ||
|
|
4811857f6b | ||
|
|
1739231050 | ||
|
|
0a7f9ed4a1 | ||
|
|
b5f884a61d | ||
|
|
5f03643efd | ||
|
|
249e18f425 | ||
|
|
9b1e01bea9 | ||
|
|
e472735144 | ||
|
|
583054feaf | ||
|
|
a6613c6708 | ||
|
|
88114f2f78 | ||
|
|
0a5bc5402b | ||
|
|
df5776c5a4 | ||
|
|
2e7ff04711 | ||
|
|
0711fada71 | ||
|
|
7704b77344 | ||
|
|
02d0ece98a | ||
|
|
17bc1aa4ab | ||
|
|
c3670c973c | ||
|
|
96199134e7 | ||
|
|
55fbb3338d | ||
|
|
4980ecdf7f | ||
|
|
7336a112b1 | ||
|
|
6b5ae1ea40 | ||
|
|
7cf019559d | ||
|
|
84fb462b6d | ||
|
|
96ae010fac | ||
|
|
28ea853730 | ||
|
|
119d880575 | ||
|
|
745a7e54c8 | ||
|
|
8131e24102 | ||
|
|
4cf52b3653 | ||
|
|
7854d42410 | ||
|
|
823ae7ffca | ||
|
|
bf69ccbd8c | ||
|
|
a6b32c0638 | ||
|
|
58b01976ae | ||
|
|
1bcec032a6 | ||
|
|
47f6ba2fa5 | ||
|
|
03a3ae2a58 | ||
|
|
00ae400175 | ||
|
|
0678972729 | ||
|
|
638f230f40 | ||
|
|
8e9b7d4689 | ||
|
|
b063966b92 | ||
|
|
55a8f62c4d | ||
|
|
0b64723128 | ||
|
|
17725071e5 | ||
|
|
2eec3a9f53 | ||
|
|
5c3ba5313a | ||
|
|
984e830df4 | ||
|
|
abb1088d3d | ||
|
|
08883ce211 | ||
|
|
3e6cee2a6a | ||
|
|
0a83ed0937 | ||
|
|
9569024c9b | ||
|
|
dc4a5dcfcf | ||
|
|
fa66bc4d21 | ||
|
|
7cbedd0b6a | ||
|
|
b13bcbcd77 | ||
|
|
6a5d5b1a13 | ||
|
|
9ec2ecae02 | ||
|
|
7f67bc29ce | ||
|
|
280ef69cd1 | ||
|
|
dbaefa766c | ||
|
|
e19d4df68e | ||
|
|
909b5cced5 | ||
|
|
6960630283 | ||
|
|
7042722fb0 | ||
|
|
ad6ecf0dda | ||
|
|
db651d51da | ||
|
|
e54a99df7b | ||
|
|
0d3c6e20e2 | ||
|
|
a0bd169cd0 | ||
|
|
b51cb03a23 | ||
|
|
d8e896a8b5 | ||
|
|
69f5147629 | ||
|
|
f0a831b931 | ||
|
|
547228ae8c | ||
|
|
ef40665371 | ||
|
|
9df95d75e7 | ||
|
|
c4a0dc5121 | ||
|
|
a0816a02f5 | ||
|
|
3b4fd7dca0 | ||
|
|
1095f5ff52 | ||
|
|
c3e0ccf99c | ||
|
|
16904ce664 | ||
|
|
54c3792085 | ||
|
|
dac635ed45 | ||
|
|
691bd03201 | ||
|
|
4837165227 | ||
|
|
49e9d9e04a | ||
|
|
ec9788d8ed | ||
|
|
58f28d61aa | ||
|
|
857951ac83 | ||
|
|
3f66304eb4 | ||
|
|
7fc3c526f1 | ||
|
|
b1ee3ff327 | ||
|
|
8ede8830fa | ||
|
|
7e03aca713 | ||
|
|
2f2ddf5d06 | ||
|
|
3c08f600a9 | ||
|
|
bfcf333222 | ||
|
|
82c016b193 | ||
|
|
28990641b1 | ||
|
|
0c837dda91 | ||
|
|
964ee5a0e6 | ||
|
|
04694eae65 | ||
|
|
65dbd122f6 | ||
|
|
89235a20bb | ||
|
|
2ee56515da | ||
|
|
e83a106c64 | ||
|
|
182d41eb87 | ||
|
|
25988eb175 | ||
|
|
171e10f69e | ||
|
|
cb3e6189d6 | ||
|
|
651779ae93 | ||
|
|
c634341883 | ||
|
|
0510873412 | ||
|
|
f2e0b1f6d8 | ||
|
|
421fff2263 | ||
|
|
660dcc662f | ||
|
|
a44bbae18a | ||
|
|
de0d1fb19a | ||
|
|
210781f88d | ||
|
|
c86383934a | ||
|
|
33d03ef099 | ||
|
|
c14d8a3a8a | ||
|
|
dc1779e8da | ||
|
|
585a243b0b | ||
|
|
c96a524742 | ||
|
|
71065f34bd | ||
|
|
74e138c472 | ||
|
|
f226ad2508 | ||
|
|
df0507fbb8 | ||
|
|
85668b0130 | ||
|
|
6a2b78f5c0 | ||
|
|
31acf1e232 | ||
|
|
da25e37b1d | ||
|
|
d0643eebb2 | ||
|
|
ab04288066 | ||
|
|
fd32b56535 | ||
|
|
4163217919 | ||
|
|
cb4b2be38e | ||
|
|
f10267038b | ||
|
|
6b015c0559 | ||
|
|
6c8650d2be | ||
|
|
98dec4a401 | ||
|
|
8a56afd571 | ||
|
|
10dccac9b0 | ||
|
|
95f5df07e6 | ||
|
|
25eefbbdec | ||
|
|
c53535946a | ||
|
|
3218500736 | ||
|
|
65e833f6e8 | ||
|
|
4a6be7dc50 | ||
|
|
b224b7bf42 | ||
|
|
a0a723109a | ||
|
|
c4641ec7f0 | ||
|
|
ff705c1dc6 | ||
|
|
a65f1062fc | ||
|
|
48cc3f4cb2 | ||
|
|
d85f9387a6 | ||
|
|
5165a54658 | ||
|
|
582b440967 | ||
|
|
3d9a0de38b | ||
|
|
b9ecc4efe0 | ||
|
|
95913f1cf6 | ||
|
|
7df5d8b58e | ||
|
|
1d6a30d11b | ||
|
|
603600bf02 | ||
|
|
e6a175e138 | ||
|
|
bb9415a457 | ||
|
|
5f661b932a | ||
|
|
bfef3c3e73 | ||
|
|
eb065799c7 | ||
|
|
dba8a49e88 | ||
|
|
a683234090 | ||
|
|
0fe5041ccd | ||
|
|
b5096d1278 | ||
|
|
618ed36b2a | ||
|
|
3781904b11 | ||
|
|
aa539fdd55 | ||
|
|
e826984619 | ||
|
|
558d471113 | ||
|
|
38d88abcc0 | ||
|
|
8a7c83435e | ||
|
|
81249ec581 | ||
|
|
d6aeafa984 | ||
|
|
1768cf4d58 | ||
|
|
5290275857 | ||
|
|
f00f238b66 | ||
|
|
395ffdb6d5 | ||
|
|
3a05934db0 | ||
|
|
6b5bf7ac2f | ||
|
|
5467670146 | ||
|
|
b40a5692ec | ||
|
|
7d49171a28 | ||
|
|
e4259d3543 | ||
|
|
5af0f1661f | ||
|
|
0df09f32cb | ||
|
|
56f3ffe968 | ||
|
|
38e6d2cf8c | ||
|
|
d2f62d4561 | ||
|
|
03fb6582ed | ||
|
|
5e757bd372 | ||
|
|
2e7bdec3a8 | ||
|
|
05b47947a1 | ||
|
|
4188ddf007 | ||
|
|
be60c9539e | ||
|
|
28d95059c7 | ||
|
|
217a9d1804 | ||
|
|
0c0e800520 | ||
|
|
be4e619dd3 | ||
|
|
66c5b5107e | ||
|
|
bf18fd648b | ||
|
|
3555d36b57 | ||
|
|
a6c2fb6115 | ||
|
|
4ecfd877fc | ||
|
|
4068b9d3b5 | ||
|
|
f297fe0a26 | ||
|
|
56a3efd93e | ||
|
|
1c3cfba15b | ||
|
|
860ae56633 | ||
|
|
326e657bf9 | ||
|
|
36228c72a8 | ||
|
|
8686c91b45 | ||
|
|
7219a20cb7 | ||
|
|
340760feb1 | ||
|
|
1dc54dfc78 | ||
|
|
2a45ac45af | ||
|
|
4610d54743 | ||
|
|
f11e2188b9 | ||
|
|
d2b5d0542e | ||
|
|
88fc19d962 | ||
|
|
8aba0e2c78 | ||
|
|
267c4901dc | ||
|
|
bac58aedd0 | ||
|
|
bf77bba995 | ||
|
|
341dc83f3c | ||
|
|
e02d0984c3 | ||
|
|
5a9a5db4e7 | ||
|
|
f78beded5c | ||
|
|
e170db3ec2 | ||
|
|
5116d11198 | ||
|
|
106255be72 | ||
|
|
c848df3fa3 | ||
|
|
421e412658 | ||
|
|
b3e868135d | ||
|
|
6ca6219513 | ||
|
|
3c8cc8c477 | ||
|
|
bfb6d8e289 | ||
|
|
a00ea34411 | ||
|
|
c5debed4da | ||
|
|
31998dfe93 | ||
|
|
3d150437a2 | ||
|
|
7d817f8a8e | ||
|
|
f12e10db62 | ||
|
|
12a9a518ef | ||
|
|
84952744f8 | ||
|
|
f38100ed44 | ||
|
|
161d6ec78f | ||
|
|
dc1acf7526 | ||
|
|
f459dbf268 | ||
|
|
9372b3a316 | ||
|
|
b16f614368 | ||
|
|
9e541658c8 | ||
|
|
7d90606f8e | ||
|
|
f67fcd261f | ||
|
|
a6afeabd05 | ||
|
|
96c91279a8 | ||
|
|
3daafde4a7 | ||
|
|
019a852be5 | ||
|
|
d1ca21f545 | ||
|
|
018aa2e76e | ||
|
|
ff4032c7db | ||
|
|
4689ea42e8 | ||
|
|
cb0851c4cc | ||
|
|
790949c8f1 | ||
|
|
595a83ee74 | ||
|
|
6e8cad791e | ||
|
|
462b79b15e | ||
|
|
fe141e069d | ||
|
|
cf148a1285 | ||
|
|
6ea352fdd8 | ||
|
|
544714b2ba | ||
|
|
7ae935335e | ||
|
|
77a7cd9c61 | ||
|
|
615cc81fce | ||
|
|
65c0d641ef | ||
|
|
4e136aad4e | ||
|
|
4de6321b10 | ||
|
|
152477205f | ||
|
|
6ac18cfbce | ||
|
|
fc85376c3f | ||
|
|
736deb1321 | ||
|
|
fa5f3a01c6 | ||
|
|
b5a2175c6e | ||
|
|
c7368be722 | ||
|
|
6ca3c94d21 | ||
|
|
900c68d638 | ||
|
|
7eecebe3ea | ||
|
|
b43dd4bb91 | ||
|
|
ca68ba07f9 | ||
|
|
f0f1ac60a5 | ||
|
|
72f2478f35 | ||
|
|
57a3624d13 | ||
|
|
cd8def12b6 | ||
|
|
b465b73178 | ||
|
|
8a8d7655e6 | ||
|
|
bb76851f95 | ||
|
|
78e7327ead | ||
|
|
c9f8bc1507 | ||
|
|
1024a0cc44 | ||
|
|
002a97fe91 | ||
|
|
45f9a65fd4 | ||
|
|
3cc72075f2 | ||
|
|
d807c1f9b8 | ||
|
|
acb1dca762 | ||
|
|
e5bb6b4a24 | ||
|
|
4e8a49408e | ||
|
|
245e13b245 | ||
|
|
be414940cb | ||
|
|
b6492b2677 | ||
|
|
c404bfe60e | ||
|
|
d7fb4daca5 | ||
|
|
fa916e8603 | ||
|
|
8429754d1c | ||
|
|
9e46fe4746 | ||
|
|
99281682a6 | ||
|
|
19430d225d | ||
|
|
330a75b8fb | ||
|
|
a91faab615 | ||
|
|
962774be14 | ||
|
|
2821aeb95f | ||
|
|
604ab2451e | ||
|
|
8ccb0f575c | ||
|
|
29608dfe78 | ||
|
|
09406f2932 | ||
|
|
6bc7a7cdba | ||
|
|
9a156e88fe | ||
|
|
50f38c77c7 | ||
|
|
68449b81fc | ||
|
|
485a18ee51 | ||
|
|
6e68735e0a | ||
|
|
3a09a2b9c5 | ||
|
|
1090980452 | ||
|
|
cdb4b1c7b2 | ||
|
|
77e9c69a9d | ||
|
|
ce61452e45 | ||
|
|
90d2c33d3a | ||
|
|
7fdd219c0e | ||
|
|
8061478f87 | ||
|
|
af1e52bed1 | ||
|
|
a99b120902 | ||
|
|
3f1db467ee | ||
|
|
a654c97279 | ||
|
|
5517a3af78 | ||
|
|
a7c1640e7a | ||
|
|
3e8cf9440c | ||
|
|
edb90cd7e4 | ||
|
|
f339f668ad | ||
|
|
d304a7607d | ||
|
|
8d82bb573d | ||
|
|
32674bdd48 | ||
|
|
e5b6f817b0 | ||
|
|
5161dd3c84 | ||
|
|
f7a5b5a79a | ||
|
|
bae312ce7a | ||
|
|
0f7a0beac7 | ||
|
|
633984124a | ||
|
|
cc7a2bf71e | ||
|
|
5a46f7277b | ||
|
|
d5ac6a3cef | ||
|
|
f0eb2a474c | ||
|
|
5be617e513 | ||
|
|
661fbd2bd9 | ||
|
|
3a7c15a4bb | ||
|
|
5adce512d4 | ||
|
|
6a3d9c15af | ||
|
|
9ac2294bec | ||
|
|
e5580e4d30 | ||
|
|
ba9fb3c8d2 | ||
|
|
401c6b8cb0 | ||
|
|
dc979239c0 | ||
|
|
51832e9661 | ||
|
|
2b87a6e10d | ||
|
|
aa8c82a606 | ||
|
|
2a5e24848b | ||
|
|
c26a5500cf | ||
|
|
e1f144d743 | ||
|
|
eb06c32366 | ||
|
|
db3a0d0d7e | ||
|
|
6cc7a8ea66 | ||
|
|
71b0ffa80e | ||
|
|
b0d8fea64c | ||
|
|
95864a9cc7 | ||
|
|
18ee05d0f1 | ||
|
|
c41c9ed0cf |
No files matched your search
@@ -1,4 +1,10 @@
|
||||
DEEPSEEK_API_KEY=sk-your-key-here
|
||||
DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||
# DEEPSEEK_API_BASE_URL is accepted as an alias of DEEPSEEK_BASE_URL.
|
||||
REASONIX_LOG_LEVEL=INFO
|
||||
REASONIX_TRANSCRIPT_DIR=./transcripts
|
||||
|
||||
# Optional Telegram remote channel
|
||||
# TELEGRAM_BOT_TOKEN=123456:botfather-token
|
||||
# TELEGRAM_OWNER_USER_ID=123456789
|
||||
# TELEGRAM_ALLOWLIST=123456789,987654321
|
||||
@@ -33,6 +33,9 @@ jobs:
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Build (tsup + dashboard)
|
||||
run: npm run build
|
||||
|
||||
- name: Test (vitest + coverage)
|
||||
run: npm run test:coverage
|
||||
|
||||
@@ -40,9 +43,6 @@ jobs:
|
||||
if: always()
|
||||
run: node scripts/coverage-summary.mjs
|
||||
|
||||
- name: Build (tsup)
|
||||
run: npm run build
|
||||
|
||||
# Smoke-test the bench harnesses themselves. --dry skips all LLM
|
||||
# calls, so this catches wiring regressions (task factories, CLI
|
||||
# parsing, file IO, checker determinism) without needing a
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
name: Publish dsnix
|
||||
|
||||
# Tag namespace: `dsnix-v<semver>` triggers npm publish for the
|
||||
# dsnix shim package only. Plain `v<semver>` tags publish the main
|
||||
# `reasonix` CLI (see publish-npm.yml); the two are decoupled so the
|
||||
# alias can be released independently when the shim itself changes.
|
||||
on:
|
||||
push:
|
||||
tags: ["dsnix-v*"]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Tag to publish (e.g. dsnix-v0.48.0). Required when running manually."
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: publish dsnix to npm
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.tag || github.ref }}
|
||||
|
||||
- name: Setup Node 22
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
registry-url: https://registry.npmjs.org/
|
||||
|
||||
- name: Verify tag matches packages/dsnix/package.json version
|
||||
shell: bash
|
||||
run: |
|
||||
tag="${{ github.event.inputs.tag || github.ref_name }}"
|
||||
tag_version="${tag#dsnix-v}"
|
||||
pkg_version=$(node -p "require('./packages/dsnix/package.json').version")
|
||||
if [ "$tag_version" != "$pkg_version" ]; then
|
||||
echo "::error::tag $tag (version $tag_version) does not match packages/dsnix/package.json version $pkg_version"
|
||||
exit 1
|
||||
fi
|
||||
echo "tag $tag matches packages/dsnix/package.json $pkg_version"
|
||||
|
||||
- name: Verify reasonix@<version> is already published on npm
|
||||
shell: bash
|
||||
run: |
|
||||
dep_version=$(node -p "require('./packages/dsnix/package.json').dependencies.reasonix")
|
||||
if ! npm view "reasonix@${dep_version}" version >/dev/null 2>&1; then
|
||||
echo "::error::dsnix depends on reasonix@${dep_version} but that version is not published yet"
|
||||
exit 1
|
||||
fi
|
||||
echo "reasonix@${dep_version} is published — safe to publish dsnix"
|
||||
|
||||
- name: Publish dsnix to npm
|
||||
run: npm publish --access public
|
||||
working-directory: packages/dsnix
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
@@ -24,13 +24,11 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target:
|
||||
# `rust` is the comma-separated list installed by dtolnay/rust-toolchain;
|
||||
# `tauri` is what we pass to `cargo tauri build --target`. They differ
|
||||
# only for the universal Mac shard, which produces one fat binary from
|
||||
# two real Rust targets via lipo. Intel-only macos-13 runner is dropped
|
||||
# — GH retired most of that pool, queue waits ran to hours.
|
||||
# macOS now ships two single-arch shards so Apple Silicon and Intel
|
||||
# users get separate DMGs. `rust` and `tauri` stay aligned per shard.
|
||||
- { os: ubuntu-22.04, label: "linux-x64", rust: "x86_64-unknown-linux-gnu", tauri: "x86_64-unknown-linux-gnu" }
|
||||
- { os: macos-14, label: "macos-universal", rust: "aarch64-apple-darwin,x86_64-apple-darwin", tauri: "universal-apple-darwin" }
|
||||
- { os: macos-15-intel, label: "macos-x64", rust: "x86_64-apple-darwin", tauri: "x86_64-apple-darwin" }
|
||||
- { os: macos-14, label: "macos-arm64", rust: "aarch64-apple-darwin", tauri: "aarch64-apple-darwin" }
|
||||
- { os: windows-latest, label: "windows-x64", rust: "x86_64-pc-windows-msvc", tauri: "x86_64-pc-windows-msvc" }
|
||||
runs-on: ${{ matrix.target.os }}
|
||||
# secrets.* isn't accessible from step-level if:, so expose presence as a
|
||||
@@ -96,14 +94,19 @@ jobs:
|
||||
working-directory: desktop
|
||||
run: npm run bundle:node
|
||||
|
||||
- name: Verify bundled Node is universal (macOS)
|
||||
- name: Verify bundled Node matches host arch (macOS)
|
||||
if: startsWith(matrix.target.os, 'macos-')
|
||||
working-directory: desktop
|
||||
run: |
|
||||
expected_arch=$(uname -m)
|
||||
archs=$(lipo -archs src-tauri/binaries/node)
|
||||
echo "node archs: $archs"
|
||||
echo "$archs" | grep -q "arm64"
|
||||
echo "$archs" | grep -q "x86_64"
|
||||
echo "expected arch: $expected_arch"
|
||||
if [ "$archs" != "$expected_arch" ]; then
|
||||
echo "Expected $expected_arch Node, got: $archs" >&2
|
||||
exit 1
|
||||
fi
|
||||
codesign -d --entitlements :- src-tauri/binaries/node 2>&1 | grep -q "com.apple.security.inherit"
|
||||
|
||||
- name: Build & verify desktop frontend
|
||||
working-directory: desktop
|
||||
@@ -172,3 +175,13 @@ jobs:
|
||||
releaseDraft: true
|
||||
prerelease: false
|
||||
args: --target ${{ matrix.target.tauri }}
|
||||
|
||||
- name: Verify app Node inherits TCC grants (macOS)
|
||||
if: startsWith(matrix.target.os, 'macos-')
|
||||
working-directory: desktop
|
||||
shell: bash
|
||||
run: |
|
||||
node_bin=$(find src-tauri/target -path '*/release/bundle/macos/*.app/Contents/Resources/node' -type f | head -n 1)
|
||||
test -n "$node_bin"
|
||||
codesign -vvv --strict "$node_bin"
|
||||
codesign -d --entitlements :- "$node_bin" 2>&1 | grep -q "com.apple.security.inherit"
|
||||
@@ -25,12 +25,15 @@ sessions/
|
||||
.turbo/
|
||||
# Local-only Claude Code settings — personal overrides, never committed.
|
||||
.claude/settings.local.json
|
||||
# Local agent notes — personal workflow reminders, never committed.
|
||||
agent.md
|
||||
# Per-user Reasonix state under .reasonix/ — committable team-level
|
||||
# files (settings.json, skills/) stay tracked, but the user-private
|
||||
# bits (semantic index, sessions, opt-out markers) never should.
|
||||
.reasonix/semantic/
|
||||
.reasonix/sessions/
|
||||
.reasonix/semantic-skip
|
||||
.reasonix/truncated-results/
|
||||
# Scratch entry regenerated each time scripts/bundle-codemirror.mjs runs.
|
||||
scripts/.cm-entry.mjs
|
||||
# Personal bun lockfile — project uses npm officially.
|
||||
@@ -48,3 +51,5 @@ desktop/src-tauri/binaries/
|
||||
# Design mock-up reference (local, not part of repo)
|
||||
.design-ref/
|
||||
*.cpuprofile
|
||||
|
||||
.probe-snapshots/
|
||||
+555
@@ -3,6 +3,561 @@
|
||||
All notable changes to Reasonix. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
|
||||
this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.52.0] — 2026-05-26
|
||||
|
||||
**Ink renderer in-tree as `@esengine/ink`.** The vendored Ink fork now
|
||||
lives as a workspace package (#1847), unblocking TUI-specific
|
||||
renderer tweaks (focus, IME, Static append) without forking upstream.
|
||||
TUI polish lands on top: guard ASCII IME enter commits so JP/CN IME
|
||||
atomic commits don't double-submit (#1874), preserve undo state across
|
||||
session context switches (#1810), incremental repaint coverage
|
||||
(#1873).
|
||||
|
||||
**Desktop — clipboard image paste.** Paste screenshots / copied images
|
||||
directly into the composer (#1889); paste path hardened against
|
||||
oversized + non-image clipboard payloads (#1897). Sessions exported by
|
||||
other local AI apps can be imported into Reasonix (#1894).
|
||||
|
||||
**Desktop — JumpBar polish + drag perf.** JumpBar now highlights the
|
||||
active message bar and follows the latest one as the thread grows
|
||||
(#1813); dot styling pulled into a shared helper (#1892). Sidebar drag
|
||||
writes CSS vars directly to the DOM with `content-visibility` on
|
||||
messages (#1812) and unit-suffixes the vars to dodge a Chromium
|
||||
recompute storm (#1875).
|
||||
|
||||
**Desktop — smaller fixes.**
|
||||
- Search-engine API key UI + slash command interception (#1867)
|
||||
- Mention `@` popup stays stable while filtering (#1850); split
|
||||
kind/length effects in the popup (#1859)
|
||||
- Update banner floats above the thread instead of pushing layout
|
||||
(#1865)
|
||||
- `memoryDetail` / `onReadMemory` wired through test fixtures so the
|
||||
desktop memory browser is exercisable in unit tests
|
||||
|
||||
**Config — base URL + proxy.** `DEEPSEEK_API_BASE_URL` accepted as an
|
||||
env alias to the existing base-url override (#1876, #1887), and
|
||||
`proxy.url` is now a first-class field in `config.json` (#1881). MCP
|
||||
`initialize` no longer leaks LSP-only fields that some servers reject
|
||||
(#1861).
|
||||
|
||||
**Providers.** Ollama usage parsing tolerates the field-name variants
|
||||
recent Ollama versions emit (#1886).
|
||||
|
||||
**i18n.**
|
||||
- Russian (`ru`) language pack added (#1879)
|
||||
- German corrections — capitalization, terminology, coverage (#1878)
|
||||
- `/qq` command hints + argsHint localized; English fallback for
|
||||
zh-CN (#1857, #1862)
|
||||
|
||||
**Dashboard / UI.**
|
||||
- Sync TUI and Web modal state so confirm gate doesn't desync between
|
||||
surfaces (#1831, #1866)
|
||||
- Highlight active plan edit mode (#1863)
|
||||
- `useEditHistory` no longer re-exports undo-context through itself
|
||||
(#1854)
|
||||
|
||||
## [0.51.0] — 2026-05-25
|
||||
|
||||
**First paint — session restore 4.5s → 430ms.** Progressive `<Static>`
|
||||
mount streams scrollback in chunks instead of mounting the full history
|
||||
synchronously (#1761). Combined with index-backed `mutateCard` + O(n)
|
||||
`plan.drop` + cursor elide (#1769) and the bump to `[email protected]` (which
|
||||
ships the flicker fix we'd been carrying as a patch — patch-package
|
||||
machinery is gone, #1809), large session restores no longer block the
|
||||
event loop on startup.
|
||||
|
||||
**Perf — tokenizer + markdown + search.** BPE tokenizer caches its
|
||||
table + bounds per-content counts with a fast-path truncate (#1741),
|
||||
so long sessions don't re-tokenize the world on every turn. CodeBlock
|
||||
syntax highlight is memoized (#1743). Literal-pattern search bypasses
|
||||
the regex worker entirely (#1748). Windows terminal repaint cadence is
|
||||
throttled (#1750) and a BEL → ST swap on Windows stdout stops `cmd.exe`
|
||||
from beeping on every render frame (#1795).
|
||||
|
||||
**Cross-platform robustness.**
|
||||
- `EXDEV` fallback for atomic config writes on OneDrive / NTFS reparse
|
||||
points (#1778) — config saves no longer fail on Windows + cloud-sync
|
||||
- Apple Terminal mouse-reset crash guarded (#1764); legacy X10 mouse
|
||||
reports without ESC are dropped (#1723); `ESC`-less CSI recovery is
|
||||
refused when followed by typed text (#1771)
|
||||
- CLI startup gates on unsupported Node versions with a clear error
|
||||
instead of crashing in the middle of bootstrap (#1757)
|
||||
- Desktop bundled Node inherits parent TCC grants on macOS (#1749) so
|
||||
file / microphone permission prompts attach to the app, not the helper
|
||||
- Crash-safe atomic rename for edit-block writes (#1721); completed
|
||||
plans archive instead of persisting across sessions (#1700)
|
||||
- `.devenv` and `.direnv` are skipped by glob / search / directory_tree (#1706)
|
||||
|
||||
**Memory — CLAUDE.md import for Claude Code migration.** Loading
|
||||
`CLAUDE.md` and `~/.claude/CLAUDE.md` alongside the existing
|
||||
`REASONIX.md` (#1694) lets Claude Code users carry their project /
|
||||
user memory directly into Reasonix without rewriting it.
|
||||
|
||||
**i18n — German.** Full German (`de`) locale added (#1773) with a
|
||||
follow-up correction pass (#1797).
|
||||
|
||||
**Desktop polish.**
|
||||
- JumpBar message navigation dock (#1725, #1729)
|
||||
- File-change summary card after each assistant turn (#1691)
|
||||
- Self-heal stale model id; expose plan as 4th editMode (#1699)
|
||||
- Restore aborted prompt drafts on relaunch (#1693)
|
||||
- Recalc thread width on resize (#1789); right panel scrollbar
|
||||
draggable (#1765); contain long MCP spec strings (#1736)
|
||||
- Sync slash setting commands across surfaces (#1724)
|
||||
- Surface desktop startup failures instead of silent exit (#1759)
|
||||
|
||||
**TUI polish.**
|
||||
- Plan-mode `[PLAN]` / `[计划]` badge in input bar (#1742)
|
||||
- Forward-delete for `Delete` key (#1735)
|
||||
- IME candidate window cursor alignment (#1754)
|
||||
- Coalesce prompt cursor sync to reduce flicker (#1768)
|
||||
- Demote stuck `running` plan step on turn end (#1784)
|
||||
- Hide `max` effort on non-DeepSeek endpoints (#1798)
|
||||
- Drop stale "press Cmd-C to copy" startup hint (#1692)
|
||||
|
||||
**Dashboard / web mode.**
|
||||
- Bootstrap mcp / skills / memory panels in web mode (#1775)
|
||||
- Expose new memory browser to web (#1779)
|
||||
- Replay active modal on SSE connect so confirm gate isn't stuck (#1770)
|
||||
- Restore scroll and memory visibility (#1766)
|
||||
- Docs responsive spacing polish (#1785)
|
||||
|
||||
**Smaller fixes.**
|
||||
- Preserve full tool results when truncated (#1615)
|
||||
- Trim long-session retained payloads to bound memory growth (#1799)
|
||||
- Drain steer queue on `/new` + `/cwd` so prior intent doesn't leak (#1774)
|
||||
- Suppress DeepSeek-specific 5xx hints when host isn't DeepSeek (#1716)
|
||||
- `normalizeSemanticEmbeddingUserConfig` preserves `timeoutMs` (#1788)
|
||||
- `REASONIX_ACP_SYSTEM_APPEND` env var for system-prompt injection (#1737)
|
||||
- Configurable cost-display currency with click-to-toggle (#1710)
|
||||
|
||||
**Refactors / internals.**
|
||||
- Extract `LruCache` + `TtlLruCache` + `lazy()` helpers from ad-hoc
|
||||
duplicates (#1746)
|
||||
- Split stream aggregator + chunked dispatch out of `step()` (#1739)
|
||||
- Per-component render trace gated by `REASONIX_TRACE_RENDERS` (#1747);
|
||||
`readStats` API on render trace + real-numbers large-session probe (#1751)
|
||||
|
||||
## [0.50.1] — 2026-05-24
|
||||
|
||||
**Install fix — `postinstall` no longer crashes on `npx`.** The
|
||||
`postinstall` hook added in 0.50.0 (#1584) ran `npm ci` inside
|
||||
`dashboard/` and `desktop/` to set up the dev workspace, but those
|
||||
directories aren't shipped in the published tarball. Result: `npx
|
||||
reasonix@0.50.0 code` failed during install with a `dashboard/package.json
|
||||
not found` error and the CLI never started. `postinstall` now delegates
|
||||
to `scripts/postinstall.mjs`, which exits 0 when `dashboard/package.json`
|
||||
is missing (i.e. anywhere except a git checkout). 0.50.0 is deprecated
|
||||
on npm.
|
||||
|
||||
## [0.50.0] — 2026-05-24
|
||||
|
||||
**Desktop dashboard — unified on the CLI-hosted React surface.** The
|
||||
old Tauri+Preact dashboard inside the desktop bundle is gone (#1418).
|
||||
Desktop now embeds the same CLI-hosted dashboard the browser surface
|
||||
already uses, so there's one React app rendering chat across all
|
||||
three surfaces (CLI, browser, desktop) instead of two divergent
|
||||
implementations drifting apart. Cuts the duplicate rendering paths
|
||||
that caused tool cards, code blocks, and compacted history to look
|
||||
subtly different depending on where you opened the same session.
|
||||
|
||||
**Presets — flash/pro are the only knobs now.** The preset abstraction
|
||||
is removed (#1657, #1630): config now exposes `model` + `effort`
|
||||
directly instead of indirecting through `preset: auto | flash | pro`.
|
||||
The `/pro` switch is dropped too — flash and pro remain as direct
|
||||
model selections. Per-skill flash/pro override lands with a Settings
|
||||
UI (#1632) so subagents can opt into a heavier model without
|
||||
reconfiguring the parent.
|
||||
|
||||
**Cumulative usage stats — persisted + auto-restored.** Session token
|
||||
totals and accumulated cost now write to the session meta on each
|
||||
turn and reload on startup (#1667, #1680 follow-up). Cost telemetry
|
||||
survives a CLI restart or desktop relaunch instead of resetting to
|
||||
zero, and the context-size readout shows the current window, not the
|
||||
all-time cumulative cache count (#1643). `SessionStats` caps at 200
|
||||
turns with cost carryover so long-lived sessions don't unbound (#1628).
|
||||
|
||||
**Plans — `editMode="plan"` enforced at the dispatch gate.** Plan
|
||||
mode now blocks write tools at the `ToolRegistry` dispatch layer
|
||||
(#1681), not as a per-tool soft check. Plan steps also advance
|
||||
correctly when intermediate actions are skipped (#1629), so the
|
||||
"current step" indicator no longer stalls on a step the model already
|
||||
completed.
|
||||
|
||||
**Context — fold once at turn start.** The pre-flight fold pass is
|
||||
gone — context folding now happens once at the start of each turn
|
||||
instead of being threaded through mid-turn checks (#1642, #1646). The
|
||||
obsolete byte-ceiling check is dropped too. Compacted history renders
|
||||
as a single collapsible card on all three surfaces (#1649), so the
|
||||
fold is visible without dominating the scrollback.
|
||||
|
||||
**Desktop polish.**
|
||||
- Draggable sidebar resize + responsive conversation panel width (#1688)
|
||||
- Responsive sidebar collapse on narrow windows (#1585)
|
||||
- Copy/edit overlay, message-history navigation, IME guard, card collapse (#1645)
|
||||
- `Esc` closes the open modal instead of aborting the model turn (#1685)
|
||||
- QQ turn routing isolated by tab so cross-tab messages don't bleed (#1672)
|
||||
- Discard explicitly aborted turns instead of replaying them on resume (#1687)
|
||||
- Render `edit_file` / `multi_edit` results as DiffCard, matching CLI (#1662)
|
||||
- Code-block syntax highlighting follows light/dark theme (#1655)
|
||||
- System events surface as a show/hide toggle (#1654, #1650)
|
||||
- File paths + completion feedback surface in tool results (#1644)
|
||||
- Block right-click context menu in release builds (#1641)
|
||||
- Preserve code operators when rendering inline code (#1640)
|
||||
- Compact composer default preserved across relaunches (#1594)
|
||||
- Stale events no longer corrupt UI after a session switch (#1582)
|
||||
- macOS bundled Node inherits parent TCC grants so file/microphone
|
||||
permission prompts attach to the desktop app, not the helper (#1614)
|
||||
- New `dashboard.enabled` config to suppress auto-start when the
|
||||
dashboard isn't wanted (#1612)
|
||||
- Localize edit approval/discard messages + event errors with zh-CN (#1617)
|
||||
|
||||
**Dashboard polish.**
|
||||
- Persistent active-session URL — shareable + refresh-safe (#1586,
|
||||
#1589, #1599)
|
||||
- Code-block syntax highlighting follows light/dark theme (#1664)
|
||||
- IME confirm-enter no longer submits mid-composition (#1689)
|
||||
- Code-fence language detection fix mirrored from desktop (#1677, #1661)
|
||||
- Heavy vendor libs split into separate chunks for faster cold load (#1587)
|
||||
- Tool cards no longer stuck spinning when the turn ends without an
|
||||
explicit completion event (#1676)
|
||||
- Markdown tables get a horizontal scrollbar instead of overflowing
|
||||
the chat column (#1562)
|
||||
- Search-engine switcher restored in dashboard + desktop settings (#1618)
|
||||
- Composer queue-count label was reading the wrong variable name (#1674)
|
||||
- Long file paths stop stretching the chat bubble width (#1590)
|
||||
- Don't synthesize `$turn_complete` from per-iter `assistant_final` —
|
||||
let the real completion event fire (#1673)
|
||||
|
||||
**TUI.**
|
||||
- New `Alt+S` hotkey stashes the current input buffer and recalls it
|
||||
later, like a one-slot clipboard for the composer
|
||||
- Static history is isolated from input re-renders, so typing during
|
||||
a model turn doesn't repaint the whole scrollback (#1635)
|
||||
- Legacy mouse reports (X10) are dropped so terminals that emit them
|
||||
by default no longer flood the input stream (#1637, #1648)
|
||||
- `multi_edit` is gated in review mode to match the single-edit
|
||||
approval flow (#1647)
|
||||
- Shell command failure output is surfaced to the user instead of
|
||||
silently truncated (#1608)
|
||||
|
||||
**Diff rendering — CJK column-border alignment.** `SplitDiff` no
|
||||
longer misaligns the column border when CJK characters appear in
|
||||
either side of the diff (#1686). The width math now accounts for
|
||||
double-width graphemes when laying out the gutter.
|
||||
|
||||
**Subagents — per-skill model override.** Each named skill
|
||||
(`/explore`, `/research`, `/review`, `/security-review`) can now
|
||||
override flash/pro independently (#1632), with a Settings UI to
|
||||
manage the overrides. Defaults flow from the parent session.
|
||||
|
||||
**MCP.**
|
||||
- Workspace roots are passed to MCP servers on connect (#1625), so
|
||||
servers that scope by root see the right project
|
||||
- `codeCommand` honors the full `normalizeMcpConfig` pipeline including
|
||||
inline `mcpServers` blocks (#1603, follow-up to #1568)
|
||||
|
||||
**Config.**
|
||||
- `(baseUrl, apiKey)` is resolved as a tuple instead of two
|
||||
independent fields (#1658), so a partial override on one no longer
|
||||
silently falls through to the env value of the other
|
||||
- Stale model IDs self-heal on load; v3 IDs are dropped from the
|
||||
picker fallback list (#1663)
|
||||
- Theme rename follow-up from #1666 closes out the prompt-input + user
|
||||
card token migration (#1678, #1666, #1623)
|
||||
|
||||
**Memory.** `JobRegistry` self-cleans entries for completed jobs
|
||||
(#1620), so long sessions don't accumulate dead job records that
|
||||
never release their references.
|
||||
|
||||
**Other.**
|
||||
- `chore(loop)` drop redundant abort warning, keep only the synthetic
|
||||
final event (#1659)
|
||||
- `chore(theme)` finish theme rename follow-up after #1666 (#1678)
|
||||
- `chore(ui)` drop redundant `overflow-wrap` from inherited
|
||||
descendants (#1675)
|
||||
- `fix(presets)` drop stale "5/31 discount" framing in pro cost copy
|
||||
(#1583)
|
||||
- `chore(install)` include desktop deps in root postinstall (#1584)
|
||||
- `test(lifecycle)` cover deterministic policy corpus + npm package
|
||||
aliases (#1574, #1621)
|
||||
|
||||
## [0.49.0] — 2026-05-22
|
||||
|
||||
**TUI — Static-history renderer.** The history pane is now rendered
|
||||
exclusively through Ink's `<Static>` (#1529, stages 1–4). The previous
|
||||
virtual-viewport machinery — viewport-budget row allocator, byte-virtual
|
||||
frame layer, manual scroll math — is gone. Net: less code on the hot
|
||||
path, no more "frame drift" on very long sessions, and the rewrite stays
|
||||
Ink-native instead of fighting it. Behind `REASONIX_STATIC_HISTORY` at
|
||||
stage 1, default-on by stage 2, scaffolding removed by stage 4.
|
||||
|
||||
**Chat — queued mid-turn steers.** Typing while the model is mid-turn
|
||||
no longer fights the live render or drops the keystroke. New input
|
||||
queues, surfaces a small "queued" hint, and feeds into the next user
|
||||
turn cleanly (#1501). Pairs with the static-history switch so the
|
||||
queued line stays visible across re-renders.
|
||||
|
||||
**Web search — Bing default, dashboard engine switcher.** Default web
|
||||
backend switches from Mojeek to Bing (#1558); the dashboard gains a
|
||||
visible engine switcher so you can flip between configured backends
|
||||
without editing config. Mojeek is dropped from the bundled set entirely
|
||||
in this release.
|
||||
|
||||
**Plans — lifecycle evidence summaries.** Plan acceptance now surfaces
|
||||
the evidence captured at each lifecycle step (#1500), so the "why this
|
||||
plan is ready to accept" question has a structured answer instead of
|
||||
implicit trust. Builds on the strict-lifecycle observability that
|
||||
landed in 0.48.1.
|
||||
|
||||
**Desktop — approval + completion notifications.** Native OS
|
||||
notifications for approval prompts and turn-complete events (#1519),
|
||||
so a long-running turn can land in the background and still surface.
|
||||
Uses `tauri-plugin-notification`.
|
||||
|
||||
**i18n — CLI command output.** `/mcp`, `/sessions`, `/prune`, `/theme`
|
||||
output is now translated (#1524), closing the remaining English-only
|
||||
gap in CLI command surfaces. Approval-prompt confirm titles and action
|
||||
labels also get zh-CN (#1560/#1561).
|
||||
|
||||
**Security.**
|
||||
- `web_fetch` now blocks SSRF — private/loopback/link-local targets are
|
||||
rejected at the boundary (#1544)
|
||||
- Edit snapshots can no longer read outside the workspace (#1454)
|
||||
- Shell redirects (`>`, `>>`, `<`) are constrained to the sandbox
|
||||
boundary (#1457)
|
||||
- New Task integrity guardrail catches objective simplification — i.e.
|
||||
the model trying to redefine a hard requirement away (#1516)
|
||||
|
||||
**Tools.** `run_command` description now actively discourages
|
||||
shell-based file edits in favor of the structured `edit` tool (#1514).
|
||||
A per-turn dispatch-rate limit lands for tool calls (#1356), keeping
|
||||
runaway loops from burning a quota slice in one turn.
|
||||
|
||||
**TUI polish.**
|
||||
- `Ctrl+Enter` now inserts a newline (#1513) — matching how every
|
||||
modern composer treats the modifier
|
||||
- `mouseWheelRows` config knob tunes scroll velocity per terminal
|
||||
(#1511) so trackpad vs. wheel mouse don't fight the default
|
||||
- `@mention` file search is restricted to the current directory prefix
|
||||
(#1548) so the picker doesn't dump the whole repo
|
||||
- Paste-sentinel label no longer claims `^O` expands the paste — the
|
||||
shortcut never existed (#1517)
|
||||
|
||||
**Context.** Pinned-constraint blocks are now preserved across context
|
||||
folds (#1515) and the fold tail captures all of them, not just the last
|
||||
(#1552). Memory constraints survive the fold instead of silently
|
||||
dropping out under pressure.
|
||||
|
||||
**Client.**
|
||||
- DeepSeek 429s are translated into an actionable "concurrency limit
|
||||
hit" hint instead of a raw HTTP error (#1526)
|
||||
- `timeoutMs` is now wired to `fetch` when the caller passes an
|
||||
`AbortSignal` (#1535) — previously the timeout was silently ignored
|
||||
- `--no-proxy` / `REASONIX_NO_PROXY` opt-out is honored for the
|
||||
DeepSeek direct-route default (#1507)
|
||||
|
||||
**Files.** Read/edit/restore now preserve the source file's encoding
|
||||
(GB18030, UTF-8 BOM, etc.) instead of normalizing to UTF-8 on write
|
||||
(#1518). Important for CJK Windows projects.
|
||||
|
||||
**Web/Dashboard.**
|
||||
- Path-access approval modal is bridged to the dashboard (#1538) so
|
||||
the browser surface can resolve the same prompts as CLI/Desktop
|
||||
- `@mention` file picker popover lands in the dashboard ChatPane
|
||||
(#1546), gated on attached mode (#1549)
|
||||
|
||||
**Config.** Atomic config writes restore the preset-write diagnostic
|
||||
trace (#1555), so when a preset write goes wrong you can actually see
|
||||
why.
|
||||
|
||||
**Refactor.** `lifecycle` extracts a standalone risk policy module
|
||||
(#1557), shrinking the orchestrator and unblocking risk policy reuse
|
||||
from non-lifecycle callers.
|
||||
|
||||
**Edit safety.** `read-before-edit` is now enforced at the dispatch
|
||||
gate, not as a per-tool check (#1563) — `edit` / `multi_edit` refuse
|
||||
to fire on a file the model hasn't read this session, closing the
|
||||
race where a SEARCH block could match stale bytes after an external
|
||||
write.
|
||||
|
||||
**Cache.** Fold-summary requests now reuse the live agent's system
|
||||
prompt + tool list as their prefix, so the cached bytes the main
|
||||
agent just paid for carry over to the summary call (#1565). Measured
|
||||
on a 48.7K-token fold: 0% → 99.6% cache hit, ~89% input cost
|
||||
reduction per fold.
|
||||
|
||||
**Other:**
|
||||
- `fix(desktop)` drop `workspace:*` protocol for core-utils — desktop
|
||||
isn't a workspace member
|
||||
- `fix(desktop)` tsc errors blocking desktop bundle build
|
||||
|
||||
## [0.48.1] — 2026-05-21
|
||||
|
||||
**`dsnix` — short alias.** A new `dsnix` shim package lands alongside
|
||||
`reasonix` (#1440), so `npx dsnix` is now equivalent to `npx reasonix`
|
||||
for users who'd rather type five characters than eight. The shim is
|
||||
just a forwarding binary — same CLI, same release cadence — and ships
|
||||
on npm with the same version pinning (#1442). The root README documents
|
||||
the alias.
|
||||
|
||||
**Lifecycle — observability + corpus tests.** Strict lifecycle now
|
||||
surfaces step-level evidence and rail decisions through a structured
|
||||
observability channel (#1426), so what the loop is doing at each step
|
||||
is legible instead of inferred. Two corpus tests lock the behavior in:
|
||||
multi-file refactor flow (#1427) and config-migration replay (#1473).
|
||||
|
||||
**Approval prompt unification.** Phase 3 of the pause-request work
|
||||
(#1322) lands a single `PauseRequest` → UI data model shared by ACP,
|
||||
CLI, and Desktop (#1443) — the three surfaces now render the same
|
||||
prompt from the same payload instead of each translating in their own
|
||||
way. Net: pause prompts stay in sync across surfaces when fields are
|
||||
added.
|
||||
|
||||
**i18n.** Builtin skill descriptions are now translated (#1468), and
|
||||
MCP error toasts + status labels carry zh-CN (#1481). The remaining
|
||||
hardcoded English surface in skill picker and MCP error toasts is gone.
|
||||
|
||||
**Composer redesign + Ctrl+P shortcut modal.** The input area is
|
||||
rebuilt with a denser layout, a clearer affordance for multi-line
|
||||
input, and a Ctrl+P shortcut-help modal that lists every key binding
|
||||
in one place (#1483). The desktop composer also autosizes for
|
||||
multi-line input properly (#1439).
|
||||
|
||||
**Java tool — cheaper.** The Java source/decompile tool description
|
||||
and output are pruned (#1485), shaving cached prefix tokens. Pairs with
|
||||
the #1390 buffer raise from 0.48.0.
|
||||
|
||||
**Security.** `metaso` web-search no longer ships with a baked API
|
||||
key — it now requires the user-configured key like every other backend
|
||||
(#1488). Project-level hooks are trusted without an extra prompt
|
||||
provided they live inside the working directory.
|
||||
|
||||
**YOLO mode polish.** `run_command` and `run_background` approval
|
||||
gates now auto-resolve in YOLO mode (#1489) so power users don't get a
|
||||
modal for every shell call after explicitly opting in.
|
||||
|
||||
**Perf — folding thresholds.** Normal fold threshold raised 50% → 75%,
|
||||
aggressive 70% → 78% (#1461) — the loop now spends less time folding
|
||||
context that didn't need to be folded.
|
||||
|
||||
**Preflight.** The cap that previously only counted tokens now also
|
||||
gates on raw JSON body bytes (#1451), catching oversized payloads that
|
||||
weren't reflected in the token count.
|
||||
|
||||
**Desktop.** Error cards are dismissable and recoverable variants
|
||||
toned down (#1487); orphaned pause-gate modals are cleared on
|
||||
`$turn_complete` so a stale gate can't block the next turn (#1484);
|
||||
session titles are renameable from the sidebar (#1478); `/btw` now
|
||||
echoes input, surfaces busy state, and routes through the side-question
|
||||
prompt instead of silently dropping the keystroke (#1472).
|
||||
|
||||
**TUI.** SGR mouse mode stays on for Windows Terminal (#1486) —
|
||||
alternate-scroll silently breaks the wheel there. The default for
|
||||
non-WT terminals switches to alternate-scroll (`?1007h`) (#1477) to
|
||||
avoid the click-eats-selection problem on conhost-class hosts.
|
||||
|
||||
**Other:**
|
||||
- `fix(cost)` show session-aggregate cache hit so `/cost` matches the
|
||||
status-bar number (#1482)
|
||||
- `fix(plan)` Accept executes immediately when the plan has no open
|
||||
questions instead of waiting on a non-existent answer (#1480)
|
||||
- `fix` resolve root-relative edit-block paths (#1452)
|
||||
- `fix(tests)` bump chat-mcp-startup timeout to 30s for Windows test
|
||||
matrix (#1471)
|
||||
|
||||
## [0.48.0] — 2026-05-20
|
||||
|
||||
**Proxy-aware networking.** `NO_PROXY` is now honored end-to-end and
|
||||
the DeepSeek host is baked into the default bypass whitelist so a
|
||||
machine-wide `HTTPS_PROXY` no longer routes API traffic through an
|
||||
unwanted proxy (#1373). A `--no-proxy` CLI flag, `REASONIX_NO_PROXY`
|
||||
env, and `cfg.proxy` field give per-invocation control (#1374), and
|
||||
`/doctor` surfaces the resolved `NO_PROXY` value plus the routing
|
||||
decision Reasonix made for each host (#1375). If proxy behavior ever
|
||||
looks wrong, `/doctor` will tell you why in one screen.
|
||||
|
||||
**Code intelligence boost.** Two new tree-sitter–powered tools land:
|
||||
`get_symbols` and `find_in_code` cover TS/JS/Python/Go/Rust/Java
|
||||
(#1387), giving Reasonix structural code navigation instead of plain
|
||||
grep. Java gets a dedicated `java_source` tool that reads `.java`
|
||||
sources and decompiles `.class` / inner jar entries via `javap`
|
||||
(#1344), with output stripped of noise and the read buffer raised so
|
||||
large classes fit in one round (#1390). Web search adds Perplexity
|
||||
and Exa backends alongside the existing Mojeek / Tavily set (#1350).
|
||||
|
||||
**Lifecycle round two.** Checkpoints now gate mutating tools during
|
||||
restore so an in-flight edit can't race a rollback (#1389), state is
|
||||
preserved across plan revisions (#1388), and step evidence is
|
||||
summarized at the plan card so progress is legible without expanding
|
||||
each step (#1391). `/plan` is excluded from the strict-rails cache
|
||||
constraint that would otherwise force a re-prefix on every revision
|
||||
(#1407, #1386), and a strict end-to-end harness covers the whole
|
||||
lifecycle path (#1424).
|
||||
|
||||
**Subagent UX.** Long-running `explore` / `research` / `review` are
|
||||
now top-level slash commands with live byte-progress in the status
|
||||
row instead of a frozen spinner (#1422). The agent loop is hardened:
|
||||
`_turnAbort` resets in `finally` so a consumer break can't lock the
|
||||
session (#1421), ambiguous search-replace edit blocks are rejected
|
||||
instead of silently miss-applying (#1414), repeated gate rejections
|
||||
no longer ping-pong (#1392), and FS tools walk `.reasonix/` so user
|
||||
skills are reachable from any subdir (#1371).
|
||||
|
||||
**Desktop.** QQ sessions are now usable from the desktop app — the
|
||||
QQ Adapter routes events through the same RPC bridge as CLI tabs
|
||||
(#1334). Custom font support + file-link rendering improvements ship
|
||||
(#1376). Reliability fixes: `rpc_spawn` is idempotent and a
|
||||
`desktop_resync` rehydrate path recovers cleanly after a renderer
|
||||
reload (#1428); F5 / Ctrl+R are swallowed in packaged builds so a
|
||||
stray refresh can't wipe the chat (#1430); React error #31 with
|
||||
non-string MCP config items is fixed (#1394); the context-token
|
||||
meter syncs after `/compact` (#1399); the cost meter and session
|
||||
cost card apply USD→CNY conversion (#1369); `augmentProcessPath()`
|
||||
lost in the #1334 rebase is restored (#1361); desktop stdout partial
|
||||
writes are handled (#1405); `/btw` on empty payload now surfaces a
|
||||
usage hint instead of dropping the keystroke (#1383).
|
||||
|
||||
**TUI / UI.** `/about` is a new slash command showing version,
|
||||
website, GitHub, and license (#1434). Remaining hardcoded strings in
|
||||
edit history and MCP browse are now i18n'd (#1433). Reasoning,
|
||||
streaming, and diff content beyond the recent window are elided so
|
||||
long sessions don't tank scroll perf (#1432). Wheel scroll is less
|
||||
sensitive and per-card stdout resize listeners collapse to one (#1423);
|
||||
terminal mouse tracking now has a CLI opt-out for users on
|
||||
mouse-hostile terminals (#1345).
|
||||
|
||||
**Settings / config.** `config.json` with a UTF-8 BOM is tolerated
|
||||
instead of crashing the loader (#1431); preset-trace diagnostic
|
||||
retired (#1431). The dashboard can now clear `baseUrl` to fall back
|
||||
to the bundled default (#1425). `string[]` config fields are
|
||||
sanitized at the `readConfig` boundary so a malformed entry can't
|
||||
propagate into the running session (#1396).
|
||||
|
||||
**Other:**
|
||||
- `fix(test)` switch vitest to forks pool to stop heap OOM on
|
||||
many-core hosts (#1408)
|
||||
- `chore(shell)` trim `run_command` param description (saves 119
|
||||
tokens of cached prefix) (#1403)
|
||||
- `chore(scripts)` measure per-tool token cost end-to-end (#1402)
|
||||
- `test(tools)` close dispatch-level E2E gaps for shell jobs, web,
|
||||
and scaffold (#1400)
|
||||
- `test(shell-tools)` raise `run_background` dispatch test timeout
|
||||
to 15s (#1406)
|
||||
- `test(web)` cover Perplexity + Exa backends (#1366)
|
||||
- `docs(qq)` add desktop quick start and trim README surface (#1401)
|
||||
|
||||
## [0.47.2] — 2026-05-19
|
||||
|
||||
**Hotfix — `npx reasonix@latest` install.** `0.47.1` shipped with
|
||||
`@reasonix/core-utils` listed under runtime `dependencies` as
|
||||
`workspace:*`. Because the workspace package is `private: true`,
|
||||
`npm publish` did not rewrite the protocol, so consumers hit
|
||||
`EUNSUPPORTEDPROTOCOL: Unsupported URL Type "workspace:"` during
|
||||
install. The bundled `dist/` already inlines core-utils at build
|
||||
time, so the dependency belongs under `devDependencies`. Moved.
|
||||
`0.47.1` is deprecated on npm; please install `0.47.2`.
|
||||
|
||||
## [0.47.1] — 2026-05-19
|
||||
|
||||
**Desktop shakeout.** Follow-ups to the 0.47.0 desktop launch:
|
||||
|
||||
+339
@@ -0,0 +1,339 @@
|
||||
<p align="center">
|
||||
<img src="docs/logo.svg" alt="Reasonix" width="640"/>
|
||||
</p>
|
||||
|
||||
> [!WARNING]
|
||||
> **これはレガシーの TypeScript 系列(Reasonix 0.x)で、メンテナンスのみです。**
|
||||
> 開発は **Go 版の書き直し** に移行しました —— 新しいデフォルトの [`main-v2`](https://github.com/esengine/DeepSeek-Reasonix/tree/main-v2) ブランチと[移行ガイド](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/MIGRATING.md)を参照してください。ここではバグ修正のみ反映されます。
|
||||
|
||||
|
||||
<p align="center">
|
||||
<a href="./README.md">English</a>
|
||||
·
|
||||
<a href="./README.zh-CN.md">简体中文</a>
|
||||
·
|
||||
<strong>日本語</strong>
|
||||
·
|
||||
<a href="https://esengine.github.io/DeepSeek-Reasonix/">公式サイト</a>
|
||||
·
|
||||
<a href="https://esengine.github.io/DeepSeek-Reasonix/configuration.html">ガイド</a>
|
||||
·
|
||||
<a href="./docs/ARCHITECTURE.md">アーキテクチャ</a>
|
||||
·
|
||||
<a href="./benchmarks/">ベンチマーク</a>
|
||||
·
|
||||
<strong><a href="https://discord.gg/XF78rEME2D">Discord</a></strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/package/reasonix"><img src="https://img.shields.io/npm/v/reasonix.svg?style=flat-square&color=cb3837&labelColor=161b22&logo=npm&logoColor=white" alt="npm version"/></a>
|
||||
<a href="https://github.com/esengine/reasonix/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/esengine/reasonix/ci.yml?style=flat-square&label=ci&labelColor=161b22&logo=githubactions&logoColor=white" alt="CI"/></a>
|
||||
<a href="./LICENSE"><img src="https://img.shields.io/npm/l/reasonix.svg?style=flat-square&color=8b949e&labelColor=161b22" alt="license"/></a>
|
||||
<a href="https://www.npmjs.com/package/reasonix"><img src="https://img.shields.io/npm/dm/reasonix.svg?style=flat-square&color=3fb950&labelColor=161b22&label=downloads" alt="downloads"/></a>
|
||||
<a href="./package.json"><img src="https://img.shields.io/node/v/reasonix.svg?style=flat-square&color=5fa04e&labelColor=161b22&logo=nodedotjs&logoColor=white" alt="node"/></a>
|
||||
<a href="https://github.com/esengine/reasonix/stargazers"><img src="https://img.shields.io/github/stars/esengine/reasonix.svg?style=flat-square&color=dbab09&labelColor=161b22&logo=github&logoColor=white" alt="GitHub stars"/></a>
|
||||
<a href="https://atomgit.com/esengine/DeepSeek-Reasonix"><img src="https://atomgit.com/esengine/DeepSeek-Reasonix/star/badge.svg" alt="AtomGit stars"/></a>
|
||||
<a href="https://github.com/esengine/reasonix/graphs/contributors"><img src="https://img.shields.io/github/contributors/esengine/reasonix.svg?style=flat-square&color=bc8cff&labelColor=161b22&logo=github&logoColor=white" alt="contributors"/></a>
|
||||
<a href="https://github.com/esengine/reasonix/discussions"><img src="https://img.shields.io/github/discussions/esengine/reasonix.svg?style=flat-square&color=58a6ff&labelColor=161b22&logo=github&logoColor=white" alt="Discussions"/></a>
|
||||
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://oosmetrics.com/repo/esengine/reasonix"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/9e931d80-2050-4b10-902e-44970cc133ad.svg" alt="oosmetrics — Top 2 in Agents by velocity"/></a>
|
||||
<a href="https://oosmetrics.com/repo/esengine/reasonix"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/556d94b3-61b7-486b-baf2-888b9327deab.svg" alt="oosmetrics — Top 3 in LLMs by velocity"/></a>
|
||||
<a href="https://oosmetrics.com/repo/esengine/reasonix"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/0f457d4c-efca-4d15-ad2b-139691ff342c.svg" alt="oosmetrics — Top 3 in CLI by velocity"/></a>
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
|
||||
<h3 align="center">ターミナルで動く DeepSeek ネイティブの AI コーディングエージェント。</h3>
|
||||
<p align="center">プレフィックスキャッシュの安定性を中心に設計されているため、長時間のセッションでもトークンコストを低く保ち、動かしっぱなしにできます。</p>
|
||||
|
||||
<br/>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/hero-terminal.svg" alt="Reasonix code mode — assistant proposes a SEARCH/REPLACE edit; nothing on disk until /apply" width="860"/>
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
|
||||
> [!TIP]
|
||||
> **キャッシュの安定性はオンにする「機能」ではなく、ループそのものが前提として設計された不変条件です。** これこそが Reasonix が DeepSeek 専用である理由であり、すべてのレイヤーがバイト単位で安定したプレフィックスキャッシュの仕組みに合わせて調整されています。
|
||||
|
||||
> [!NOTE]
|
||||
> **実ユーザーの1日(2026-05-01):** 入力トークン 4億3,500万、**キャッシュヒット率 99.82%**、同じワークロードを `v4-flash` でキャッシュなしに実行した場合の約 \$61 に対して、わずか約 \$12 で済んでいます — 詳しくは[ケーススタディ](./benchmarks/real-world-cache/README.md)を参照。キャッシュ可能なバイト列を提供するのは DeepSeek ですが、それを長時間のセッションにわたってキャッシュ可能に保つのが [Pillar 1](./docs/ARCHITECTURE.md#pillar-1--cache-first-loop) の4つの仕組みです。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Community · 加入社区** — セットアップのヘルプ(`#help` / `#求助`)、ワークフローのショーケース、機能アイデア、コントリビューター限定の PR 調整用チャンネルを備えたバイリンガル Discord。サーバー内で GitHub を認証すると、自動的に **Contributor** ロールが付与されます。→ **<https://discord.gg/XF78rEME2D>**
|
||||
|
||||
<br/>
|
||||
|
||||
## インストール
|
||||
|
||||
Node ≥ 22 が必要です。macOS · Linux · Windows(PowerShell · Git Bash · Windows Terminal)で動作します。
|
||||
|
||||
`reasonix` コマンドを `PATH` 上で使えるようにしたい場合は、Reasonix をグローバルにインストールします:
|
||||
|
||||
~~~bash
|
||||
npm install -g reasonix
|
||||
reasonix code my-project # 初回実行時に DeepSeek API キーを貼り付け。以降は保存される
|
||||
~~~
|
||||
|
||||
または、グローバルインストールせずに一度だけ実行することもできます:
|
||||
|
||||
~~~bash
|
||||
cd my-project
|
||||
npx reasonix code # デフォルトでは常に最新のパッケージを使用
|
||||
~~~
|
||||
|
||||
[DeepSeek API キーを取得 →](https://platform.deepseek.com/api_keys) · フラグは `reasonix code --help` を参照。
|
||||
|
||||
Reasonix を毎日使うなら、グローバルインストールが最もシンプルな方法です。とりあえず試してみたいだけなら `npx` を使ってください。
|
||||
|
||||
**キー入力を減らしたい?** より短い `dsnix` エイリアスが同じ CLI を指します:
|
||||
|
||||
~~~bash
|
||||
npm install -g dsnix # PATH 上に `dsnix` を公開。reasonix に依存
|
||||
npx dsnix@latest code # より短いコマンドで一度だけ実行
|
||||
~~~
|
||||
|
||||
`npm install -g reasonix` でグローバルインストールすると `dsnix` のシムも PATH 上に配置されるため、2つは相互に置き換え可能です。
|
||||
|
||||
サブコマンドなしの `reasonix`(裸の `reasonix`)はカレントディレクトリで `code` を起動します — `reasonix` と入力するのは `reasonix code` と等価です。
|
||||
|
||||
| コマンド | 用途 |
|
||||
|---|---|
|
||||
| `reasonix` / `reasonix code [dir]` | コーディングエージェント。**ここから始めてください。** |
|
||||
| `reasonix chat` | プレーンなチャット — ファイルシステムやシェルのツールなし。 |
|
||||
| `reasonix run "task"` | 一度だけ実行し、stdout にストリーミング。パイプ向き。 |
|
||||
| `reasonix doctor` | ヘルスチェック: Node、API キー、MCP の配線。 |
|
||||
| `reasonix update` | Reasonix 自体をアップグレード。 |
|
||||
|
||||
その他のサブコマンド(`replay` · `diff` · `events` · `stats` · `index` · `mcp` · `prune-sessions`)は `reasonix --help` と [CLI リファレンス](https://esengine.github.io/DeepSeek-Reasonix/#cli)にあります。
|
||||
|
||||
### QQ チャンネル
|
||||
|
||||
QQ は既存の `chat`、`code`、またはデスクトップのセッションをリモートチャンネルとして拡張できます。これは独立したランタイムモードではなく、現在のセッションフローの一部です。
|
||||
|
||||
- CLI: セッションを開始し、`/qq connect` を実行
|
||||
- デスクトップ: `Settings -> General -> QQ Channel` を開く
|
||||
|
||||
接続すると、QQ のメッセージを現在のセッションに取り込み、アシスタントの返信を QQ に転送し、その後のやり取りをリモートで継続できます。
|
||||
|
||||
詳しいセットアップ、デスクトップのクイックスタート、トラブルシューティングは [QQ チャンネルのセットアップ](./docs/qq-connect.md)を参照してください。
|
||||
|
||||
### デスクトップクライアント(プレリリース)
|
||||
|
||||
同じループの GUI が欲しいユーザー向けのネイティブ Tauri クライアント。マルチタブ対応で、右パネルにはこのセッションでエージェントが読み取り・編集したファイルが表示され、下部には同じコスト / キャッシュ / トークンのメーターがリアルタイムで表示されます。同じ DeepSeek API キー、同じ `~/.reasonix` 設定を使い、デスクトップ版は独自の Node ランタイムを同梱するため、別途 `npm install` する必要はありません。
|
||||
|
||||
各プラットフォームのインストーラーは [GitHub Releases](https://github.com/esengine/DeepSeek-Reasonix/releases) からダウンロードできます。デスクトップ版は **プレリリース** として提供されます: ループとプロトコルは CLI と同じですが、UI はまだ磨き込み中で、インストーラーはまだコード署名されていません。
|
||||
|
||||
- **macOS** — 初回起動時に Gatekeeper に引っかかります。一度きりの対処: `xattr -dr com.apple.quarantine /Applications/Reasonix.app`(または右クリック → 開く → 確認)。
|
||||
- **Windows** — SmartScreen が「不明な発行元」と警告します。**詳細情報 → 実行** をクリックしてください。
|
||||
- **Linux** — `.deb` と `.AppImage` はそのまま起動でき、追加の手順は不要です。
|
||||
|
||||
CLI が引き続き正規のインターフェースです。CLI に入った機能はすべてデスクトップのコンポーザーからも利用できます。
|
||||
|
||||
<details>
|
||||
<summary><strong>別のフォルダで作業する · chat と code の違い · スキルを書く</strong></summary>
|
||||
|
||||
**別のフォルダで作業する。** Reasonix はファイルシステムツールを起動ディレクトリにスコープします。対象を変えるには `--dir` を渡してください。セッション途中での切り替えは設計上サポートされていません(メモリのパスが古いルートと絡まってしまうため)— 一度終了して再起動してください。
|
||||
|
||||
~~~bash
|
||||
npx reasonix code --dir /path/to/project
|
||||
~~~
|
||||
|
||||
**`chat` と `code` の選び方。** `code` がデフォルトであり、ファイルシステム / シェルツールと SEARCH/REPLACE レビューを備えた唯一のモードです。`chat` はより軽量でツールをオフにしたシェルです — MCP は接続しつつディスクアクセスのない思考のパートナーが欲しいときに使ってください。
|
||||
|
||||
| 得られるもの | `code` | `chat` |
|
||||
|---|---|---|
|
||||
| ファイルシステムツール + `edit_file` | ✓ | — |
|
||||
| SEARCH/REPLACE → `/apply` レビュー | ✓ | — |
|
||||
| シェルツール(ゲート付き) | ✓ | — |
|
||||
| プランモード · `/todo` · `/skill new` · `/mcp add` | ✓ | — |
|
||||
| メモリ(`remember` / `recall_memory`) | プロジェクト + グローバル | グローバルのみ |
|
||||
| 設定からの MCP サーバー · ウェブ検索 · `ask_choice` | ✓ | ✓ |
|
||||
| コーディング用システムプロンプト | ✓ | 汎用 |
|
||||
| セッションのスコープ | ディレクトリごと | 共有デフォルト |
|
||||
|
||||
**最初のスキルを書く。** リモートレジストリはありません — 直接書きます。ファイル(`description:` フロントマター + 本文)を編集し、`/skill list` を実行します。本文をインライン展開する代わりに分離したサブエージェントループを生成するには `runAs: subagent` を追加します。
|
||||
|
||||
~~~bash
|
||||
/skill new my-skill # <project>/.reasonix/skills/my-skill.md
|
||||
/skill new my-skill --global # プロジェクト横断で使うなら ~/.reasonix/skills
|
||||
~~~
|
||||
|
||||
**Claude 形式のスキルも読み込まれます。** `<project>/.claude/skills/<name>/SKILL.md` と `~/.claude/skills/` が Reasonix のネイティブパスと並んで読み込まれるため、Claude 形式のスキルを出力するツールはそのまま動きます。例 — 上流アダプターなしで OpenSpec ワークフローを投入する:
|
||||
|
||||
~~~bash
|
||||
npx openspec init --tools claude # .claude/skills/openspec-*/SKILL.md を書き出す
|
||||
/skill openspec-propose <task> # その後 Reasonix から呼び出す
|
||||
~~~
|
||||
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
|
||||
## 設定
|
||||
|
||||
`~/.reasonix/config.json` の1つの JSON ファイルと、`<project>/.reasonix/` 配下のプロジェクトごとのオーバーライドで設定します。すべてのキー、すべてのスラッシュコマンド、スキル / メモリ / フックのオンディスク構造を網羅した完全なバイリンガルリファレンスはこちら:
|
||||
|
||||
> 📘 **[設定ガイド](https://esengine.github.io/DeepSeek-Reasonix/configuration.html)** · [中文](https://esengine.github.io/DeepSeek-Reasonix/configuration.html?lang=zh)
|
||||
|
||||
| トピック | 概要 |
|
||||
|---|---|
|
||||
| [MCP サーバー](https://esengine.github.io/DeepSeek-Reasonix/configuration.html#mcp) | stdio · SSE · Streamable HTTP。1つの仕様形式が `config.json` と `--mcp` の両方で動作。 |
|
||||
| [スキル](https://esengine.github.io/DeepSeek-Reasonix/configuration.html#skills) | モデルが呼び出せる Markdown のプレイブック。`inline` または `subagent` モード。 |
|
||||
| [メモリ](https://esengine.github.io/DeepSeek-Reasonix/configuration.html#memory) | プレフィックスにピン留めされるユーザー専用の知識。`user` / `feedback` / `project` / `reference` の型。 |
|
||||
| [フック](https://esengine.github.io/DeepSeek-Reasonix/configuration.html#hooks) | ライフサイクルイベント時のシェルコマンド。`PreToolUse`(ゲート) · `PostToolUse` · `UserPromptSubmit` · `Stop`。 |
|
||||
| [権限](https://esengine.github.io/DeepSeek-Reasonix/configuration.html#permissions) | ワークスペースごとのシェル許可リスト。完全な前方一致。 |
|
||||
| [ウェブ検索](https://esengine.github.io/DeepSeek-Reasonix/configuration.html#search) | デフォルトは Mojeek。`/search-engine` でセルフホストの SearXNG や Metaso に切り替え可能。 |
|
||||
| [セマンティックインデックス](https://esengine.github.io/DeepSeek-Reasonix/configuration.html#index) | `reasonix index` — ローカルの Ollama または任意の OpenAI 互換の埋め込みエンドポイント。 |
|
||||
|
||||
<br/>
|
||||
|
||||
## Reasonix の何が違うのか
|
||||
|
||||
ループは3つの柱を中心に構成されています。それぞれが、汎用的なエージェントフレームワークには見えてすらいない問題を解決します — それらは別のキャッシュの仕組み向けに設計されているからです。
|
||||
|
||||
<sub align="center">
|
||||
|
||||
完全なアーキテクチャ解説はこちらから → [Pillar 1 — キャッシュファーストのループ](./docs/ARCHITECTURE.md#pillar-1--cache-first-loop) · [Pillar 2 — ツール呼び出しの修復](./docs/ARCHITECTURE.md#pillar-2--tool-call-repair) · [Pillar 3 — コスト制御](./docs/ARCHITECTURE.md#pillar-3--cost-control-v06)
|
||||
|
||||
</sub>
|
||||
|
||||
<br/>
|
||||
|
||||
## 機能
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/feature-grid.svg" alt="Reasonix capabilities — cell-diff renderer, MCP, plan mode, permissions, dashboard, persistent sessions, hooks/skills/memory, semantic search, auto-checkpoints, /effort knob, transcript replay, event log" width="880"/>
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
|
||||
## 比較
|
||||
|
||||
| | Reasonix | Claude Code | Cursor | Aider |
|
||||
|-----------------------------------|------------------|-------------------|---------------------|--------------------|
|
||||
| バックエンド | DeepSeek | Anthropic | OpenAI / Anthropic | 任意(OpenRouter) |
|
||||
| ライセンス | **MIT** | クローズド | クローズド | Apache 2 |
|
||||
| コストプロファイル | **タスクあたり低コスト** | プレミアム | サブスク + 従量 | まちまち |
|
||||
| DeepSeek プレフィックスキャッシュ | **設計込み** | 該当なし | 該当なし | 偶発的 |
|
||||
| 組み込みウェブダッシュボード | あり | — | 該当なし(IDE) | — |
|
||||
| 設定可能なウェブ検索エンジン | `/search-engine` | — | — | — |
|
||||
| ワークスペースごとの永続セッション | あり | 一部 | 該当なし | — |
|
||||
| プランモード · MCP · フック · スキル | あり | あり | あり | 一部 |
|
||||
| ウェブ検索(Mojeek + SearXNG + Metaso) | あり | あり | あり | あり |
|
||||
| オープンなコミュニティ開発 | あり | — | — | あり |
|
||||
|
||||
リアルタイムのキャッシュヒット率、コスト、手法については [`benchmarks/`](./benchmarks/) を参照してください — 数値はモデルの料金とともに変動するため、README ではなくハーネスとともに置いています。
|
||||
|
||||
<br/>
|
||||
|
||||
## ドキュメント
|
||||
|
||||
- [**アーキテクチャ**](./docs/ARCHITECTURE.md) — 3つの柱: キャッシュファーストのループ、ツール呼び出しの修復、コスト制御
|
||||
- [**CLI リファレンス**](./docs/CLI-REFERENCE.md) — すべてのシェルサブコマンド、すべてのスラッシュコマンド、すべてのキーバインド
|
||||
- [**QQ チャンネルのセットアップ**](./docs/qq-connect.md) — CLI の初回接続フロー、デスクトップのエントリ、QQ Open Platform の認証情報
|
||||
- [**ベンチマーク**](./benchmarks/) — τ-bench-lite ハーネス、トランスクリプト、コスト算出手法
|
||||
- [**公式サイト**](https://esengine.github.io/DeepSeek-Reasonix/) — はじめに、ダッシュボードのモックアップ、TUI のモックアップ
|
||||
- [**コントリビューション**](./CONTRIBUTING.md) — コメントポリシー、エラーハンドリングのルール、自作よりライブラリを優先
|
||||
- [**行動規範**](./CODE_OF_CONDUCT.md) · [**セキュリティポリシー**](./SECURITY.md)
|
||||
|
||||
<br/>
|
||||
|
||||
## コミュニティ
|
||||
|
||||
> [!NOTE]
|
||||
> Reasonix はオープンソースであり、コミュニティによって開発されています。このファイル末尾の謝辞ウォールにあるすべてのアバターは、実際にマージされた PR です。
|
||||
|
||||
スコープが切られた入門用チケット — それぞれ背景、コードの参照ポイント、受け入れ基準、ヒント付き — は [`good first issue`](https://github.com/esengine/reasonix/labels/good%20first%20issue) ラベルの下にあります。オープンなものなら何でも選んでください。
|
||||
|
||||
**オープンな Discussions — 意見募集中:**
|
||||
|
||||
- [#20 · CLI / TUI デザイン](https://github.com/esengine/reasonix/discussions/20) — 何が壊れていて、何が足りず、何を変えますか?
|
||||
- [#21 · ダッシュボードデザイン](https://github.com/esengine/reasonix/discussions/21) — [提案中のモックアップ](https://esengine.github.io/DeepSeek-Reasonix/design/agent-dashboard.html)への反応をどうぞ
|
||||
- [#22 · 今後の機能ウィッシュリスト](https://github.com/esengine/reasonix/discussions/22) — 次に Reasonix に組み込みたいものは?
|
||||
|
||||
**すでに Reasonix を使っていて、他の人に広める手伝いをしてもいい?** ブログ記事、記事、スクリーンショット、講演、動画を [**Show and tell**](https://github.com/esengine/reasonix/discussions/categories/show-and-tell) に公開してください。このプロジェクトにはマーケティング予算がありません — 新しいユーザーは口コミで見つけてくれます。継続的に応援してくれる人は、以下のバッジを獲得でき、付与されるとコントリビューターウォールの横に表示されます:
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/esengine/reasonix/discussions/categories/show-and-tell">
|
||||
<img src="https://img.shields.io/badge/REASONIX-📣%20ADVOCATE-c4b5fd?style=for-the-badge&labelColor=0d1117" alt="Reasonix Advocate badge — earned by sustained advocates"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
**最初の PR の前に**: [`CONTRIBUTING.md`](./CONTRIBUTING.md) を読んでください — 短く厳格なルール(コメント、エラー、自作よりライブラリを優先)。`tests/comment-policy.test.ts` がコメントのルールを強制します。`npm run verify` がプッシュ前のゲートです。参加することで [行動規範](./CODE_OF_CONDUCT.md) に同意したものとみなされます。セキュリティの問題は → [SECURITY.md](./SECURITY.md)。
|
||||
|
||||
<br/>
|
||||
|
||||
## 非目標
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Reasonix には明確な方針があります。あえて *やらない* ことがいくつかあります — あなたの作業に合ったツールを選べるよう、ここに挙げておきます。
|
||||
|
||||
- **マルチプロバイダーの柔軟性。** あえて DeepSeek 専用にしています。1つのバックエンドに密結合していることは制限ではなく機能です。
|
||||
- **IDE 統合。** ターミナルファースト。差分は `git diff` に、ファイルツリーは `ls` にあります。ダッシュボードはあくまで補助であり、Cursor の代替ではありません。
|
||||
- **最難関リーダーボードの推論。** Claude Opus はいまだに一部のベンチマークで勝っています。DeepSeek はコーディングで競争力がありますが、もしあなたの作業が「この認証バグを直す」ではなく「この博士課程レベルの証明を解く」ものなら、Claude から始めてください。
|
||||
- **エアギャップ / 完全無料。** Reasonix には有料の DeepSeek API キーが必要です。エアギャップ環境やゼロコストでの実行には Aider + Ollama または [Continue](https://continue.dev) を参照してください。
|
||||
|
||||
<br/>
|
||||
|
||||
## スター履歴
|
||||
|
||||
<a href="https://www.star-history.com/?repos=esengine%2FDeepSeek-Reasonix&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=esengine/DeepSeek-Reasonix&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=esengine/DeepSeek-Reasonix&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=esengine/DeepSeek-Reasonix&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
<br/>
|
||||
|
||||
## サポート
|
||||
|
||||
Reasonix が役に立って、お礼を言いたいと思ったらどうぞ。これはあくまでコーヒー一杯であって契約ではありません — 寄付で機能の優先順位が買えるわけでも、issue のトリアージのされ方が変わるわけでもありません。
|
||||
|
||||
- **International** — PayPal: [paypal.me/yuhuahui](https://paypal.me/yuhuahui)
|
||||
- **国内** — 微信支付(扫码)
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/sponsor/wechat-pay.jpg" alt="WeChat Pay QR code" width="240"/>
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
|
||||
## 謝辞
|
||||
|
||||
Reasonix を最も形づくってくれた人々の小さなリストです — コミット数とコード量の両方で測っています。**重要度の順位付けはなく、アルファベット順に記載しています。** 完全なコントリビューターグラフは
|
||||
[GitHub](https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors) にあります。
|
||||
|
||||
- [**ctharvey**](https://github.com/ctharvey)
|
||||
- [**dimasd-angga**](https://github.com/dimasd-angga) (Dimas D. Angga)
|
||||
- [**Evan-Pycraft**](https://github.com/Evan-Pycraft)
|
||||
- [**ForeverYoungPp**](https://github.com/ForeverYoungPp)
|
||||
- [**GTC2080**](https://github.com/GTC2080) (TaoMu)
|
||||
- [**kabaka9527**](https://github.com/kabaka9527)
|
||||
- [**lisniuse**](https://github.com/lisniuse) (Richie)
|
||||
- [**wade19990814-hue**](https://github.com/wade19990814-hue)
|
||||
- [**wviana**](https://github.com/wviana) (Wesley Viana)
|
||||
|
||||
また、プロジェクトのロゴをデザインしてくれた [**Bernardxu123**](https://github.com/Bernardxu123)([`docs/brand/`](./docs/brand/) を参照)と、小紅書(XiaoHongShu)でプロジェクトを宣伝してくれた [AIGC Link](https://xhslink.com/m/80ngts127cA) にも別途感謝します。
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=esengine/DeepSeek-Reasonix&max=100&columns=12" alt="Contributors to esengine/DeepSeek-Reasonix" width="860"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<sub>MIT — <a href="./LICENSE">LICENSE</a> を参照</sub>
|
||||
<br/>
|
||||
<sub><a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors">esengine/DeepSeek-Reasonix</a> のコミュニティによって構築</sub>
|
||||
</p>
|
||||
@@ -2,11 +2,18 @@
|
||||
<img src="docs/logo.svg" alt="Reasonix" width="640"/>
|
||||
</p>
|
||||
|
||||
> [!WARNING]
|
||||
> **This is the legacy TypeScript line (Reasonix 0.x), in maintenance mode.**
|
||||
> Active development moved to the **Go rewrite** — see the [`main-v2`](https://github.com/esengine/DeepSeek-Reasonix/tree/main-v2) branch (the new default) and the [migration guide](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/MIGRATING.md). Only bug fixes land here.
|
||||
|
||||
|
||||
<p align="center">
|
||||
<strong>English</strong>
|
||||
·
|
||||
<a href="./README.zh-CN.md">简体中文</a>
|
||||
·
|
||||
<a href="./README.ja-JP.md">日本語</a>
|
||||
·
|
||||
<a href="https://esengine.github.io/DeepSeek-Reasonix/">Website</a>
|
||||
·
|
||||
<a href="https://esengine.github.io/DeepSeek-Reasonix/configuration.html">Guide</a>
|
||||
@@ -25,6 +32,7 @@
|
||||
<a href="https://www.npmjs.com/package/reasonix"><img src="https://img.shields.io/npm/dm/reasonix.svg?style=flat-square&color=3fb950&labelColor=161b22&label=downloads" alt="downloads"/></a>
|
||||
<a href="./package.json"><img src="https://img.shields.io/node/v/reasonix.svg?style=flat-square&color=5fa04e&labelColor=161b22&logo=nodedotjs&logoColor=white" alt="node"/></a>
|
||||
<a href="https://github.com/esengine/reasonix/stargazers"><img src="https://img.shields.io/github/stars/esengine/reasonix.svg?style=flat-square&color=dbab09&labelColor=161b22&logo=github&logoColor=white" alt="GitHub stars"/></a>
|
||||
<a href="https://atomgit.com/esengine/DeepSeek-Reasonix"><img src="https://atomgit.com/esengine/DeepSeek-Reasonix/star/badge.svg" alt="AtomGit stars"/></a>
|
||||
<a href="https://github.com/esengine/reasonix/graphs/contributors"><img src="https://img.shields.io/github/contributors/esengine/reasonix.svg?style=flat-square&color=bc8cff&labelColor=161b22&logo=github&logoColor=white" alt="contributors"/></a>
|
||||
<a href="https://github.com/esengine/reasonix/discussions"><img src="https://img.shields.io/github/discussions/esengine/reasonix.svg?style=flat-square&color=58a6ff&labelColor=161b22&logo=github&logoColor=white" alt="Discussions"/></a>
|
||||
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
|
||||
@@ -82,6 +90,15 @@ Grab a [DeepSeek API key →](https://platform.deepseek.com/api_keys) · `reason
|
||||
|
||||
If you use Reasonix daily, global install is the simplest path. If you just want to try it, use `npx`.
|
||||
|
||||
**Prefer fewer keystrokes?** The shorter `dsnix` alias resolves to the same CLI:
|
||||
|
||||
~~~bash
|
||||
npm install -g dsnix # exposes `dsnix` on PATH, depends on reasonix
|
||||
npx dsnix@latest code # one-shot via the shorter command
|
||||
~~~
|
||||
|
||||
A global `npm install -g reasonix` also drops a `dsnix` shim on PATH, so the two are interchangeable.
|
||||
|
||||
Bare `reasonix` (no subcommand) launches `code` in the current directory — typing `reasonix` and `reasonix code` are equivalent.
|
||||
|
||||
| Command | When |
|
||||
@@ -96,35 +113,14 @@ Other subcommands (`replay` · `diff` · `events` · `stats` · `index` · `mcp`
|
||||
|
||||
### QQ channel
|
||||
|
||||
Reasonix can attach QQ as a remote communication channel for existing `chat` and `code` sessions. It is not a separate runtime mode.
|
||||
QQ can extend an existing `chat`, `code`, or desktop session as a remote channel. It is part of the current session flow, not a separate runtime mode.
|
||||
|
||||
Start a session first:
|
||||
- CLI: start a session, then run `/qq connect`
|
||||
- Desktop: open `Settings -> General -> QQ Channel`
|
||||
|
||||
~~~bash
|
||||
reasonix code
|
||||
# or
|
||||
reasonix chat
|
||||
~~~
|
||||
Once connected, QQ messages can enter the current session, assistant replies route back to QQ, and follow-up interactions can continue remotely.
|
||||
|
||||
Then connect QQ from inside the session:
|
||||
|
||||
~~~text
|
||||
/qq connect
|
||||
~~~
|
||||
|
||||
On first use, Reasonix asks for the QQ `App ID` and `App Secret` inside the current TUI. Later `/qq connect` calls reuse the saved credentials directly.
|
||||
|
||||
Available commands:
|
||||
|
||||
- `/qq connect`
|
||||
- `/qq status`
|
||||
- `/qq disconnect`
|
||||
|
||||
Once enabled, later `chat` / `code` sessions auto-start the QQ channel. Slash commands, confirmation prompts, and follow-up assistant replies can continue through QQ without terminal-side input.
|
||||
|
||||
Desktop users can configure the same channel from `Settings -> General -> QQ Channel`.
|
||||
|
||||
See [QQ channel setup](./docs/qq-connect.md) for the CLI flow, desktop entry, and QQ Open Platform setup.
|
||||
For full setup, desktop quick start, and troubleshooting, see [QQ channel setup](./docs/qq-connect.md).
|
||||
|
||||
### Desktop client (prerelease)
|
||||
|
||||
@@ -191,7 +187,7 @@ One JSON file at `~/.reasonix/config.json` plus per-project overrides under `<pr
|
||||
| [Memory](https://esengine.github.io/DeepSeek-Reasonix/configuration.html#memory) | User-private knowledge pinned into the prefix. `user` / `feedback` / `project` / `reference` types. |
|
||||
| [Hooks](https://esengine.github.io/DeepSeek-Reasonix/configuration.html#hooks) | Shell commands on lifecycle events. `PreToolUse` (gating) · `PostToolUse` · `UserPromptSubmit` · `Stop`. |
|
||||
| [Permissions](https://esengine.github.io/DeepSeek-Reasonix/configuration.html#permissions) | Per-workspace shell allowlist. Exact-prefix match. |
|
||||
| [Web search](https://esengine.github.io/DeepSeek-Reasonix/configuration.html#search) | Mojeek by default; switch to self-hosted SearXNG or Metaso with `/search-engine`. |
|
||||
| [Web search](https://esengine.github.io/DeepSeek-Reasonix/configuration.html#search) | Bing by default; switch to Baidu AI Search, self-hosted SearXNG, Metaso, Tavily, Perplexity, Exa, Brave, or Ollama with `/search-engine`. |
|
||||
| [Semantic index](https://esengine.github.io/DeepSeek-Reasonix/configuration.html#index) | `reasonix index` — local Ollama or any OpenAI-compatible embedding endpoint. |
|
||||
|
||||
<br/>
|
||||
@@ -228,7 +224,7 @@ Click through to the full architecture writeup → [Pillar 1 — Cache-first loo
|
||||
| Configurable web search engine | `/search-engine` | — | — | — |
|
||||
| Persistent per-workspace sessions | yes | partial | n/a | — |
|
||||
| Plan mode · MCP · hooks · skills | yes | yes | yes | partial |
|
||||
| Web search (Mojeek + SearXNG + Metaso) | yes | yes | yes | yes |
|
||||
| Web search (Bing + Baidu + SearXNG + API engines) | yes | yes | yes | yes |
|
||||
| Open community development | yes | — | — | yes |
|
||||
|
||||
For live cache-hit rates, costs, and methodology, see [`benchmarks/`](./benchmarks/) — the numbers move with model pricing, so they live with the harness, not in the README.
|
||||
|
||||
+14
-27
@@ -2,11 +2,18 @@
|
||||
<img src="docs/logo.svg" alt="Reasonix" width="640"/>
|
||||
</p>
|
||||
|
||||
> [!WARNING]
|
||||
> **这是 legacy TypeScript 线(Reasonix 0.x),仅维护。**
|
||||
> 开发已转移到 **Go 重写版** —— 见 [`main-v2`](https://github.com/esengine/DeepSeek-Reasonix/tree/main-v2) 分支(新的默认分支)与[迁移指南](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/MIGRATING.md)。此处仅修复 bug。
|
||||
|
||||
|
||||
<p align="center">
|
||||
<a href="./README.md">English</a>
|
||||
·
|
||||
<strong>简体中文</strong>
|
||||
·
|
||||
<a href="./README.ja-JP.md">日本語</a>
|
||||
·
|
||||
<a href="https://esengine.github.io/DeepSeek-Reasonix/">官方网站</a>
|
||||
·
|
||||
<a href="https://esengine.github.io/DeepSeek-Reasonix/configuration.html?lang=zh">配置指南</a>
|
||||
@@ -25,6 +32,7 @@
|
||||
<a href="https://www.npmjs.com/package/reasonix"><img src="https://img.shields.io/npm/dm/reasonix.svg?style=flat-square&color=3fb950&labelColor=161b22&label=downloads" alt="downloads"/></a>
|
||||
<a href="./package.json"><img src="https://img.shields.io/node/v/reasonix.svg?style=flat-square&color=5fa04e&labelColor=161b22&logo=nodedotjs&logoColor=white" alt="node"/></a>
|
||||
<a href="https://github.com/esengine/reasonix/stargazers"><img src="https://img.shields.io/github/stars/esengine/reasonix.svg?style=flat-square&color=dbab09&labelColor=161b22&logo=github&logoColor=white" alt="GitHub stars"/></a>
|
||||
<a href="https://atomgit.com/esengine/DeepSeek-Reasonix"><img src="https://atomgit.com/esengine/DeepSeek-Reasonix/star/badge.svg" alt="AtomGit stars"/></a>
|
||||
<a href="https://github.com/esengine/reasonix/graphs/contributors"><img src="https://img.shields.io/github/contributors/esengine/reasonix.svg?style=flat-square&color=bc8cff&labelColor=161b22&logo=github&logoColor=white" alt="contributors"/></a>
|
||||
<a href="https://github.com/esengine/reasonix/discussions"><img src="https://img.shields.io/github/discussions/esengine/reasonix.svg?style=flat-square&color=58a6ff&labelColor=161b22&logo=github&logoColor=white" alt="Discussions"/></a>
|
||||
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
|
||||
@@ -80,35 +88,14 @@ npx reasonix code # 首次运行粘贴 DeepSeek API Key,之后会记住
|
||||
|
||||
### QQ 通道
|
||||
|
||||
Reasonix 可以把 QQ 挂到现有的 `chat` / `code` 会话上,作为远程通信通道使用。它不是一个独立的新模式。
|
||||
Reasonix 可以把现有的 `chat`、`code` 或桌面端会话延伸到 QQ 上,作为远程通道使用;它扩展的是当前会话,不是独立的新运行模式。
|
||||
|
||||
先启动一个会话:
|
||||
- CLI:先启动会话,再执行 `/qq connect`
|
||||
- 桌面端:打开 `设置 -> 通用 -> QQ通道`
|
||||
|
||||
~~~bash
|
||||
reasonix code
|
||||
# 或
|
||||
reasonix chat
|
||||
~~~
|
||||
连接成功后,QQ 消息可以进入当前会话,助手回复会回到 QQ,后续确认和跟进交互也可以继续在 QQ 上完成。
|
||||
|
||||
然后在会话里连接 QQ:
|
||||
|
||||
~~~text
|
||||
/qq connect
|
||||
~~~
|
||||
|
||||
第一次使用时,Reasonix 会直接在当前 TUI 里引导输入 QQ 的 `App ID` 和 `App Secret`;之后再次执行 `/qq connect` 会直接复用已保存凭据。
|
||||
|
||||
可用命令:
|
||||
|
||||
- `/qq connect`
|
||||
- `/qq status`
|
||||
- `/qq disconnect`
|
||||
|
||||
启用后,后续的 `chat` / `code` 会话会自动启动 QQ 通道。斜杠命令、确认提示,以及后续助手回复都可以通过 QQ 继续完成,不需要回到终端交互。
|
||||
|
||||
桌面端也可以在 `Settings -> General -> QQ Channel` 里配置同一条通道。
|
||||
|
||||
详细说明见 [QQ 连接指南](./docs/qq-connect.zh-CN.md)。
|
||||
完整配置、桌面端快速上手与排障说明见:[QQ 连接指南](./docs/qq-connect.zh-CN.md)。
|
||||
|
||||
<details>
|
||||
<summary><strong>切换工作区 · chat vs. code · 写第一个 Skill</strong></summary>
|
||||
@@ -156,7 +143,7 @@ npx reasonix code --dir /path/to/project
|
||||
| [Memory](https://esengine.github.io/DeepSeek-Reasonix/configuration.html?lang=zh#memory) | 用户私有的知识,钉进前缀。`user` / `feedback` / `project` / `reference` 四类。 |
|
||||
| [Hooks](https://esengine.github.io/DeepSeek-Reasonix/configuration.html?lang=zh#hooks) | 生命周期事件触发的 shell 命令。`PreToolUse`(拦截)· `PostToolUse` · `UserPromptSubmit` · `Stop`。 |
|
||||
| [权限](https://esengine.github.io/DeepSeek-Reasonix/configuration.html?lang=zh#permissions) | 按工作区的 shell 白名单,精确前缀匹配。 |
|
||||
| [Web 搜索](https://esengine.github.io/DeepSeek-Reasonix/configuration.html?lang=zh#search) | 默认 Mojeek;用 `/search-engine` 可切到自托管的 SearXNG 或 Metaso。 |
|
||||
| [Web 搜索](https://esengine.github.io/DeepSeek-Reasonix/configuration.html?lang=zh#search) | 默认 Bing;可用 `/search-engine` 切到百度 AI Search、自托管 SearXNG、Metaso、Tavily、Perplexity、Exa、Brave 或 Ollama。 |
|
||||
| [语义索引](https://esengine.github.io/DeepSeek-Reasonix/configuration.html?lang=zh#index) | `reasonix index` —— 本地 Ollama,或任何 OpenAI 兼容的 embedding 接口。 |
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/** Evaluation harness — runs scenarios and collects results. */
|
||||
|
||||
import type { EvalReport, EvalResult, EvalScenario } from "./types.js";
|
||||
|
||||
export async function runScenarios(
|
||||
scenarios: EvalScenario[],
|
||||
opts: {
|
||||
onResult?: (r: EvalResult) => void;
|
||||
} = {},
|
||||
): Promise<EvalReport> {
|
||||
const results: EvalResult[] = [];
|
||||
for (const sc of scenarios) {
|
||||
const t0 = Date.now();
|
||||
let result: EvalResult;
|
||||
try {
|
||||
result = await sc.run();
|
||||
result.durationMs = Date.now() - t0;
|
||||
} catch (err) {
|
||||
result = {
|
||||
scenarioId: sc.id,
|
||||
pass: false,
|
||||
durationMs: Date.now() - t0,
|
||||
details: `UNEXPECTED ERROR: ${(err as Error).message}`,
|
||||
};
|
||||
}
|
||||
results.push(result);
|
||||
opts.onResult?.(result);
|
||||
}
|
||||
|
||||
// Dynamic import avoids bundling VERSION into the harness module.
|
||||
const { VERSION } = await import("../../src/version.js");
|
||||
return {
|
||||
meta: {
|
||||
date: new Date().toISOString(),
|
||||
reasonixVersion: VERSION,
|
||||
},
|
||||
results,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/** Render behavior-stability results.json → markdown report. */
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type { EvalReport } from "./types.js";
|
||||
|
||||
interface CliArgs {
|
||||
input: string;
|
||||
outPath: string;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
const out: CliArgs = { input: "", outPath: "benchmarks/behavior-stability/report.md" };
|
||||
const positional: string[] = [];
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === "--out") out.outPath = argv[++i] ?? out.outPath;
|
||||
else if (a && !a.startsWith("--")) positional.push(a);
|
||||
}
|
||||
out.input = positional[0] ?? "";
|
||||
if (!out.input) {
|
||||
throw new Error(
|
||||
"usage: npx tsx benchmarks/behavior-stability/report.ts <results.json> [--out report.md]",
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderHeader(report: EvalReport): string {
|
||||
return `# Reasonix Behavior-Stability Evaluation
|
||||
|
||||
**Date:** ${report.meta.date}
|
||||
**Version:** ${report.meta.reasonixVersion}
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSummary(report: EvalReport): string {
|
||||
const passed = report.results.filter((r) => r.pass).length;
|
||||
const total = report.results.length;
|
||||
const pct = total > 0 ? ((passed / total) * 100).toFixed(0) : "0";
|
||||
|
||||
const rows = report.results.map((r) => {
|
||||
const icon = r.pass ? "✅" : "❌";
|
||||
return `| ${icon} | \`${r.scenarioId}\` | ${r.durationMs}ms | ${r.details} |`;
|
||||
});
|
||||
|
||||
return `## Summary
|
||||
|
||||
**${passed}/${total} passed (${pct}%)**
|
||||
|
||||
| status | scenario | time | details |
|
||||
|---|---:|---:|:---|
|
||||
${rows.join("\n")}
|
||||
`;
|
||||
}
|
||||
|
||||
function render(report: EvalReport): string {
|
||||
return [renderHeader(report), renderSummary(report)].join("\n\n");
|
||||
}
|
||||
|
||||
function isMain(): boolean {
|
||||
const entry = process.argv[1];
|
||||
if (!entry) return false;
|
||||
return import.meta.url === pathToFileURL(entry).href;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const raw = readFileSync(args.input, "utf8");
|
||||
const report = JSON.parse(raw) as EvalReport;
|
||||
const md = render(report);
|
||||
writeFileSync(args.outPath, md, "utf8");
|
||||
console.log(`Wrote ${args.outPath} (${report.results.length} results)`);
|
||||
}
|
||||
|
||||
if (isMain()) {
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/** CLI runner for behavior-stability evaluation. */
|
||||
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { loadDotenv } from "../../src/index.js";
|
||||
import { ALL_SCENARIOS, API_SCENARIOS, LOCAL_SCENARIOS } from "./scenarios.js";
|
||||
import { runScenarios } from "./harness.js";
|
||||
import type { EvalScenario } from "./types.js";
|
||||
|
||||
loadDotenv();
|
||||
|
||||
interface CliArgs {
|
||||
local: boolean;
|
||||
api: boolean;
|
||||
outPath: string | null;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
const out: CliArgs = { local: false, api: false, outPath: null };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === "--local") out.local = true;
|
||||
else if (a === "--api") out.api = true;
|
||||
else if (a === "--out") out.outPath = argv[++i] ?? null;
|
||||
}
|
||||
// Default to local-only when neither flag is provided.
|
||||
if (!out.local && !out.api) out.local = true;
|
||||
return out;
|
||||
}
|
||||
|
||||
function pickScenarios(args: CliArgs): EvalScenario[] {
|
||||
const scenarios: EvalScenario[] = [];
|
||||
if (args.local) scenarios.push(...LOCAL_SCENARIOS);
|
||||
if (args.api) scenarios.push(...API_SCENARIOS);
|
||||
return scenarios;
|
||||
}
|
||||
|
||||
function isMain(): boolean {
|
||||
const entry = process.argv[1];
|
||||
if (!entry) return false;
|
||||
return import.meta.url === pathToFileURL(entry).href;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const scenarios = pickScenarios(args);
|
||||
|
||||
if (scenarios.length === 0) {
|
||||
console.log("No scenarios selected. Use --local, --api, or both.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const categories = new Set(scenarios.map((s) => s.category));
|
||||
console.log(`Running ${scenarios.length} scenario(s) [${[...categories].join(", ")}] …\n`);
|
||||
|
||||
const report = await runScenarios(scenarios, {
|
||||
onResult: (r) => {
|
||||
const icon = r.pass ? "✅" : "❌";
|
||||
console.log(`${icon} ${r.scenarioId} (${r.durationMs}ms)`);
|
||||
console.log(` ${r.details}\n`);
|
||||
},
|
||||
});
|
||||
|
||||
const passed = report.results.filter((r) => r.pass).length;
|
||||
const total = report.results.length;
|
||||
console.log(`────────────────────────────────────────`);
|
||||
console.log(`Results: ${passed}/${total} passed`);
|
||||
console.log(`Version: ${report.meta.reasonixVersion}`);
|
||||
console.log(`Date: ${report.meta.date}`);
|
||||
|
||||
if (args.outPath) {
|
||||
const dir = dirname(args.outPath);
|
||||
if (dir !== ".") mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(args.outPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
||||
console.log(`Wrote ${args.outPath}`);
|
||||
}
|
||||
|
||||
if (passed < total) process.exit(1);
|
||||
}
|
||||
|
||||
if (isMain()) {
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/** Behavior-stability scenario for PR #1462 — constraint persistence across context folds. */
|
||||
|
||||
import { AppendOnlyLog, SessionStats } from "../../src/index.js";
|
||||
import { ContextManager } from "../../src/context-manager.js";
|
||||
import { Usage } from "../../src/client.js";
|
||||
import type { EvalResult, EvalScenario } from "./types.js";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function withTiming(fn: () => Promise<EvalResult>): Promise<EvalResult> {
|
||||
const t0 = Date.now();
|
||||
return fn().then((r) => ({ ...r, durationMs: Date.now() - t0 }));
|
||||
}
|
||||
|
||||
function pass(id: string, details: string): EvalResult {
|
||||
return { scenarioId: id, pass: true, durationMs: 0, details };
|
||||
}
|
||||
|
||||
function fail(id: string, details: string): EvalResult {
|
||||
return { scenarioId: id, pass: false, durationMs: 0, details };
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Mock client — returns a fixed summary for fold testing */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
class MockDeepSeekClient {
|
||||
private _replyContent: string;
|
||||
constructor(replyContent: string) {
|
||||
this._replyContent = replyContent;
|
||||
}
|
||||
async chat(): Promise<{
|
||||
content: string;
|
||||
reasoningContent: string;
|
||||
usage: Usage;
|
||||
}> {
|
||||
return {
|
||||
content: this._replyContent,
|
||||
reasoningContent: "",
|
||||
usage: new Usage(100, 50, 150, 0, 100),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* #1462 — Constraint persistence across folds (local) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const constraintPersistence: EvalScenario = {
|
||||
id: "constraint-persistence",
|
||||
name: "Context fold preserves pinned constraints from system prompt",
|
||||
category: "context",
|
||||
requiresApi: false,
|
||||
run: () =>
|
||||
withTiming(async () => {
|
||||
const systemPrompt = `You are a coding assistant.
|
||||
|
||||
# HIGH PRIORITY constraints
|
||||
|
||||
- DO NOT use npm install without asking.
|
||||
- Never delete files without confirmation.
|
||||
|
||||
# User memory
|
||||
|
||||
The user prefers TypeScript over JavaScript.
|
||||
|
||||
# Project memory
|
||||
|
||||
This project uses pnpm instead of npm.`;
|
||||
|
||||
const log = new AppendOnlyLog();
|
||||
// Fill log with synthetic long history to trigger a fold boundary > 0.
|
||||
for (let i = 0; i < 40; i++) {
|
||||
log.append({
|
||||
role: "user",
|
||||
content: `Turn ${i}: Please review the codebase and tell me what you found.`,
|
||||
});
|
||||
log.append({
|
||||
role: "assistant",
|
||||
content: `Turn ${i} summary: I reviewed the codebase and found several issues including unused imports, missing type annotations, and inconsistent formatting.`,
|
||||
});
|
||||
}
|
||||
|
||||
const client = new MockDeepSeekClient("Summary of all prior turns.");
|
||||
const stats = new SessionStats();
|
||||
const ctx = new ContextManager({
|
||||
client: client as any,
|
||||
log,
|
||||
stats,
|
||||
sessionName: null,
|
||||
getAbortSignal: () => new AbortController().signal,
|
||||
getCurrentTurn: () => 1,
|
||||
getSystemPrompt: () => systemPrompt,
|
||||
});
|
||||
|
||||
// Use a tiny tail budget so almost everything becomes head and gets summarized.
|
||||
const result = await ctx.fold("deepseek-v4-flash", { keepRecentTokens: 200 });
|
||||
|
||||
if (!result.folded) {
|
||||
return fail(
|
||||
"constraint-persistence",
|
||||
`Fold did not happen (before=${result.beforeMessages}, after=${result.afterMessages}).`,
|
||||
);
|
||||
}
|
||||
|
||||
const foldedSystem = log.toMessages()[0];
|
||||
if (foldedSystem?.role !== "assistant") {
|
||||
return fail(
|
||||
"constraint-persistence",
|
||||
`Expected first folded message to be assistant summary, got ${foldedSystem?.role}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const content = foldedSystem.content as string;
|
||||
const checks = [
|
||||
{ text: "DO NOT use npm install without asking", label: "HIGH PRIORITY constraint" },
|
||||
{ text: "Never delete files without confirmation", label: "HIGH PRIORITY constraint 2" },
|
||||
{ text: "TypeScript over JavaScript", label: "User memory" },
|
||||
{ text: "pnpm instead of npm", label: "Project memory" },
|
||||
];
|
||||
|
||||
const missing = checks.filter((c) => !content.includes(c.text));
|
||||
if (missing.length > 0) {
|
||||
return fail(
|
||||
"constraint-persistence",
|
||||
`Folded summary missing ${missing.length} constraint(s): ${missing.map((m) => m.label).join(", ")}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return pass(
|
||||
"constraint-persistence",
|
||||
`Fold reduced ${result.beforeMessages} → ${result.afterMessages} messages and preserved all ${checks.length} pinned constraints.`,
|
||||
);
|
||||
}),
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Exports */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const LOCAL_SCENARIOS: EvalScenario[] = [constraintPersistence];
|
||||
|
||||
export const API_SCENARIOS: EvalScenario[] = [];
|
||||
|
||||
export const ALL_SCENARIOS: EvalScenario[] = [...LOCAL_SCENARIOS, ...API_SCENARIOS];
|
||||
@@ -0,0 +1,26 @@
|
||||
/** Behavior-stability evaluation types — lightweight, local-first regression suite. */
|
||||
|
||||
export interface EvalScenario {
|
||||
id: string;
|
||||
name: string;
|
||||
category: "shell" | "context" | "integrity";
|
||||
/** True when the scenario calls the live LLM API (costs money). */
|
||||
requiresApi: boolean;
|
||||
run: () => Promise<EvalResult>;
|
||||
}
|
||||
|
||||
export interface EvalResult {
|
||||
scenarioId: string;
|
||||
pass: boolean;
|
||||
durationMs: number;
|
||||
details: string;
|
||||
}
|
||||
|
||||
export interface EvalReport {
|
||||
meta: {
|
||||
date: string;
|
||||
reasonixVersion: string;
|
||||
model?: string;
|
||||
};
|
||||
results: EvalResult[];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
runs/
|
||||
@@ -0,0 +1,165 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolvePreset } from "../../src/cli/ui/presets.js";
|
||||
import { DeepSeekClient } from "../../src/client.js";
|
||||
import { codeSystemPrompt } from "../../src/code/prompt.js";
|
||||
import { buildCodeToolset } from "../../src/code/setup.js";
|
||||
import { loadApiKey, loadBaseUrl, loadPreset } from "../../src/config.js";
|
||||
import { loadDotenv } from "../../src/env.js";
|
||||
import { CacheFirstLoop } from "../../src/loop.js";
|
||||
import { ImmutablePrefix } from "../../src/memory/runtime.js";
|
||||
import { DEEPSEEK_CONTEXT_TOKENS } from "../../src/telemetry/stats.js";
|
||||
|
||||
interface DriverOptions {
|
||||
taskFile: string;
|
||||
transcriptOut: string;
|
||||
budgetUsd: number;
|
||||
rootDir: string;
|
||||
/** Override DEEPSEEK_CONTEXT_TOKENS to force fold triggers on short tasks. */
|
||||
fakeCtxMax: number | null;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): DriverOptions {
|
||||
let taskFile = "";
|
||||
let transcriptOut = "";
|
||||
let budgetUsd = 2.0;
|
||||
let rootDir = process.cwd();
|
||||
let fakeCtxMax: number | null = null;
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i]!;
|
||||
if (a === "--task-file") taskFile = argv[++i]!;
|
||||
else if (a === "--transcript-out") transcriptOut = argv[++i]!;
|
||||
else if (a === "--budget") budgetUsd = Number(argv[++i]);
|
||||
else if (a === "--root") rootDir = argv[++i]!;
|
||||
else if (a === "--fake-ctxmax") fakeCtxMax = Number(argv[++i]);
|
||||
}
|
||||
if (!taskFile || !transcriptOut) {
|
||||
console.error(
|
||||
"usage: driver.ts --task-file <p> --transcript-out <p> [--budget 2] [--root <dir>] [--fake-ctxmax N]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
return { taskFile, transcriptOut, budgetUsd, rootDir, fakeCtxMax };
|
||||
}
|
||||
|
||||
interface TurnRecord {
|
||||
turn: number;
|
||||
role: string;
|
||||
prompt_tokens?: number;
|
||||
cache_hit_tokens?: number;
|
||||
cache_miss_tokens?: number;
|
||||
cost_usd?: number;
|
||||
tool?: string;
|
||||
content_preview?: string;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
loadDotenv();
|
||||
const opts = parseArgs(process.argv.slice(2));
|
||||
const key = loadApiKey();
|
||||
if (!key) {
|
||||
console.error("DEEPSEEK_API_KEY missing — run `reasonix setup` first");
|
||||
process.exit(1);
|
||||
}
|
||||
process.env.DEEPSEEK_API_KEY = key;
|
||||
|
||||
const presetSettings = resolvePreset(loadPreset());
|
||||
const model = presetSettings.model;
|
||||
if (opts.fakeCtxMax !== null && opts.fakeCtxMax > 0) {
|
||||
for (const k of Object.keys(DEEPSEEK_CONTEXT_TOKENS)) {
|
||||
DEEPSEEK_CONTEXT_TOKENS[k] = opts.fakeCtxMax;
|
||||
}
|
||||
console.log(`ctxMax override (ALL deepseek models): -> ${opts.fakeCtxMax}`);
|
||||
}
|
||||
console.log(`model: ${model}`);
|
||||
console.log(`root: ${opts.rootDir}`);
|
||||
console.log(`budget: $${opts.budgetUsd}`);
|
||||
|
||||
const task = readFileSync(opts.taskFile, "utf8").trim();
|
||||
console.log(`task: ${task.length} chars`);
|
||||
|
||||
const toolset = await buildCodeToolset({ rootDir: opts.rootDir });
|
||||
console.log(`tools: ${toolset.tools.specs().length} registered`);
|
||||
|
||||
const client = new DeepSeekClient({ baseUrl: loadBaseUrl() });
|
||||
const prefix = new ImmutablePrefix({
|
||||
system: codeSystemPrompt(opts.rootDir),
|
||||
toolSpecs: toolset.tools.specs(),
|
||||
});
|
||||
const loop = new CacheFirstLoop({
|
||||
client,
|
||||
prefix,
|
||||
tools: toolset.tools,
|
||||
model,
|
||||
budgetUsd: opts.budgetUsd,
|
||||
});
|
||||
|
||||
const records: TurnRecord[] = [];
|
||||
const t0 = Date.now();
|
||||
let lastTurn = 0;
|
||||
try {
|
||||
for await (const ev of loop.step(task)) {
|
||||
if (ev.role === "assistant_final" && ev.stats?.usage) {
|
||||
const u = ev.stats.usage;
|
||||
const rec: TurnRecord = {
|
||||
turn: ev.turn,
|
||||
role: "assistant_final",
|
||||
prompt_tokens: u.promptTokens,
|
||||
cache_hit_tokens: u.promptCacheHitTokens,
|
||||
cache_miss_tokens: u.promptCacheMissTokens,
|
||||
cost_usd: ev.stats.cost,
|
||||
content_preview: (ev.content ?? "").slice(0, 200),
|
||||
};
|
||||
records.push(rec);
|
||||
lastTurn = ev.turn;
|
||||
const ratio = u.promptCacheHitTokens / (u.promptTokens || 1);
|
||||
process.stdout.write(
|
||||
` turn ${ev.turn}: pt=${u.promptTokens} hit=${(ratio * 100).toFixed(1)}% cost=$${ev.stats.cost.toFixed(4)}\n`,
|
||||
);
|
||||
} else if (ev.role === "tool" && ev.toolName) {
|
||||
records.push({
|
||||
turn: ev.turn,
|
||||
role: "tool",
|
||||
tool: ev.toolName,
|
||||
content_preview: (ev.content ?? "").slice(0, 200),
|
||||
});
|
||||
} else if (ev.role === "warning" || ev.role === "status") {
|
||||
const lc = (ev.content ?? "").toLowerCase();
|
||||
const isFold = lc.includes("fold") || lc.includes("compact") || lc.includes("summar");
|
||||
records.push({
|
||||
turn: ev.turn,
|
||||
role: isFold ? "fold_event" : `${ev.role}_event`,
|
||||
content_preview: ev.content,
|
||||
});
|
||||
if (isFold) {
|
||||
process.stdout.write(
|
||||
` ⟳ ${ev.role.toUpperCase()} at turn ${ev.turn}: ${ev.content.slice(0, 120)}\n`,
|
||||
);
|
||||
}
|
||||
} else if (ev.role === "error") {
|
||||
records.push({ turn: ev.turn, role: "error", content_preview: ev.error });
|
||||
process.stdout.write(` ✗ error turn ${ev.turn}: ${ev.error}\n`);
|
||||
} else if (ev.role === "done") {
|
||||
process.stdout.write(` ✓ done (turn ${ev.turn})\n`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
process.stdout.write(` ✗ loop threw: ${(err as Error).message}\n`);
|
||||
}
|
||||
|
||||
const elapsed = Math.round((Date.now() - t0) / 1000);
|
||||
const s = loop.stats.summary();
|
||||
console.log(
|
||||
`\nDONE turns=${lastTurn} elapsed=${elapsed}s cache=${(s.cacheHitRatio * 100).toFixed(1)}% cost=$${s.totalCostUsd.toFixed(4)}`,
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
opts.transcriptOut,
|
||||
`${JSON.stringify({ _meta: { model, task_length: task.length, elapsed_s: elapsed, summary: s } })}\n${records.map((r) => JSON.stringify(r)).join("\n")}\n`,
|
||||
);
|
||||
console.log(`transcript: ${opts.transcriptOut}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("driver crashed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
interface RunSpec {
|
||||
name: string;
|
||||
foldThreshold: number;
|
||||
aggressiveThreshold: number;
|
||||
}
|
||||
|
||||
interface RunSummary {
|
||||
name: string;
|
||||
repeat: number;
|
||||
turns: number;
|
||||
cacheHitRatio: number;
|
||||
costUsd: number;
|
||||
totalInputTokens: number;
|
||||
cacheHitTokens: number;
|
||||
cacheMissTokens: number;
|
||||
foldCount: number;
|
||||
exitCode: number;
|
||||
}
|
||||
|
||||
const RUNS: RunSpec[] = [
|
||||
{ name: "no-fold", foldThreshold: 0.99, aggressiveThreshold: 0.99 },
|
||||
{ name: "current_50_70", foldThreshold: 0.5, aggressiveThreshold: 0.7 },
|
||||
{ name: "very_late_75_90", foldThreshold: 0.75, aggressiveThreshold: 0.9 },
|
||||
{ name: "aggressive_90_95", foldThreshold: 0.9, aggressiveThreshold: 0.95 },
|
||||
];
|
||||
|
||||
function parseArgs(argv: string[]): {
|
||||
taskFile: string;
|
||||
budget: number;
|
||||
outDir: string;
|
||||
only: string | null;
|
||||
fakeCtxMax: number | null;
|
||||
repeats: number;
|
||||
} {
|
||||
let taskFile = "";
|
||||
let budget = 2.0;
|
||||
let outDir = "";
|
||||
let only: string | null = null;
|
||||
let fakeCtxMax: number | null = null;
|
||||
let repeats = 1;
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i]!;
|
||||
if (a === "--task-file") taskFile = argv[++i]!;
|
||||
else if (a === "--budget") budget = Number(argv[++i]);
|
||||
else if (a === "--out-dir") outDir = argv[++i]!;
|
||||
else if (a === "--only") only = argv[++i]!;
|
||||
else if (a === "--fake-ctxmax") fakeCtxMax = Number(argv[++i]);
|
||||
else if (a === "--repeats") repeats = Number(argv[++i]);
|
||||
}
|
||||
if (!taskFile) {
|
||||
console.error(
|
||||
"usage: tsx e2e.ts --task-file <p> [--budget N] [--out-dir <p>] [--only <n>] [--fake-ctxmax N] [--repeats N]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!outDir) {
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||||
outDir = `benchmarks/compression-eval/runs/${ts}`;
|
||||
}
|
||||
return { taskFile, budget, outDir, only, fakeCtxMax, repeats };
|
||||
}
|
||||
|
||||
function loadTranscriptSummary(path: string): Partial<RunSummary> {
|
||||
let turns = 0;
|
||||
let inputTokens = 0;
|
||||
let cacheHit = 0;
|
||||
let cacheMiss = 0;
|
||||
let costUsd = 0;
|
||||
let foldCount = 0;
|
||||
const promptSeq: number[] = [];
|
||||
try {
|
||||
const lines = readFileSync(path, "utf8").split("\n");
|
||||
for (const ln of lines) {
|
||||
const t = ln.trim();
|
||||
if (!t) continue;
|
||||
let rec: {
|
||||
role?: string;
|
||||
prompt_tokens?: number;
|
||||
cache_hit_tokens?: number;
|
||||
cache_miss_tokens?: number;
|
||||
cost_usd?: number;
|
||||
};
|
||||
try {
|
||||
rec = JSON.parse(t);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (rec.role === "assistant_final") {
|
||||
turns++;
|
||||
const pt = rec.prompt_tokens ?? 0;
|
||||
inputTokens += pt;
|
||||
cacheHit += rec.cache_hit_tokens ?? 0;
|
||||
cacheMiss += rec.cache_miss_tokens ?? 0;
|
||||
costUsd += rec.cost_usd ?? 0;
|
||||
if (pt > 0) promptSeq.push(pt);
|
||||
} else if (rec.role === "fold_event") {
|
||||
foldCount++;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* file missing — caller will see exitCode and zeros */
|
||||
}
|
||||
if (foldCount === 0) {
|
||||
for (let i = 1; i < promptSeq.length; i++) {
|
||||
if (promptSeq[i]! < promptSeq[i - 1]! * 0.6) foldCount++;
|
||||
}
|
||||
}
|
||||
return {
|
||||
turns,
|
||||
totalInputTokens: inputTokens,
|
||||
cacheHitTokens: cacheHit,
|
||||
cacheMissTokens: cacheMiss,
|
||||
cacheHitRatio: inputTokens > 0 ? cacheHit / inputTokens : 0,
|
||||
costUsd,
|
||||
foldCount,
|
||||
};
|
||||
}
|
||||
|
||||
function runOne(
|
||||
spec: RunSpec,
|
||||
task: string,
|
||||
budget: number,
|
||||
outDir: string,
|
||||
fakeCtxMax: number | null,
|
||||
repeat: number,
|
||||
): RunSummary {
|
||||
const runName = repeat > 1 ? `${spec.name}_r${repeat}` : spec.name;
|
||||
const runDir = join(outDir, runName);
|
||||
mkdirSync(runDir, { recursive: true });
|
||||
const transcript = join(runDir, "transcript.jsonl");
|
||||
const logPath = join(runDir, "stdout.log");
|
||||
|
||||
console.log(
|
||||
`\n>>> [${spec.name}] fold=${spec.foldThreshold} agg=${spec.aggressiveThreshold}`,
|
||||
);
|
||||
const tsxCli = join("node_modules", "tsx", "dist", "cli.mjs");
|
||||
const taskFilePath = join(runDir, "task.txt");
|
||||
writeFileSync(taskFilePath, task);
|
||||
const t0 = Date.now();
|
||||
const res = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
tsxCli,
|
||||
"benchmarks/compression-eval/driver.ts",
|
||||
"--task-file",
|
||||
taskFilePath,
|
||||
"--transcript-out",
|
||||
transcript,
|
||||
"--budget",
|
||||
String(budget),
|
||||
...(fakeCtxMax ? ["--fake-ctxmax", String(fakeCtxMax)] : []),
|
||||
],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
REASONIX_FOLD_THRESHOLD: String(spec.foldThreshold),
|
||||
REASONIX_FOLD_AGGRESSIVE_THRESHOLD: String(spec.aggressiveThreshold),
|
||||
},
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
shell: false,
|
||||
maxBuffer: 1024 * 1024 * 128,
|
||||
},
|
||||
);
|
||||
const exitCode = res.status ?? -1;
|
||||
const stdout = res.stdout ?? "";
|
||||
const stderr = res.stderr ?? "";
|
||||
const elapsedS = Math.round((Date.now() - t0) / 1000);
|
||||
writeFileSync(logPath, `${stdout}\n--- stderr ---\n${stderr}`);
|
||||
console.log(` exit=${exitCode} elapsed=${elapsedS}s log=${logPath}`);
|
||||
|
||||
const parsed = loadTranscriptSummary(transcript);
|
||||
return {
|
||||
name: spec.name,
|
||||
repeat,
|
||||
turns: parsed.turns ?? 0,
|
||||
cacheHitRatio: parsed.cacheHitRatio ?? 0,
|
||||
costUsd: parsed.costUsd ?? 0,
|
||||
totalInputTokens: parsed.totalInputTokens ?? 0,
|
||||
cacheHitTokens: parsed.cacheHitTokens ?? 0,
|
||||
cacheMissTokens: parsed.cacheMissTokens ?? 0,
|
||||
foldCount: parsed.foldCount ?? 0,
|
||||
exitCode,
|
||||
};
|
||||
}
|
||||
|
||||
function pct(n: number): string {
|
||||
return `${(n * 100).toFixed(1)}%`;
|
||||
}
|
||||
function fmtTok(n: number): string {
|
||||
if (n >= 1e6) return `${(n / 1e6).toFixed(2)}M`;
|
||||
if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
interface AggregateRow {
|
||||
name: string;
|
||||
n: number;
|
||||
meanCost: number;
|
||||
stdCost: number;
|
||||
meanMiss: number;
|
||||
stdMiss: number;
|
||||
meanHitPct: number;
|
||||
stdHitPct: number;
|
||||
meanTurns: number;
|
||||
meanFolds: number;
|
||||
}
|
||||
|
||||
function meanStd(xs: number[]): [number, number] {
|
||||
if (xs.length === 0) return [0, 0];
|
||||
const m = xs.reduce((a, b) => a + b, 0) / xs.length;
|
||||
const v = xs.reduce((a, b) => a + (b - m) ** 2, 0) / xs.length;
|
||||
return [m, Math.sqrt(v)];
|
||||
}
|
||||
|
||||
function aggregateByConfig(rows: RunSummary[]): AggregateRow[] {
|
||||
const groups = new Map<string, RunSummary[]>();
|
||||
for (const r of rows) {
|
||||
if (!groups.has(r.name)) groups.set(r.name, []);
|
||||
groups.get(r.name)!.push(r);
|
||||
}
|
||||
const result: AggregateRow[] = [];
|
||||
for (const [name, group] of groups) {
|
||||
const [meanCost, stdCost] = meanStd(group.map((r) => r.costUsd));
|
||||
const [meanMiss, stdMiss] = meanStd(group.map((r) => r.cacheMissTokens));
|
||||
const [meanHitPct, stdHitPct] = meanStd(group.map((r) => r.cacheHitRatio * 100));
|
||||
const [meanTurns] = meanStd(group.map((r) => r.turns));
|
||||
const [meanFolds] = meanStd(group.map((r) => r.foldCount));
|
||||
result.push({
|
||||
name,
|
||||
n: group.length,
|
||||
meanCost,
|
||||
stdCost,
|
||||
meanMiss,
|
||||
stdMiss,
|
||||
meanHitPct,
|
||||
stdHitPct,
|
||||
meanTurns,
|
||||
meanFolds,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function reportAggregate(rows: AggregateRow[]): string {
|
||||
const cols: Array<[string, (r: AggregateRow) => string]> = [
|
||||
["config", (r) => r.name],
|
||||
["n", (r) => String(r.n)],
|
||||
["turns", (r) => r.meanTurns.toFixed(1)],
|
||||
["folds", (r) => r.meanFolds.toFixed(1)],
|
||||
["hit%", (r) => `${r.meanHitPct.toFixed(1)}±${r.stdHitPct.toFixed(1)}`],
|
||||
["miss", (r) => `${fmtTok(r.meanMiss)}±${fmtTok(r.stdMiss)}`],
|
||||
["cost$", (r) => `${r.meanCost.toFixed(4)}±${r.stdCost.toFixed(4)}`],
|
||||
];
|
||||
const widths = cols.map(([h, f]) => Math.max(h.length, ...rows.map((r) => f(r).length)));
|
||||
const head = cols.map(([h], i) => h.padEnd(widths[i]!)).join("│ ");
|
||||
const sep = "─".repeat(widths.reduce((a, b) => a + b + 2, 0));
|
||||
const body = rows
|
||||
.map((r) => cols.map(([_, f], i) => f(r).padEnd(widths[i]!)).join("│ "))
|
||||
.join("\n");
|
||||
return `${head}\n${sep}\n${body}`;
|
||||
}
|
||||
|
||||
function reportTable(rows: RunSummary[]): string {
|
||||
const cols: Array<[string, (r: RunSummary) => string]> = [
|
||||
["config", (r) => r.name],
|
||||
["turns", (r) => String(r.turns)],
|
||||
["input", (r) => fmtTok(r.totalInputTokens)],
|
||||
["hit", (r) => fmtTok(r.cacheHitTokens)],
|
||||
["hit%", (r) => pct(r.cacheHitRatio)],
|
||||
["miss", (r) => fmtTok(r.cacheMissTokens)],
|
||||
["folds", (r) => String(r.foldCount)],
|
||||
["cost", (r) => `$${r.costUsd.toFixed(4)}`],
|
||||
["exit", (r) => String(r.exitCode)],
|
||||
];
|
||||
const widths = cols.map(([h, f]) => Math.max(h.length, ...rows.map((r) => f(r).length)));
|
||||
const head = cols.map(([h], i) => h.padEnd(widths[i]!)).join("│ ");
|
||||
const sep = "─".repeat(widths.reduce((a, b) => a + b + 2, 0));
|
||||
const body = rows
|
||||
.map((r) => cols.map(([_, f], i) => f(r).padEnd(widths[i]!)).join("│ "))
|
||||
.join("\n");
|
||||
return `${head}\n${sep}\n${body}`;
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const { taskFile, budget, outDir, only, fakeCtxMax, repeats } = parseArgs(process.argv.slice(2));
|
||||
const task = readFileSync(taskFile, "utf8").trim();
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
const specs = only ? RUNS.filter((r) => r.name === only) : RUNS;
|
||||
if (specs.length === 0) {
|
||||
console.error(`no config matches --only=${only}; available: ${RUNS.map((r) => r.name).join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`task: ${taskFile} (${task.length} chars)`);
|
||||
console.log(`out: ${outDir}`);
|
||||
console.log(`budget per run: $${budget}`);
|
||||
console.log(`configs: ${specs.map((s) => s.name).join(", ")}`);
|
||||
console.log(`repeats: ${repeats}`);
|
||||
if (fakeCtxMax) console.log(`fake ctxMax: ${fakeCtxMax}`);
|
||||
|
||||
const summaries: RunSummary[] = [];
|
||||
for (let r = 1; r <= repeats; r++) {
|
||||
for (const spec of specs) {
|
||||
summaries.push(runOne(spec, task, budget, outDir, fakeCtxMax, r));
|
||||
}
|
||||
}
|
||||
|
||||
const table = reportTable(summaries);
|
||||
console.log(`\n=== Raw runs ===\n${table}`);
|
||||
if (repeats > 1) {
|
||||
const agg = aggregateByConfig(summaries);
|
||||
const aggTable = reportAggregate(agg);
|
||||
console.log(`\n=== Aggregate (mean ± std) ===\n${aggTable}`);
|
||||
writeFileSync(join(outDir, "aggregate.txt"), aggTable);
|
||||
}
|
||||
writeFileSync(join(outDir, "summary.json"), JSON.stringify(summaries, null, 2));
|
||||
writeFileSync(join(outDir, "summary.txt"), table);
|
||||
console.log(`\nsaved: ${join(outDir, "summary.json")}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,89 @@
|
||||
import { basename } from "node:path";
|
||||
import { loadSession, simulate, type RunResult, type ThresholdConfig } from "./simulator.js";
|
||||
|
||||
const BASELINE: ThresholdConfig = {
|
||||
ctxMax: 1_000_000,
|
||||
foldThreshold: 0.5,
|
||||
tailFraction: 0.2,
|
||||
aggressiveThreshold: 0.7,
|
||||
aggressiveTailFraction: 0.1,
|
||||
minSavingsFraction: 0.3,
|
||||
summaryRatio: 0.05,
|
||||
};
|
||||
|
||||
interface NamedConfig {
|
||||
name: string;
|
||||
config: ThresholdConfig;
|
||||
}
|
||||
|
||||
function configs(ctxMax: number): NamedConfig[] {
|
||||
const mk = (over: Partial<ThresholdConfig>): ThresholdConfig => ({ ...BASELINE, ...over, ctxMax });
|
||||
return [
|
||||
{ name: "no-fold", config: mk({ foldThreshold: 999, aggressiveThreshold: 999 }) },
|
||||
{ name: "current (50/70)", config: mk({ foldThreshold: 0.5, aggressiveThreshold: 0.7 }) },
|
||||
{ name: "late (60/80)", config: mk({ foldThreshold: 0.6, aggressiveThreshold: 0.8 }) },
|
||||
{ name: "very-late (75/90)", config: mk({ foldThreshold: 0.75, aggressiveThreshold: 0.9 }) },
|
||||
{ name: "early (35/55)", config: mk({ foldThreshold: 0.35, aggressiveThreshold: 0.55 }) },
|
||||
];
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): { sessions: string[]; ctxMax: number } {
|
||||
const sessions: string[] = [];
|
||||
let ctxMax = 1_000_000;
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i]!;
|
||||
if (a === "--ctx-max") ctxMax = Number(argv[++i]);
|
||||
else if (a === "--session") sessions.push(argv[++i]!);
|
||||
else if (!a.startsWith("--")) sessions.push(a);
|
||||
}
|
||||
return { sessions, ctxMax };
|
||||
}
|
||||
|
||||
function fmt(n: number): string {
|
||||
if (n >= 1e9) return `${(n / 1e9).toFixed(2)}G`;
|
||||
if (n >= 1e6) return `${(n / 1e6).toFixed(2)}M`;
|
||||
if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function pct(n: number): string {
|
||||
return `${(n * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function report(label: string, rows: Array<{ name: string; result: RunResult }>): void {
|
||||
console.log(`\n=== ${label} ===`);
|
||||
const w = [22, 10, 10, 8, 10, 6, 10];
|
||||
const headers = ["config", "input", "cache_hit", "hit%", "miss", "folds", "peak"];
|
||||
console.log(headers.map((h, i) => h.padEnd(w[i]!)).join("│ "));
|
||||
console.log("─".repeat(w.reduce((a, b) => a + b + 2, 0)));
|
||||
for (const { name, result: r } of rows) {
|
||||
const peak = r.perTurn.reduce((m, t) => Math.max(m, t.ratio), 0);
|
||||
console.log(
|
||||
[
|
||||
name.padEnd(w[0]!),
|
||||
fmt(r.totalInputTokens).padEnd(w[1]!),
|
||||
fmt(r.totalCacheHitTokens).padEnd(w[2]!),
|
||||
pct(r.cacheHitRatio).padEnd(w[3]!),
|
||||
fmt(r.totalCacheMissTokens).padEnd(w[4]!),
|
||||
String(r.foldCount).padEnd(w[5]!),
|
||||
pct(peak).padEnd(w[6]!),
|
||||
].join("│ "),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const { sessions, ctxMax } = parseArgs(process.argv.slice(2));
|
||||
if (sessions.length === 0) {
|
||||
console.error("usage: tsx run.ts [--ctx-max N] <session.jsonl> [more...]");
|
||||
process.exit(1);
|
||||
}
|
||||
for (const path of sessions) {
|
||||
const msgs = loadSession(path);
|
||||
const cfgs = configs(ctxMax);
|
||||
const rows = cfgs.map(({ name, config }) => ({ name, result: simulate(msgs, config, path) }));
|
||||
report(`${basename(path)} (${msgs.length} msgs, ctxMax=${fmt(ctxMax)})`, rows);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,197 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import {
|
||||
countTokensBounded,
|
||||
estimateConversationTokens,
|
||||
estimateRequestTokens,
|
||||
} from "../../src/tokenizer.js";
|
||||
import type { ChatMessage } from "../../src/types.js";
|
||||
|
||||
export interface ThresholdConfig {
|
||||
ctxMax: number;
|
||||
foldThreshold: number;
|
||||
tailFraction: number;
|
||||
aggressiveThreshold: number;
|
||||
aggressiveTailFraction: number;
|
||||
minSavingsFraction: number;
|
||||
summaryRatio: number;
|
||||
}
|
||||
|
||||
export interface FoldEvent {
|
||||
turn: number;
|
||||
ratioBeforeFold: number;
|
||||
promptTokensBefore: number;
|
||||
headMessages: number;
|
||||
tailMessages: number;
|
||||
headTokens: number;
|
||||
summaryTokens: number;
|
||||
aggressive: boolean;
|
||||
}
|
||||
|
||||
export interface TurnRecord {
|
||||
turn: number;
|
||||
promptTokens: number;
|
||||
cacheHitTokens: number;
|
||||
cacheMissTokens: number;
|
||||
ratio: number;
|
||||
folded: boolean;
|
||||
}
|
||||
|
||||
export interface RunResult {
|
||||
config: ThresholdConfig;
|
||||
sessionPath: string;
|
||||
totalTurns: number;
|
||||
totalInputTokens: number;
|
||||
totalCacheHitTokens: number;
|
||||
totalCacheMissTokens: number;
|
||||
cacheHitRatio: number;
|
||||
foldCount: number;
|
||||
folds: FoldEvent[];
|
||||
perTurn: TurnRecord[];
|
||||
}
|
||||
|
||||
export function loadSession(path: string): ChatMessage[] {
|
||||
const lines = readFileSync(path, "utf8").split("\n");
|
||||
const out: ChatMessage[] = [];
|
||||
for (const ln of lines) {
|
||||
const t = ln.trim();
|
||||
if (!t) continue;
|
||||
try {
|
||||
out.push(JSON.parse(t) as ChatMessage);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function messageTokens(m: ChatMessage): number {
|
||||
return estimateConversationTokens([m], true);
|
||||
}
|
||||
|
||||
function messageKey(m: ChatMessage): string {
|
||||
return JSON.stringify({
|
||||
r: m.role,
|
||||
c: m.content ?? "",
|
||||
tc: m.tool_calls ?? null,
|
||||
tcid: m.tool_call_id ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
function commonPrefixTokens(curr: ChatMessage[], prev: ChatMessage[]): number {
|
||||
const n = Math.min(curr.length, prev.length);
|
||||
let tokens = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (messageKey(curr[i]!) !== messageKey(prev[i]!)) break;
|
||||
tokens += messageTokens(curr[i]!);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function findFoldBoundary(log: ChatMessage[], tailBudget: number): number {
|
||||
let cum = 0;
|
||||
let boundary = log.length;
|
||||
for (let i = log.length - 1; i >= 0; i--) {
|
||||
const t = messageTokens(log[i]!);
|
||||
if (cum + t > tailBudget) break;
|
||||
cum += t;
|
||||
if (log[i]!.role === "user") boundary = i;
|
||||
}
|
||||
return boundary;
|
||||
}
|
||||
|
||||
function synthesizeSummary(head: ChatMessage[], targetTokens: number): ChatMessage {
|
||||
const userBits: string[] = [];
|
||||
for (const m of head) {
|
||||
if (m.role === "user" && typeof m.content === "string") userBits.push(m.content);
|
||||
}
|
||||
const seed = userBits.join("\n") || "earlier turns folded";
|
||||
let text = `[CONVERSATION HISTORY SUMMARY — fold of ${head.length} messages]\n\n${seed}`;
|
||||
const currentTokens = countTokensBounded(text);
|
||||
if (currentTokens > targetTokens) {
|
||||
const ratio = targetTokens / currentTokens;
|
||||
text = text.slice(0, Math.max(80, Math.floor(text.length * ratio)));
|
||||
}
|
||||
return { role: "assistant", content: text, reasoning_content: "" };
|
||||
}
|
||||
|
||||
function turnBoundaries(messages: ChatMessage[]): number[] {
|
||||
const ends: number[] = [];
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
if (messages[i]!.role === "assistant") ends.push(i);
|
||||
}
|
||||
return ends;
|
||||
}
|
||||
|
||||
export function simulate(messages: ChatMessage[], config: ThresholdConfig, sessionPath: string): RunResult {
|
||||
const ends = turnBoundaries(messages);
|
||||
const log: ChatMessage[] = [];
|
||||
let prevSnapshot: ChatMessage[] = [];
|
||||
const perTurn: TurnRecord[] = [];
|
||||
const folds: FoldEvent[] = [];
|
||||
let totalInput = 0;
|
||||
let totalHit = 0;
|
||||
let totalMiss = 0;
|
||||
let cursor = 0;
|
||||
|
||||
for (let turn = 0; turn < ends.length; turn++) {
|
||||
const end = ends[turn]!;
|
||||
for (let i = cursor; i < end; i++) log.push(messages[i]!);
|
||||
cursor = end;
|
||||
|
||||
const promptTokens = estimateRequestTokens(log, null, true);
|
||||
const ratio = promptTokens / config.ctxMax;
|
||||
const hit = commonPrefixTokens(log, prevSnapshot);
|
||||
const miss = Math.max(0, promptTokens - hit);
|
||||
let folded = false;
|
||||
|
||||
if (ratio > config.foldThreshold) {
|
||||
const aggressive = ratio > config.aggressiveThreshold;
|
||||
const tailFrac = aggressive ? config.aggressiveTailFraction : config.tailFraction;
|
||||
const tailBudget = Math.floor(config.ctxMax * tailFrac);
|
||||
const boundary = findFoldBoundary(log, tailBudget);
|
||||
if (boundary > 0) {
|
||||
const head = log.slice(0, boundary);
|
||||
const tail = log.slice(boundary);
|
||||
const headTokens = head.reduce((a, m) => a + messageTokens(m), 0);
|
||||
const totalTokens = log.reduce((a, m) => a + messageTokens(m), 0);
|
||||
if (headTokens >= totalTokens * config.minSavingsFraction) {
|
||||
const summaryTokens = Math.max(64, Math.floor(headTokens * config.summaryRatio));
|
||||
const summary = synthesizeSummary(head, summaryTokens);
|
||||
const next = [summary, ...tail];
|
||||
log.length = 0;
|
||||
for (const m of next) log.push(m);
|
||||
folds.push({
|
||||
turn,
|
||||
ratioBeforeFold: ratio,
|
||||
promptTokensBefore: promptTokens,
|
||||
headMessages: head.length,
|
||||
tailMessages: tail.length,
|
||||
headTokens,
|
||||
summaryTokens,
|
||||
aggressive,
|
||||
});
|
||||
folded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
totalInput += promptTokens;
|
||||
totalHit += hit;
|
||||
totalMiss += miss;
|
||||
perTurn.push({ turn, promptTokens, cacheHitTokens: hit, cacheMissTokens: miss, ratio, folded });
|
||||
prevSnapshot = log.slice();
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
sessionPath,
|
||||
totalTurns: ends.length,
|
||||
totalInputTokens: totalInput,
|
||||
totalCacheHitTokens: totalHit,
|
||||
totalCacheMissTokens: totalMiss,
|
||||
cacheHitRatio: totalInput > 0 ? totalHit / totalInput : 0,
|
||||
foldCount: folds.length,
|
||||
folds,
|
||||
perTurn,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
为这个仓库的 src/ 目录写一份完整的架构总览文档。
|
||||
|
||||
要求:
|
||||
1. 用 read_file / directory_tree / grep_search 等工具,**逐个**读取 src/ 下每个子目录(cli/, mcp/, memory/, telemetry/, transcript/, tools/, ui/, i18n/, loop/ 等)的代表性文件。每个子目录至少读 3-5 个核心 .ts 文件。
|
||||
2. 不要只 grep 关键词就下结论 —— 必须打开文件读真实内容,从代码里抽出"这个模块的职责 / 它的核心数据结构 / 它对外暴露什么"。
|
||||
3. 边读边在心里搭建依赖图。
|
||||
4. 全部读完后,输出一份 markdown 报告,包含:
|
||||
- 整体架构图(mermaid 或 ASCII,模块之间的依赖关系)
|
||||
- 按模块列出:职责 / 关键文件 / 对外接口 / 内部依赖
|
||||
- 至少 8 个具体的代码引用(路径:行号),证明你真读了
|
||||
- 结尾给出"如果新人加入项目,应该按什么顺序读代码"的建议
|
||||
|
||||
这是一个真实的代码审计任务,请不要偷懒、不要瞎编、不要用通用模板。文件读得不够多就直接说"我读得不够"。
|
||||
@@ -20,22 +20,22 @@ Used with permission, anonymized.
|
||||
## Cost — using the prices Reasonix bills against (`src/telemetry/stats.ts`)
|
||||
|
||||
USD per 1M tokens — `inputCacheHit / inputCacheMiss / output`:
|
||||
- `deepseek-v4-flash` — `0.028 / 0.139 / 0.278`
|
||||
- `deepseek-v4-pro` — `0.139 / 1.667 / 3.333`
|
||||
- `deepseek-v4-flash` — `0.0028 / 0.14 / 0.28`
|
||||
- `deepseek-v4-pro` — `0.003625 / 0.435 / 0.87`
|
||||
|
||||
Assuming **v4-flash** (the project default):
|
||||
|
||||
| | This user (99.82% hit) | Same workload, **0% cache** |
|
||||
|---|---:|---:|
|
||||
| Cache-hit input | $12.18 | — |
|
||||
| Cache-miss input | $0.11 | $60.58 |
|
||||
| Cache-hit input | $1.22 | — |
|
||||
| Cache-miss input | $0.11 | $61.01 |
|
||||
| Output | $0.05 | $0.05 |
|
||||
| **Total / day** | **$12.34** | **$60.63** |
|
||||
| **Total / day** | **$1.38** | **$61.06** |
|
||||
|
||||
→ Cache saved this user **$48.29**, or **~80%** off the un-cached baseline, on a single day.
|
||||
→ Cache saved this user **$59.68**, or **~97.7%** off the un-cached baseline, on a single day.
|
||||
|
||||
On **v4-pro** (5× the prefix-cache discount) the same workload would cost
|
||||
**~$62.35** vs **~$727.08** without cache — a **~91% saving**.
|
||||
On **v4-pro** (6.25% of uncached input cost) the same workload would cost
|
||||
**~$2.07** vs **~$189.73** without cache — a **~98.9%** saving.
|
||||
|
||||
## "Isn't that just DeepSeek's prefix cache?"
|
||||
|
||||
@@ -59,8 +59,9 @@ hit rates:
|
||||
3. **`VolatileScratch`** — chain-of-thought / per-turn scratch lives outside
|
||||
the cached prefix so it never poisons the next hit.
|
||||
4. **Auto-compact** — when context approaches the cap, older turns fold into
|
||||
a summary message *appended* to the prefix; the prefix itself isn't
|
||||
rewritten, so the cache survives the fold.
|
||||
a summary message. The summary request is shaped to reuse the main agent's
|
||||
already-cached system/tool/history prefix; the following main-agent request
|
||||
still pays a cold miss for the newly synthesized summary segment.
|
||||
|
||||
DeepSeek gave us cacheable bytes. The four mechanisms above are how we keep
|
||||
the bytes cacheable.
|
||||
|
||||
+8
-1
@@ -24,6 +24,13 @@
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"ignore": ["dist", "node_modules", "coverage", "*.d.ts", "dashboard/codemirror.js"]
|
||||
"ignore": [
|
||||
"dist",
|
||||
"node_modules",
|
||||
"coverage",
|
||||
"*.d.ts",
|
||||
"dashboard/codemirror.js",
|
||||
"packages/ink/**"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,14 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<title>Reasonix</title>
|
||||
<meta name="reasonix-token" content="__REASONIX_TOKEN__" />
|
||||
<meta name="reasonix-mode" content="__REASONIX_MODE__" />
|
||||
<!-- Production: served by CLI server; Vite dev: transformed by vite.config.ts -->
|
||||
<link rel="stylesheet" href="/assets/app.css?token=__REASONIX_TOKEN__" />
|
||||
<link rel="stylesheet" href="/assets/vendor-uplot.css?token=__REASONIX_TOKEN__" />
|
||||
<link rel="stylesheet" href="/assets/vendor-hljs.css?token=__REASONIX_TOKEN__" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
|
||||
Generated
+3601
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "reasonix-dashboard-v2",
|
||||
"private": true,
|
||||
"version": "0.47.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/geist": "^5.2.8",
|
||||
"@fontsource/geist-mono": "^5.2.7",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"katex": "^0.16.21",
|
||||
"lucide-react": "^0.469.0",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-virtuoso": "^4.12.5",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-math": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,163 @@
|
||||
import { Highlight, type PrismTheme } from "prism-react-renderer";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
const DARK_THEME: PrismTheme = {
|
||||
plain: { color: "#dde1ea", backgroundColor: "transparent" },
|
||||
styles: [
|
||||
{ types: ["comment", "prolog", "doctype", "cdata"], style: { color: "#6d6e80", fontStyle: "italic" } },
|
||||
{ types: ["punctuation"], style: { color: "#a8a9b8" } },
|
||||
{ types: ["property", "tag", "boolean", "number", "constant", "symbol", "deleted"], style: { color: "#fbbf24" } },
|
||||
{ types: ["selector", "attr-name", "string", "char", "builtin", "inserted"], style: { color: "#86dcb1" } },
|
||||
{ types: ["operator", "entity", "url"], style: { color: "#84b9e8" } },
|
||||
{ types: ["atrule", "attr-value", "keyword"], style: { color: "#b4a8f0" } },
|
||||
{ types: ["function", "class-name", "maybe-class-name"], style: { color: "#84b9e8", fontWeight: "500" } },
|
||||
{ types: ["regex", "important", "variable"], style: { color: "#f0c062" } },
|
||||
{ types: ["important", "bold"], style: { fontWeight: "bold" } },
|
||||
{ types: ["italic"], style: { fontStyle: "italic" } },
|
||||
],
|
||||
};
|
||||
|
||||
const LIGHT_THEME: PrismTheme = {
|
||||
plain: { color: "#24292e", backgroundColor: "transparent" },
|
||||
styles: [
|
||||
{ types: ["comment", "prolog", "doctype", "cdata"], style: { color: "#6a737d", fontStyle: "italic" } },
|
||||
{ types: ["punctuation"], style: { color: "#24292e" } },
|
||||
{ types: ["property", "tag", "boolean", "number", "constant", "symbol", "deleted"], style: { color: "#d73a49" } },
|
||||
{ types: ["selector", "attr-name", "string", "char", "builtin", "inserted"], style: { color: "#032f62" } },
|
||||
{ types: ["operator", "entity", "url"], style: { color: "#d73a49" } },
|
||||
{ types: ["atrule", "attr-value", "keyword"], style: { color: "#d73a49" } },
|
||||
{ types: ["function", "class-name", "maybe-class-name"], style: { color: "#6f42c1", fontWeight: "500" } },
|
||||
{ types: ["regex", "important", "variable"], style: { color: "#e36209" } },
|
||||
{ types: ["important", "bold"], style: { fontWeight: "bold" } },
|
||||
{ types: ["italic"], style: { fontStyle: "italic" } },
|
||||
],
|
||||
};
|
||||
|
||||
function usePrismTheme(): PrismTheme {
|
||||
const [theme, setTheme] = useState<"dark" | "light">(() =>
|
||||
document.documentElement.dataset.theme === "light" ? "light" : "dark",
|
||||
);
|
||||
const prevRef = useRef(theme);
|
||||
useEffect(() => {
|
||||
const el = document.documentElement;
|
||||
const cb = () => {
|
||||
const t = el.dataset.theme === "light" ? "light" : "dark";
|
||||
if (t !== prevRef.current) {
|
||||
prevRef.current = t;
|
||||
setTheme(t);
|
||||
}
|
||||
};
|
||||
const mo = new MutationObserver(cb);
|
||||
mo.observe(el, { attributes: true, attributeFilter: ["data-theme"] });
|
||||
return () => mo.disconnect();
|
||||
}, []);
|
||||
return theme === "dark" ? DARK_THEME : LIGHT_THEME;
|
||||
}
|
||||
|
||||
export const PRISM_THEME = DARK_THEME;
|
||||
|
||||
const EXTS: Record<string, string> = {
|
||||
ts: "typescript", tsx: "tsx", mts: "typescript", cts: "typescript",
|
||||
js: "javascript", jsx: "jsx", mjs: "javascript", cjs: "javascript",
|
||||
py: "python", pyi: "python",
|
||||
rs: "rust",
|
||||
go: "go",
|
||||
json: "json", jsonc: "json",
|
||||
md: "markdown", mdx: "markdown",
|
||||
css: "css", scss: "scss", less: "less",
|
||||
html: "markup", htm: "markup", xml: "markup", svg: "markup",
|
||||
yaml: "yaml", yml: "yaml",
|
||||
toml: "toml",
|
||||
sh: "bash", bash: "bash", zsh: "bash", fish: "bash",
|
||||
sql: "sql",
|
||||
rb: "ruby",
|
||||
java: "java", kt: "kotlin",
|
||||
swift: "swift",
|
||||
c: "c", h: "c",
|
||||
cpp: "cpp", cc: "cpp", cxx: "cpp", hpp: "cpp", hxx: "cpp",
|
||||
cs: "csharp",
|
||||
php: "php",
|
||||
lua: "lua",
|
||||
dart: "dart",
|
||||
ex: "elixir", exs: "elixir",
|
||||
erl: "erlang",
|
||||
hs: "haskell",
|
||||
clj: "clojure", cljs: "clojure",
|
||||
zig: "zig",
|
||||
vue: "markup",
|
||||
svelte: "markup",
|
||||
graphql: "graphql", gql: "graphql",
|
||||
proto: "protobuf",
|
||||
dockerfile: "docker",
|
||||
};
|
||||
|
||||
export function langFromPath(path: string | null | undefined): string | null {
|
||||
if (!path) return null;
|
||||
const base = path.split(/[\\/]/).pop() ?? "";
|
||||
if (base.toLowerCase() === "dockerfile") return "docker";
|
||||
if (base.toLowerCase() === "makefile") return "makefile";
|
||||
const m = /\.([a-z0-9]+)$/i.exec(base);
|
||||
if (!m) return null;
|
||||
return EXTS[m[1].toLowerCase()] ?? null;
|
||||
}
|
||||
|
||||
export function CodeView({
|
||||
text,
|
||||
lang,
|
||||
startLine = 1,
|
||||
showLineNumbers = true,
|
||||
}: {
|
||||
text: string;
|
||||
lang: string;
|
||||
startLine?: number;
|
||||
showLineNumbers?: boolean;
|
||||
}) {
|
||||
const theme = usePrismTheme();
|
||||
return (
|
||||
<Highlight theme={theme} code={text} language={lang}>
|
||||
{({ className, tokens, getLineProps, getTokenProps }) => (
|
||||
<pre className={`codeview ${className}`}>
|
||||
{tokens.map((line, i) => (
|
||||
<div key={i} {...getLineProps({ line })} className="codeview-line">
|
||||
{showLineNumbers && (
|
||||
<span className="codeview-line-num">{i + startLine}</span>
|
||||
)}
|
||||
<span className="codeview-line-content">
|
||||
{line.map((token, k) => (
|
||||
<span key={k} {...getTokenProps({ token })} />
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</pre>
|
||||
)}
|
||||
</Highlight>
|
||||
);
|
||||
}
|
||||
|
||||
export function CollapsibleCode({
|
||||
text,
|
||||
lang,
|
||||
startLine = 1,
|
||||
maxLines = 20,
|
||||
}: {
|
||||
text: string;
|
||||
lang: string;
|
||||
startLine?: number;
|
||||
maxLines?: number;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const lines = text.split("\n");
|
||||
const tooLong = lines.length > maxLines;
|
||||
const shown = open || !tooLong ? text : lines.slice(0, maxLines).join("\n");
|
||||
return (
|
||||
<>
|
||||
<CodeView text={shown} lang={lang} startLine={startLine} />
|
||||
{tooLong && (
|
||||
<button type="button" className="tool-more" onClick={() => setOpen((v) => !v)}>
|
||||
{open ? "less" : `+ ${lines.length - maxLines} more lines`}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import {
|
||||
ClipboardCopy,
|
||||
Download,
|
||||
FilePlus,
|
||||
FocusIcon,
|
||||
FolderOpen,
|
||||
Info,
|
||||
Plus,
|
||||
Search,
|
||||
Settings,
|
||||
SquareX,
|
||||
StopCircle,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { t, useLang } from "./i18n";
|
||||
import { Shortcut, type ShortcutKey } from "./ui/shortcut";
|
||||
|
||||
export type CommandGroup = "nav" | "action" | "workspace" | "settings";
|
||||
|
||||
export type Command = {
|
||||
id: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
icon: ReactNode;
|
||||
shortcut?: ShortcutKey[];
|
||||
group: CommandGroup;
|
||||
run: () => void;
|
||||
};
|
||||
|
||||
export function useCommandPalette(active: boolean = true) {
|
||||
const [open, setOpen] = useState(false);
|
||||
useEffect(() => {
|
||||
// Skip in background tabs — each TabRuntime calls this hook, so without the gate Cmd+K toggles every tab's palette at once.
|
||||
if (!active) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
if (mod && (e.key === "k" || e.key === "K")) {
|
||||
e.preventDefault();
|
||||
setOpen((v) => !v);
|
||||
} else if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [active]);
|
||||
return { open, setOpen };
|
||||
}
|
||||
|
||||
export type CommandHandlers = {
|
||||
newChat: () => void;
|
||||
clearChat: () => void;
|
||||
focusComposer: () => void;
|
||||
openSettings: () => void;
|
||||
about: () => void;
|
||||
abort: () => void;
|
||||
copyLast: () => void;
|
||||
conversationCopy: () => void;
|
||||
exportMarkdown: () => void;
|
||||
pickWorkspace: () => void;
|
||||
newTab: () => void;
|
||||
closeTab: () => void;
|
||||
busy: boolean;
|
||||
canCloseTab: boolean;
|
||||
hasMessages: boolean;
|
||||
};
|
||||
|
||||
export function buildCommands(handlers: CommandHandlers): Command[] {
|
||||
const list: Command[] = [
|
||||
{
|
||||
id: "new-chat",
|
||||
group: "nav",
|
||||
label: t("palette.newChat"),
|
||||
hint: t("palette.newChatHint"),
|
||||
icon: <FilePlus size={13} />,
|
||||
shortcut: ["mod", "N"],
|
||||
run: handlers.newChat,
|
||||
},
|
||||
{
|
||||
id: "new-tab",
|
||||
group: "nav",
|
||||
label: t("palette.newTab"),
|
||||
hint: t("palette.newTabHint"),
|
||||
icon: <Plus size={13} />,
|
||||
shortcut: ["mod", "T"],
|
||||
run: handlers.newTab,
|
||||
},
|
||||
];
|
||||
if (handlers.canCloseTab) {
|
||||
list.push({
|
||||
id: "close-tab",
|
||||
group: "nav",
|
||||
label: t("palette.closeTab"),
|
||||
hint: t("palette.closeTabHint"),
|
||||
icon: <SquareX size={13} />,
|
||||
shortcut: ["mod", "W"],
|
||||
run: handlers.closeTab,
|
||||
});
|
||||
}
|
||||
list.push({
|
||||
id: "focus-composer",
|
||||
group: "nav",
|
||||
label: t("palette.focusComposer"),
|
||||
icon: <FocusIcon size={13} />,
|
||||
shortcut: ["mod", "L"],
|
||||
run: handlers.focusComposer,
|
||||
});
|
||||
if (handlers.busy) {
|
||||
list.push({
|
||||
id: "abort",
|
||||
group: "action",
|
||||
label: t("palette.abort"),
|
||||
hint: t("palette.abortHint"),
|
||||
icon: <StopCircle size={13} />,
|
||||
shortcut: ["esc"],
|
||||
run: handlers.abort,
|
||||
});
|
||||
}
|
||||
if (handlers.hasMessages) {
|
||||
list.push({
|
||||
id: "copy-last",
|
||||
group: "action",
|
||||
label: t("palette.copyLast"),
|
||||
hint: t("palette.copyLastHint"),
|
||||
icon: <ClipboardCopy size={13} />,
|
||||
run: handlers.copyLast,
|
||||
});
|
||||
list.push({
|
||||
id: "copy-conv",
|
||||
group: "action",
|
||||
label: t("palette.copyConv"),
|
||||
hint: t("palette.copyConvHint"),
|
||||
icon: <ClipboardCopy size={13} />,
|
||||
run: handlers.conversationCopy,
|
||||
});
|
||||
list.push({
|
||||
id: "export-md",
|
||||
group: "action",
|
||||
label: t("palette.exportMd"),
|
||||
hint: t("palette.exportMdHint"),
|
||||
icon: <Download size={13} />,
|
||||
run: handlers.exportMarkdown,
|
||||
});
|
||||
list.push({
|
||||
id: "clear-chat",
|
||||
group: "action",
|
||||
label: t("palette.clearChat"),
|
||||
hint: t("palette.clearChatHint"),
|
||||
icon: <Trash2 size={13} />,
|
||||
run: handlers.clearChat,
|
||||
});
|
||||
}
|
||||
list.push({
|
||||
id: "pick-workspace",
|
||||
group: "workspace",
|
||||
label: t("palette.pickWorkspace"),
|
||||
hint: t("palette.pickWorkspaceHint"),
|
||||
icon: <FolderOpen size={13} />,
|
||||
run: handlers.pickWorkspace,
|
||||
});
|
||||
list.push({
|
||||
id: "settings",
|
||||
group: "settings",
|
||||
label: t("palette.settings"),
|
||||
hint: t("palette.settingsHint"),
|
||||
icon: <Settings size={13} />,
|
||||
run: handlers.openSettings,
|
||||
});
|
||||
list.push({
|
||||
id: "about",
|
||||
group: "settings",
|
||||
label: t("palette.about"),
|
||||
icon: <Info size={13} />,
|
||||
run: handlers.about,
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
const GROUP_ORDER: CommandGroup[] = ["nav", "action", "workspace", "settings"];
|
||||
|
||||
function groupLabel(g: CommandGroup): string {
|
||||
switch (g) {
|
||||
case "nav": return t("palette.groupNav");
|
||||
case "action": return t("palette.groupAction");
|
||||
case "workspace": return t("palette.groupWorkspace");
|
||||
case "settings": return t("palette.groupSettings");
|
||||
}
|
||||
}
|
||||
|
||||
export function CommandPalette({
|
||||
open,
|
||||
onClose,
|
||||
commands,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
commands: Command[];
|
||||
}) {
|
||||
useLang();
|
||||
const [query, setQuery] = useState("");
|
||||
const [active, setActive] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return commands;
|
||||
return commands.filter((c) =>
|
||||
[c.label, c.hint].filter(Boolean).join(" ").toLowerCase().includes(q),
|
||||
);
|
||||
}, [query, commands]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery("");
|
||||
setActive(0);
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
setActive((a) => Math.min(a, Math.max(0, filtered.length - 1)));
|
||||
}, [filtered.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = listRef.current?.querySelector<HTMLElement>(`[data-idx="${active}"]`);
|
||||
el?.scrollIntoView({ block: "nearest" });
|
||||
}, [active]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const byGroup = new Map<CommandGroup, Command[]>();
|
||||
for (const c of filtered) {
|
||||
const arr = byGroup.get(c.group) ?? [];
|
||||
arr.push(c);
|
||||
byGroup.set(c.group, arr);
|
||||
}
|
||||
return GROUP_ORDER
|
||||
.map((g) => ({ group: g, items: byGroup.get(g) ?? [] }))
|
||||
.filter((s) => s.items.length > 0);
|
||||
}, [filtered]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const run = (cmd: Command) => {
|
||||
cmd.run();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="cmdk-mask" onMouseDown={onClose}>
|
||||
<div className="cmdk" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<div className="cmdk-head">
|
||||
<Search size={14} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
placeholder={t("palette.searchPlaceholder")}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActive((a) => Math.min(a + 1, filtered.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActive((a) => Math.max(a - 1, 0));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const cmd = filtered[active];
|
||||
if (cmd) run(cmd);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="hint">
|
||||
<Shortcut keys={["esc"]} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="cmdk-body" ref={listRef}>
|
||||
{filtered.length === 0 ? (
|
||||
<div className="cmdk-empty">{t("palette.empty")}</div>
|
||||
) : null}
|
||||
{grouped.map((section) => (
|
||||
<div className="cmdk-group" key={section.group}>
|
||||
<div className="cmdk-gh">{groupLabel(section.group)}</div>
|
||||
{section.items.map((c) => {
|
||||
const i = filtered.indexOf(c);
|
||||
return (
|
||||
<div
|
||||
key={c.id}
|
||||
data-idx={i}
|
||||
className="cmdk-row"
|
||||
data-active={i === active}
|
||||
onMouseEnter={() => setActive(i)}
|
||||
onClick={() => run(c)}
|
||||
>
|
||||
<span className="ic">{c.icon}</span>
|
||||
<span className="l">{c.label}</span>
|
||||
<span className="g">{groupLabel(c.group)}</span>
|
||||
{c.shortcut ? (
|
||||
<span className="kb">
|
||||
<Shortcut keys={c.shortcut} />
|
||||
</span>
|
||||
) : (
|
||||
<span className="kb-empty" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="cmdk-foot">
|
||||
<span>
|
||||
<Shortcut keys={["updown"]} />
|
||||
{t("palette.footMove")}
|
||||
</span>
|
||||
<span>
|
||||
<Shortcut keys={["enter"]} />
|
||||
{t("palette.footRun")}
|
||||
</span>
|
||||
<span>
|
||||
<Shortcut keys={["esc"]} />
|
||||
{t("palette.footClose")}
|
||||
</span>
|
||||
<span style={{ marginLeft: "auto", color: "var(--muted)" }}>
|
||||
{filtered.length} {t("palette.countSuffix")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Toast({ message }: { message: { msg: string; yolo?: boolean } | null }) {
|
||||
if (!message) return null;
|
||||
if (message.yolo) {
|
||||
return (
|
||||
<div className="toast toast-yolo">
|
||||
<span className="toast-yolo-badge">YOLO</span>
|
||||
{message.msg}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div className="toast">{message.msg}</div>;
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { openPath, openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { Check, Copy, ExternalLink, FileText } from "lucide-react";
|
||||
import {
|
||||
Children,
|
||||
cloneElement,
|
||||
createContext,
|
||||
isValidElement,
|
||||
memo,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useState,
|
||||
} from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkMath from "remark-math";
|
||||
import { CodeView } from "./CodeView";
|
||||
import { t, useLang } from "./i18n";
|
||||
|
||||
async function openWithEditor(
|
||||
editor: string | undefined,
|
||||
abs: string,
|
||||
line?: number,
|
||||
): Promise<void> {
|
||||
if (editor && editor.trim()) {
|
||||
await invoke("open_in_editor", { command: editor, path: abs, line: line ?? null });
|
||||
return;
|
||||
}
|
||||
await openPath(abs);
|
||||
}
|
||||
|
||||
type WorkspaceCtx = { dir?: string; editor?: string };
|
||||
const WorkspaceContext = createContext<WorkspaceCtx>({});
|
||||
export const WorkspaceProvider = WorkspaceContext.Provider;
|
||||
|
||||
function resolveAgainstWorkspace(rel: string, ws: string | undefined): string {
|
||||
if (!ws) return rel;
|
||||
if (/^[a-zA-Z]:[\\/]/.test(rel) || rel.startsWith("/")) return rel;
|
||||
const sep = ws.includes("\\") ? "\\" : "/";
|
||||
const trimmed = ws.replace(/[\\/]$/, "");
|
||||
return `${trimmed}${sep}${rel.replace(/^\.[\\/]/, "")}`;
|
||||
}
|
||||
|
||||
const KNOWN_EXTS =
|
||||
"ts|tsx|mts|cts|js|jsx|mjs|cjs|py|pyi|rs|go|json|jsonc|md|mdx|css|scss|less|html|htm|xml|svg|yaml|yml|toml|sh|bash|zsh|fish|sql|rb|java|kt|swift|c|cpp|cc|cxx|h|hpp|hxx|cs|php|lua|dart|ex|exs|erl|hs|clj|cljs|zig|vue|svelte|graphql|gql|proto";
|
||||
// No lookbehind here — Tauri's WKWebView on macOS Monterey (Safari < 16.4)
|
||||
// can't parse `(?<=...)` and the whole bundle fails to load with an
|
||||
// "invalid group specifier name" error. Capture the leading char as
|
||||
// group 1 instead and let splitFilePaths skip past it. Issue #1209.
|
||||
const FILE_PATH_RE = new RegExp(
|
||||
`(^|[\\s\`'"(\\[])((?:[\\w.-]+\\/)+[\\w.-]+\\.(?:${KNOWN_EXTS}))(?::(\\d+(?:-\\d+)?))?(?=[\\s.,;!?\\]\\)'"\`]|$)`,
|
||||
"g",
|
||||
);
|
||||
|
||||
function FilePill({ path, line }: { path: string; line?: string }) {
|
||||
useLang();
|
||||
const ctx = useContext(WorkspaceContext);
|
||||
const [done, setDone] = useState<"open" | "copy" | null>(null);
|
||||
const display = line ? `${path}:${line}` : path;
|
||||
const openInEditor = async () => {
|
||||
try {
|
||||
const abs = resolveAgainstWorkspace(path, ctx.dir);
|
||||
const lineNum = line ? Number.parseInt(line.split("-")[0] ?? line, 10) : undefined;
|
||||
await openWithEditor(ctx.editor, abs, Number.isFinite(lineNum) ? lineNum : undefined);
|
||||
setDone("open");
|
||||
setTimeout(() => setDone(null), 1200);
|
||||
} catch {
|
||||
try {
|
||||
await navigator.clipboard.writeText(display);
|
||||
setDone("copy");
|
||||
setTimeout(() => setDone(null), 1200);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
};
|
||||
const copyOnly = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(display);
|
||||
setDone("copy");
|
||||
setTimeout(() => setDone(null), 1200);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={`file-pill ${done ? "done" : ""}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={openInEditor}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
void copyOnly(e);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
void openInEditor();
|
||||
}
|
||||
}}
|
||||
title={t("markdown.filePillTitle")}
|
||||
>
|
||||
<FileText size={10} className="file-pill-icon" />
|
||||
<span className="file-pill-path">{path}</span>
|
||||
{line && <span className="file-pill-line">:{line}</span>}
|
||||
{done && <Check size={10} className="file-pill-check" />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function splitFilePaths(text: string): ReactNode[] | string {
|
||||
FILE_PATH_RE.lastIndex = 0;
|
||||
const out: ReactNode[] = [];
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null = FILE_PATH_RE.exec(text);
|
||||
while (m !== null) {
|
||||
const prefix = m[1] ?? "";
|
||||
const path = m[2]!;
|
||||
const line = m[3];
|
||||
const pillStart = m.index + prefix.length;
|
||||
if (pillStart > last) out.push(text.slice(last, pillStart));
|
||||
out.push(<FilePill key={`fp-${pillStart}`} path={path} line={line} />);
|
||||
last = pillStart + path.length + (line ? line.length + 1 : 0);
|
||||
m = FILE_PATH_RE.exec(text);
|
||||
}
|
||||
if (out.length === 0) return text;
|
||||
if (last < text.length) out.push(text.slice(last));
|
||||
return out;
|
||||
}
|
||||
|
||||
type AnyProps = { children?: ReactNode } & Record<string, unknown>;
|
||||
|
||||
function withFilePills(children: ReactNode): ReactNode {
|
||||
return Children.map(children, (child) => {
|
||||
if (typeof child === "string") return splitFilePaths(child);
|
||||
if (isValidElement(child)) {
|
||||
const props = child.props as AnyProps;
|
||||
if (props.children !== undefined) {
|
||||
return cloneElement(child, undefined, withFilePills(props.children));
|
||||
}
|
||||
}
|
||||
return child;
|
||||
});
|
||||
}
|
||||
|
||||
export const Markdown = memo(function Markdown({ source }: { source: string }) {
|
||||
return (
|
||||
<div className="markdown">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkBreaks, remarkMath]}
|
||||
rehypePlugins={[[rehypeKatex, { throwOnError: false }]]}
|
||||
components={{
|
||||
pre: ({ children }) => {
|
||||
// react-markdown v9 nests children unpredictably — flatten all text.
|
||||
const rawText = flattenChildText(children).trimEnd();
|
||||
return <CodeBlock lang={extractFencedLang(children)} text={rawText} />;
|
||||
},
|
||||
code: ({ className, children }) => <code className={className}>{children}</code>,
|
||||
a: ({ href, children }) => <SafeLink href={href}>{children}</SafeLink>,
|
||||
p: ({ children }) => <p>{withFilePills(children)}</p>,
|
||||
li: ({ children }) => <li>{withFilePills(children)}</li>,
|
||||
td: ({ children }) => <td>{withFilePills(children)}</td>,
|
||||
}}
|
||||
>
|
||||
{source}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function SafeLink({ href, children }: { href?: string; children: ReactNode }) {
|
||||
useLang();
|
||||
const ctx = useContext(WorkspaceContext);
|
||||
const [done, setDone] = useState(false);
|
||||
const isExternal = !!href && /^https?:\/\//i.test(href);
|
||||
const onClick = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (!href) return;
|
||||
if (isExternal) {
|
||||
try {
|
||||
await openUrl(href);
|
||||
} catch {
|
||||
window.open(href, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const stripped = href.replace(/^file:\/\//, "");
|
||||
const abs = resolveAgainstWorkspace(stripped, ctx.dir);
|
||||
await openWithEditor(ctx.editor, abs);
|
||||
} catch {
|
||||
try {
|
||||
await navigator.clipboard.writeText(href);
|
||||
setDone(true);
|
||||
setTimeout(() => setDone(false), 1200);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
};
|
||||
return (
|
||||
<a
|
||||
href={href ?? "#"}
|
||||
onClick={onClick}
|
||||
className={`md-link ${isExternal ? "external" : "local"} ${done ? "done" : ""}`}
|
||||
title={
|
||||
isExternal
|
||||
? t("markdown.externalLinkTitle", { href: href ?? "" })
|
||||
: t("markdown.localLinkTitle", { href: href ?? "" })
|
||||
}
|
||||
>
|
||||
{children}
|
||||
{isExternal ? (
|
||||
<ExternalLink size={10} className="md-link-icon" />
|
||||
) : done ? (
|
||||
<Check size={10} className="md-link-icon" />
|
||||
) : null}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function extractFencedLang(children: ReactNode): string {
|
||||
for (const kid of Children.toArray(children)) {
|
||||
if (isValidElement(kid)) {
|
||||
const cls = (kid.props as Record<string, unknown>).className;
|
||||
if (typeof cls === "string") {
|
||||
const m = cls.match(/language-([\w-]+)/);
|
||||
if (m) return m[1]!;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "text";
|
||||
}
|
||||
|
||||
function flattenChildText(node: ReactNode): string {
|
||||
if (typeof node === "string" || typeof node === "number") return String(node);
|
||||
if (Array.isArray(node)) return node.map(flattenChildText).join("");
|
||||
if (isValidElement(node))
|
||||
return flattenChildText((node.props as { children?: ReactNode }).children);
|
||||
return "";
|
||||
}
|
||||
|
||||
function CodeBlock({ lang, text }: { lang: string; text: string }): ReactNode {
|
||||
useLang();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const onCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1400);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="codeblock">
|
||||
<div className="codeblock-head">
|
||||
<span className="codeblock-lang">{lang}</span>
|
||||
<button type="button" className={`copy-btn ${copied ? "done" : ""}`} onClick={onCopy}>
|
||||
{copied ? <Check size={11} /> : <Copy size={11} />}
|
||||
{copied ? t("markdown.copied") : t("markdown.copy")}
|
||||
</button>
|
||||
</div>
|
||||
<CodeView text={text} lang={lang} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,912 +0,0 @@
|
||||
import { marked } from "marked";
|
||||
import { memo } from "preact/compat";
|
||||
import { useState } from "preact/hooks";
|
||||
import { html } from "../lib/html.js";
|
||||
import { t, useLang } from "../i18n/index.js";
|
||||
import {
|
||||
escapeHtml,
|
||||
hlLine,
|
||||
langFromPath,
|
||||
renderHighlightedBlock,
|
||||
renderMarkdownToString,
|
||||
renderSearchReplace,
|
||||
} from "../lib/markdown.js";
|
||||
|
||||
export type ChatRole = "user" | "assistant" | "tool" | "info" | "warning" | "error";
|
||||
|
||||
export interface ChatMsg {
|
||||
id: string;
|
||||
role: ChatRole;
|
||||
text?: string;
|
||||
reasoning?: string;
|
||||
toolName?: string;
|
||||
toolArgs?: string;
|
||||
}
|
||||
|
||||
const ROLE_GLYPH: Record<ChatRole, string> = {
|
||||
user: "◇",
|
||||
assistant: "◆",
|
||||
tool: "▣",
|
||||
info: "·",
|
||||
warning: "▲",
|
||||
error: "✦",
|
||||
};
|
||||
|
||||
export type OnResolve = (kind: string, ...args: unknown[]) => void;
|
||||
|
||||
interface ToolCardProps {
|
||||
msg: ChatMsg;
|
||||
}
|
||||
|
||||
interface ChatMessageProps {
|
||||
msg: ChatMsg;
|
||||
streaming?: boolean;
|
||||
}
|
||||
|
||||
interface ModalCardProps {
|
||||
accent: string;
|
||||
icon: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children?: unknown;
|
||||
}
|
||||
|
||||
interface ShellModalSpec {
|
||||
command: string;
|
||||
allowPrefix?: string;
|
||||
shellKind?: string;
|
||||
}
|
||||
|
||||
interface ChoiceOption {
|
||||
id: string;
|
||||
title: string;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
interface ChoiceModalSpec {
|
||||
question: string;
|
||||
options: ChoiceOption[];
|
||||
allowCustom?: boolean;
|
||||
}
|
||||
|
||||
interface PlanModalSpec {
|
||||
body?: string;
|
||||
}
|
||||
|
||||
interface EditReviewSpec {
|
||||
search?: string;
|
||||
replace?: string;
|
||||
path?: string;
|
||||
remaining: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface WorkspaceSpec {
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface CheckpointSpec {
|
||||
stepId: string;
|
||||
title?: string;
|
||||
completed?: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
interface RevisionStep {
|
||||
id: string;
|
||||
title: string;
|
||||
action: string;
|
||||
risk?: "low" | "med" | "high";
|
||||
}
|
||||
|
||||
interface RevisionSpec {
|
||||
summary?: string;
|
||||
reason: string;
|
||||
remainingSteps: RevisionStep[];
|
||||
}
|
||||
|
||||
export type PickerActionName =
|
||||
| "pick"
|
||||
| "delete"
|
||||
| "rename"
|
||||
| "new"
|
||||
| "install"
|
||||
| "uninstall"
|
||||
| "load-more"
|
||||
| "refine"
|
||||
| "cancel";
|
||||
|
||||
export interface PickerItemSpec {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
badge?: string;
|
||||
meta?: string;
|
||||
}
|
||||
|
||||
export interface PickerModalSpec {
|
||||
pickerKind: string;
|
||||
title: string;
|
||||
query?: string;
|
||||
items: PickerItemSpec[];
|
||||
actions: PickerActionName[];
|
||||
hasMore?: boolean;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface ViewerStep {
|
||||
id: string;
|
||||
title: string;
|
||||
status: "done" | "queued";
|
||||
}
|
||||
|
||||
export interface ViewerModalSpec {
|
||||
viewerKind: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
steps?: ViewerStep[];
|
||||
meta?: string;
|
||||
}
|
||||
|
||||
interface DiffEntry {
|
||||
kind: "context" | "ins" | "del";
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface DiffPair {
|
||||
left: string | null;
|
||||
right: string | null;
|
||||
kind: "context" | "change" | "ins" | "del";
|
||||
}
|
||||
|
||||
export function renderMessageBody(text: string | null | undefined) {
|
||||
if (!text) return null;
|
||||
return html`<div class="md" dangerouslySetInnerHTML=${{ __html: renderMarkdownToString(text) }}></div>`;
|
||||
}
|
||||
|
||||
export function parseToolArgs(raw: string | null | undefined): Record<string, unknown> | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function ToolCard({ msg }: ToolCardProps) {
|
||||
useLang();
|
||||
const args = parseToolArgs(msg.toolArgs);
|
||||
const name = msg.toolName ?? "tool";
|
||||
// Reasonix's filesystem tools emit the path in args.path; MCP-bridged
|
||||
// ones may differ but most expose a `path` field too. Normalize.
|
||||
const path = (args?.path ?? args?.file_path ?? args?.filename) as string | undefined;
|
||||
|
||||
// edit_file (Reasonix) — search/replace pair → diff view.
|
||||
if (
|
||||
(name === "edit_file" || name.endsWith("_edit_file")) &&
|
||||
args &&
|
||||
typeof args.search === "string" &&
|
||||
typeof args.replace === "string"
|
||||
) {
|
||||
const diffHtml = renderSearchReplace(
|
||||
args.search as string,
|
||||
args.replace as string,
|
||||
path ?? "",
|
||||
);
|
||||
return html`
|
||||
<div class="tool-card">
|
||||
<div class="tool-card-head">
|
||||
<span class="tool-card-icon">✎</span>
|
||||
<span class="tool-card-name">edit_file</span>
|
||||
${path ? html`<code class="tool-card-path">${path}</code>` : null}
|
||||
</div>
|
||||
<div dangerouslySetInnerHTML=${{ __html: diffHtml }}></div>
|
||||
${msg.text ? html`<div class="tool-card-result">${msg.text}</div>` : null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// write_file — show new content as a code block with path-derived lang.
|
||||
if (
|
||||
(name === "write_file" || name.endsWith("_write_file")) &&
|
||||
args &&
|
||||
typeof args.content === "string"
|
||||
) {
|
||||
const lang = langFromPath(path);
|
||||
return html`
|
||||
<div class="tool-card">
|
||||
<div class="tool-card-head">
|
||||
<span class="tool-card-icon">+</span>
|
||||
<span class="tool-card-name">write_file</span>
|
||||
${path ? html`<code class="tool-card-path">${path}</code>` : null}
|
||||
${lang ? html`<span class="pill">${lang}</span>` : null}
|
||||
</div>
|
||||
<div dangerouslySetInnerHTML=${{ __html: renderHighlightedBlock(args.content as string, lang) }}></div>
|
||||
${msg.text ? html`<div class="tool-card-result">${msg.text}</div>` : null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// read_file / list_files — content lands in msg.text.
|
||||
if (name === "read_file" || name.endsWith("_read_file") || name === "filesystem_read_file") {
|
||||
const lang = langFromPath(path);
|
||||
return html`
|
||||
<div class="tool-card">
|
||||
<div class="tool-card-head">
|
||||
<span class="tool-card-icon">▤</span>
|
||||
<span class="tool-card-name">read_file</span>
|
||||
${path ? html`<code class="tool-card-path">${path}</code>` : null}
|
||||
${lang ? html`<span class="pill">${lang}</span>` : null}
|
||||
</div>
|
||||
<div dangerouslySetInnerHTML=${{ __html: renderHighlightedBlock(msg.text ?? "", lang) }}></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// run_command / run_background — terminal-style.
|
||||
if (name === "run_command" || name === "run_background") {
|
||||
const cmd = args?.command;
|
||||
return html`
|
||||
<div class="tool-card">
|
||||
<div class="tool-card-head">
|
||||
<span class="tool-card-icon">⚡</span>
|
||||
<span class="tool-card-name">${name === "run_background" ? "run_background" : "run_command"}</span>
|
||||
</div>
|
||||
${
|
||||
cmd
|
||||
? html`<pre class="tool-card-cmd"><span class="tool-card-prompt">$</span> <code>${cmd}</code></pre>`
|
||||
: null
|
||||
}
|
||||
${msg.text ? html`<pre class="tool-card-output">${msg.text}</pre>` : null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// list_files / file_exists / delete_file — show args + result inline.
|
||||
if (
|
||||
name === "list_files" ||
|
||||
name === "file_exists" ||
|
||||
name === "delete_file" ||
|
||||
name === "create_directory" ||
|
||||
name === "delete_directory" ||
|
||||
name.endsWith("_list_files")
|
||||
) {
|
||||
return html`
|
||||
<div class="tool-card">
|
||||
<div class="tool-card-head">
|
||||
<span class="tool-card-icon">▣</span>
|
||||
<span class="tool-card-name">${name}</span>
|
||||
${path ? html`<code class="tool-card-path">${path}</code>` : null}
|
||||
</div>
|
||||
<pre class="tool-card-output">${msg.text}</pre>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Default — keep the legacy compact box but add an args preview when
|
||||
// present so MCP-bridged tools still surface something readable.
|
||||
return html`
|
||||
<div class="tool-card">
|
||||
<div class="tool-card-head">
|
||||
<span class="tool-card-icon">▣</span>
|
||||
<span class="tool-card-name">${name}</span>
|
||||
</div>
|
||||
${
|
||||
args
|
||||
? html`<details class="tool-card-args"><summary>${t("modal.arguments")}</summary><pre>${escapeHtml(JSON.stringify(args, null, 2))}</pre></details>`
|
||||
: null
|
||||
}
|
||||
<pre class="tool-card-output">${msg.text}</pre>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// memo() short-circuits re-renders when shallow props are unchanged.
|
||||
// Historical messages keep stable msg references across deltas, so the
|
||||
// O(N) marked.parse + hljs work that used to fire per assistant_delta
|
||||
// now only runs on truly new messages and the live streaming bubble.
|
||||
export const ChatMessage = memo(function ChatMessage({ msg, streaming }: ChatMessageProps) {
|
||||
const role = msg.role;
|
||||
const glyph = ROLE_GLYPH[role as ChatRole] ?? "·";
|
||||
if (role === "tool") {
|
||||
return html`
|
||||
<div class="chat-msg tool">
|
||||
<div class="glyph">${glyph}</div>
|
||||
<${ToolCard} msg=${msg} />
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return html`
|
||||
<div class="chat-msg ${role}">
|
||||
<div class="glyph">${glyph}</div>
|
||||
<div class="body">
|
||||
${msg.reasoning ? html`<div class="reasoning">${msg.reasoning}</div>` : null}
|
||||
${renderMessageBody(msg.text)}
|
||||
${streaming ? html`<span class="chat-streaming-cursor"></span>` : null}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
//
|
||||
// Each component renders a card matching the TUI's ModalCard accent
|
||||
// palette: red for shell (run-now), magenta for choice (branching),
|
||||
// cyan for plan (decision), green for edits. onResolve pushes to the
|
||||
// server; the SSE channel will echo back a modal-down that clears the
|
||||
// local state — both surfaces stay in lockstep without polling.
|
||||
|
||||
export function ModalCard({ accent, icon, title, subtitle, children }: ModalCardProps) {
|
||||
return html`
|
||||
<div class="modal-card" style=${`border-left-color: ${accent};`}>
|
||||
<div class="modal-card-head">
|
||||
<span class="modal-card-icon" style=${`color: ${accent};`}>${icon}</span>
|
||||
<div>
|
||||
<div class="modal-card-title">${title}</div>
|
||||
${subtitle ? html`<div class="modal-card-subtitle">${subtitle}</div>` : null}
|
||||
</div>
|
||||
</div>
|
||||
${children}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function ShellModal({ modal, onResolve }: { modal: ShellModalSpec; onResolve: OnResolve }) {
|
||||
useLang();
|
||||
const isBg = modal.shellKind === "run_background";
|
||||
return html`
|
||||
<${ModalCard}
|
||||
accent="#f87171"
|
||||
icon=${isBg ? "⏱" : "⚡"}
|
||||
title=${isBg ? t("modal.shellBgTitle") : t("modal.shellTitle")}
|
||||
subtitle=${
|
||||
isBg ? t("modal.shellBgSubtitle") : t("modal.shellSubtitle")
|
||||
}
|
||||
>
|
||||
<div class="modal-cmd"><span class="modal-cmd-prompt">$</span> <code>${modal.command}</code></div>
|
||||
<div class="modal-actions">
|
||||
<button class="primary" onClick=${() => onResolve("shell", "run_once")}>${t("modal.runOnce")}</button>
|
||||
<button onClick=${() => onResolve("shell", "always_allow")}>${t("modal.alwaysAllow", { prefix: modal.allowPrefix ?? "" })}</button>
|
||||
<button class="danger" onClick=${() => onResolve("shell", "deny")}>${t("modal.deny")}</button>
|
||||
</div>
|
||||
<//>
|
||||
`;
|
||||
}
|
||||
|
||||
export function ChoiceModal({ modal, onResolve }: { modal: ChoiceModalSpec; onResolve: OnResolve }) {
|
||||
useLang();
|
||||
const [custom, setCustom] = useState("");
|
||||
const [showCustom, setShowCustom] = useState(false);
|
||||
return html`
|
||||
<${ModalCard} accent="#f0abfc" icon="🔀" title=${t("modal.choiceTitle")} subtitle=${modal.question}>
|
||||
${modal.options.map(
|
||||
(opt: ChoiceOption) => html`
|
||||
<button
|
||||
key=${opt.id}
|
||||
class="modal-choice-row"
|
||||
onClick=${() => onResolve("choice", { kind: "pick", optionId: opt.id })}
|
||||
>
|
||||
<span class="modal-choice-id">${opt.id}</span>
|
||||
<span class="modal-choice-title">${opt.title}</span>
|
||||
${opt.summary ? html`<span class="modal-choice-summary">${opt.summary}</span>` : null}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
${
|
||||
modal.allowCustom
|
||||
? showCustom
|
||||
? html`
|
||||
<div class="modal-custom">
|
||||
<textarea
|
||||
placeholder=${t("modal.typePlaceholder")}
|
||||
rows="2"
|
||||
value=${custom}
|
||||
onInput=${(e: Event) => setCustom((e.target as HTMLTextAreaElement).value)}
|
||||
></textarea>
|
||||
<div class="modal-actions">
|
||||
<button class="primary" onClick=${() => onResolve("choice", { kind: "custom", text: custom })} disabled=${!custom.trim()}>${t("modal.send")}</button>
|
||||
<button onClick=${() => {
|
||||
setShowCustom(false);
|
||||
setCustom("");
|
||||
}}>${t("common.back")}</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<button class="modal-choice-row" onClick=${() => setShowCustom(true)}>
|
||||
<span class="modal-choice-id">·</span>
|
||||
<span class="modal-choice-title">${t("modal.typeOwn")}</span>
|
||||
<span class="modal-choice-summary">${t("modal.typeOwnSummary")}</span>
|
||||
</button>
|
||||
`
|
||||
: null
|
||||
}
|
||||
<button class="modal-choice-row modal-choice-cancel" onClick=${() => onResolve("choice", { kind: "cancel" })}>
|
||||
<span class="modal-choice-id">×</span>
|
||||
<span class="modal-choice-title">${t("modal.cancel")}</span>
|
||||
<span class="modal-choice-summary">${t("modal.cancelSummary")}</span>
|
||||
</button>
|
||||
<//>
|
||||
`;
|
||||
}
|
||||
|
||||
export function PlanModal({ modal, onResolve }: { modal: PlanModalSpec; onResolve: OnResolve }) {
|
||||
useLang();
|
||||
const [feedback, setFeedback] = useState("");
|
||||
const [stage, setStage] = useState<"approve" | "refine" | null>(null);
|
||||
const send = () => onResolve("plan", stage, feedback);
|
||||
return html`
|
||||
<${ModalCard} accent="#67e8f9" icon="◆" title=${t("modal.planTitle")} subtitle=${t("modal.planSubtitle")}>
|
||||
<div class="md modal-plan-body" dangerouslySetInnerHTML=${{ __html: marked.parse(modal.body || "") }}></div>
|
||||
${
|
||||
stage
|
||||
? html`
|
||||
<textarea
|
||||
placeholder=${
|
||||
stage === "approve"
|
||||
? t("modal.approveInstructions")
|
||||
: t("modal.refinePlaceholder")
|
||||
}
|
||||
rows="3"
|
||||
value=${feedback}
|
||||
onInput=${(e: Event) => setFeedback((e.target as HTMLTextAreaElement).value)}
|
||||
></textarea>
|
||||
<div class="modal-actions">
|
||||
<button class="primary" onClick=${send}>${stage === "approve" ? t("modal.approve") : t("modal.sendRefinement")}</button>
|
||||
<button onClick=${() => {
|
||||
setStage(null);
|
||||
setFeedback("");
|
||||
}}>${t("common.back")}</button>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="modal-actions">
|
||||
<button class="primary" onClick=${() => setStage("approve")}>${t("modal.approve")}</button>
|
||||
<button onClick=${() => setStage("refine")}>${t("modal.refine")}</button>
|
||||
<button class="danger" onClick=${() => onResolve("plan", "cancel")}>${t("modal.cancel")}</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
<//>
|
||||
`;
|
||||
}
|
||||
|
||||
// Line-level LCS diff. Returns an ordered list of rows; "context" rows
|
||||
// appear on both sides, "del" only on the left (red), "ins" only on the
|
||||
// right (green). Adjacent del/ins are paired into one row downstream so
|
||||
// the change reads "old → new" left-to-right like a git side-by-side.
|
||||
function lineDiff(aLines: string[], bLines: string[]): DiffEntry[] {
|
||||
const m = aLines.length;
|
||||
const n = bLines.length;
|
||||
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array<number>(n + 1).fill(0));
|
||||
for (let i = 1; i <= m; i++) {
|
||||
for (let j = 1; j <= n; j++) {
|
||||
if (aLines[i - 1] === bLines[j - 1]) dp[i]![j] = dp[i - 1]![j - 1]! + 1;
|
||||
else dp[i]![j] = Math.max(dp[i - 1]![j]!, dp[i]![j - 1]!);
|
||||
}
|
||||
}
|
||||
const out: DiffEntry[] = [];
|
||||
let i = m;
|
||||
let j = n;
|
||||
while (i > 0 || j > 0) {
|
||||
if (i > 0 && j > 0 && aLines[i - 1] === bLines[j - 1]) {
|
||||
out.push({ kind: "context", text: aLines[i - 1]! });
|
||||
i--;
|
||||
j--;
|
||||
} else if (j > 0 && (i === 0 || dp[i]![j - 1]! >= dp[i - 1]![j]!)) {
|
||||
out.push({ kind: "ins", text: bLines[j - 1]! });
|
||||
j--;
|
||||
} else {
|
||||
out.push({ kind: "del", text: aLines[i - 1]! });
|
||||
i--;
|
||||
}
|
||||
}
|
||||
return out.reverse();
|
||||
}
|
||||
|
||||
// Pair del/ins runs into side-by-side rows. A run of consecutive dels
|
||||
// followed by a run of inss collapses into rows of (del[k], ins[k]) so
|
||||
// the modified line lines up across the gutter; surplus on either side
|
||||
// produces rows with the opposite cell empty.
|
||||
function pairDiffRows(diff: DiffEntry[]): DiffPair[] {
|
||||
const rows: DiffPair[] = [];
|
||||
let k = 0;
|
||||
while (k < diff.length) {
|
||||
const entry = diff[k]!;
|
||||
if (entry.kind === "context") {
|
||||
rows.push({ left: entry.text, right: entry.text, kind: "context" });
|
||||
k++;
|
||||
continue;
|
||||
}
|
||||
const dels: string[] = [];
|
||||
const inss: string[] = [];
|
||||
while (k < diff.length && diff[k]!.kind === "del") {
|
||||
dels.push(diff[k]!.text);
|
||||
k++;
|
||||
}
|
||||
while (k < diff.length && diff[k]!.kind === "ins") {
|
||||
inss.push(diff[k]!.text);
|
||||
k++;
|
||||
}
|
||||
const pairs = Math.max(dels.length, inss.length);
|
||||
for (let p = 0; p < pairs; p++) {
|
||||
const dp = dels[p];
|
||||
const ip = inss[p];
|
||||
rows.push({
|
||||
left: dp ?? null,
|
||||
right: ip ?? null,
|
||||
kind: dp != null && ip != null ? "change" : dp != null ? "del" : "ins",
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function EditReviewModal({ modal, onResolve }: { modal: EditReviewSpec; onResolve: OnResolve }) {
|
||||
useLang();
|
||||
const search = modal.search ?? "";
|
||||
const replace = modal.replace ?? "";
|
||||
const lang = langFromPath(modal.path);
|
||||
const aLines = search.split("\n");
|
||||
const bLines = replace.split("\n");
|
||||
const rows = pairDiffRows(lineDiff(aLines, bLines));
|
||||
|
||||
return html`
|
||||
<${ModalCard}
|
||||
accent="#86efac"
|
||||
icon="◆"
|
||||
title=${t("modal.editTitle")}
|
||||
subtitle=${t("modal.editSubtitle", { path: modal.path ?? "", remaining: modal.remaining, total: modal.total })}
|
||||
>
|
||||
<div class="edit-diff-wrap">
|
||||
<div class="edit-diff-head">
|
||||
<div class="edit-diff-side edit-diff-side-old">
|
||||
<span class="edit-diff-marker">−</span> ${t("modal.before")}
|
||||
</div>
|
||||
<div class="edit-diff-side edit-diff-side-new">
|
||||
<span class="edit-diff-marker">+</span> ${t("modal.after")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="edit-diff-body">
|
||||
${rows.map(
|
||||
(row, i) => html`
|
||||
<div key=${i} class=${`edit-diff-row edit-diff-row-${row.kind}`}>
|
||||
<div class="edit-diff-cell edit-diff-cell-old">
|
||||
${
|
||||
row.left != null
|
||||
? html`<span
|
||||
class="edit-diff-line"
|
||||
dangerouslySetInnerHTML=${{ __html: hlLine(row.left, lang) || " " }}
|
||||
></span>`
|
||||
: html`<span class="edit-diff-empty"> </span>`
|
||||
}
|
||||
</div>
|
||||
<div class="edit-diff-cell edit-diff-cell-new">
|
||||
${
|
||||
row.right != null
|
||||
? html`<span
|
||||
class="edit-diff-line"
|
||||
dangerouslySetInnerHTML=${{ __html: hlLine(row.right, lang) || " " }}
|
||||
></span>`
|
||||
: html`<span class="edit-diff-empty"> </span>`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="primary" onClick=${() => onResolve("edit-review", "apply")}>${t("chat.confirmBtn")}</button>
|
||||
<button onClick=${() => onResolve("edit-review", "reject")}>${t("chat.rejectBtn")}</button>
|
||||
<button onClick=${() => onResolve("edit-review", "apply-rest-of-turn")}>${t("chat.applyRestBtn")}</button>
|
||||
<button onClick=${() => onResolve("edit-review", "flip-to-auto")}>${t("chat.flipAutoBtn")}</button>
|
||||
</div>
|
||||
<//>
|
||||
`;
|
||||
}
|
||||
|
||||
export function WorkspaceModal({ modal, onResolve }: { modal: WorkspaceSpec; onResolve: OnResolve }) {
|
||||
useLang();
|
||||
return html`
|
||||
<${ModalCard}
|
||||
accent="#fbbf24"
|
||||
icon="◇"
|
||||
title=${t("modal.workspaceTitle")}
|
||||
subtitle=${t("modal.workspaceSubtitle")}
|
||||
>
|
||||
<div class="modal-cmd"><span class="modal-cmd-prompt">→</span> <code>${modal.path}</code></div>
|
||||
<div class="modal-actions">
|
||||
<button class="primary" onClick=${() => onResolve("workspace", "switch")}>${t("modal.switchBtn")}</button>
|
||||
<button class="danger" onClick=${() => onResolve("workspace", "deny")}>${t("modal.denyBtn")}</button>
|
||||
</div>
|
||||
<//>
|
||||
`;
|
||||
}
|
||||
|
||||
export function CheckpointModal({ modal, onResolve }: { modal: CheckpointSpec; onResolve: OnResolve }) {
|
||||
useLang();
|
||||
const [reviseText, setReviseText] = useState("");
|
||||
const [staged, setStaged] = useState(false);
|
||||
const label = modal.title ? `${modal.stepId} · ${modal.title}` : modal.stepId;
|
||||
const counter = (modal.total ?? 0) > 0 ? ` (${modal.completed}/${modal.total})` : "";
|
||||
return html`
|
||||
<${ModalCard}
|
||||
accent="#a5f3fc"
|
||||
icon="✓"
|
||||
title=${t("modal.stepComplete", { counter })}
|
||||
subtitle=${label}
|
||||
>
|
||||
${
|
||||
staged
|
||||
? html`
|
||||
<textarea
|
||||
placeholder=${t("modal.revisePlaceholder")}
|
||||
rows="3"
|
||||
value=${reviseText}
|
||||
onInput=${(e: Event) => setReviseText((e.target as HTMLTextAreaElement).value)}
|
||||
></textarea>
|
||||
<div class="modal-actions">
|
||||
<button class="primary" onClick=${() => onResolve("checkpoint", "revise", reviseText)}>${t("modal.sendRevision")}</button>
|
||||
<button onClick=${() => {
|
||||
setStaged(false);
|
||||
setReviseText("");
|
||||
}}>${t("common.back")}</button>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="modal-actions">
|
||||
<button class="primary" onClick=${() => onResolve("checkpoint", "continue")}>${t("modal.continueBtn")}</button>
|
||||
<button onClick=${() => setStaged(true)}>${t("modal.reviseBtn")}</button>
|
||||
<button class="danger" onClick=${() => onResolve("checkpoint", "stop")}>${t("modal.stopBtn")}</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
<//>
|
||||
`;
|
||||
}
|
||||
|
||||
export function PickerModal({
|
||||
modal,
|
||||
onResolve,
|
||||
}: {
|
||||
modal: PickerModalSpec;
|
||||
onResolve: OnResolve;
|
||||
}) {
|
||||
useLang();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(modal.items[0]?.id ?? null);
|
||||
const [query, setQuery] = useState(modal.query ?? "");
|
||||
const [renameTarget, setRenameTarget] = useState<string | null>(null);
|
||||
const [renameText, setRenameText] = useState("");
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [newText, setNewText] = useState("");
|
||||
|
||||
const has = (a: PickerActionName) => modal.actions.includes(a);
|
||||
const selected = modal.items.find((i) => i.id === selectedId) ?? null;
|
||||
|
||||
const submitRefine = (next: string) => {
|
||||
setQuery(next);
|
||||
if (has("refine")) onResolve("picker", { action: "refine", query: next });
|
||||
};
|
||||
|
||||
const startRename = (id: string) => {
|
||||
const item = modal.items.find((i) => i.id === id);
|
||||
if (!item) return;
|
||||
setRenameTarget(id);
|
||||
setRenameText(item.title);
|
||||
};
|
||||
|
||||
const sendRename = () => {
|
||||
if (!renameTarget || !renameText.trim()) return;
|
||||
onResolve("picker", { action: "rename", id: renameTarget, text: renameText });
|
||||
setRenameTarget(null);
|
||||
setRenameText("");
|
||||
};
|
||||
|
||||
const sendNew = () => {
|
||||
onResolve("picker", newText.trim() ? { action: "new", text: newText } : { action: "new" });
|
||||
setShowNew(false);
|
||||
setNewText("");
|
||||
};
|
||||
|
||||
return html`
|
||||
<${ModalCard}
|
||||
accent="#fcd34d"
|
||||
icon="≡"
|
||||
title=${modal.title}
|
||||
subtitle=${modal.hint}
|
||||
>
|
||||
${
|
||||
has("refine")
|
||||
? html`<input
|
||||
class="modal-picker-search"
|
||||
type="search"
|
||||
placeholder=${t("modal.pickerFilter")}
|
||||
value=${query}
|
||||
onInput=${(e: Event) => submitRefine((e.target as HTMLInputElement).value)}
|
||||
/>`
|
||||
: null
|
||||
}
|
||||
<div class="modal-picker-list">
|
||||
${
|
||||
modal.items.length === 0
|
||||
? html`<div class="modal-picker-empty">${t("modal.pickerEmpty")}</div>`
|
||||
: modal.items.map(
|
||||
(it) => html`
|
||||
<button
|
||||
key=${it.id}
|
||||
class=${`modal-picker-row${it.id === selectedId ? " selected" : ""}`}
|
||||
onClick=${() => setSelectedId(it.id)}
|
||||
onDblClick=${() => has("pick") && onResolve("picker", { action: "pick", id: it.id })}
|
||||
>
|
||||
<span class="modal-picker-title">${it.title}</span>
|
||||
${it.badge ? html`<span class="modal-picker-badge">${it.badge}</span>` : null}
|
||||
${it.subtitle ? html`<span class="modal-picker-subtitle">${it.subtitle}</span>` : null}
|
||||
${it.meta ? html`<span class="modal-picker-meta">${it.meta}</span>` : null}
|
||||
</button>
|
||||
`,
|
||||
)
|
||||
}
|
||||
</div>
|
||||
${
|
||||
modal.hasMore && has("load-more")
|
||||
? html`<button
|
||||
class="modal-picker-more"
|
||||
onClick=${() => onResolve("picker", { action: "load-more" })}
|
||||
>${t("modal.pickerLoadMore")}</button>`
|
||||
: null
|
||||
}
|
||||
${
|
||||
renameTarget
|
||||
? html`
|
||||
<div class="modal-picker-form">
|
||||
<input
|
||||
type="text"
|
||||
value=${renameText}
|
||||
onInput=${(e: Event) => setRenameText((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<div class="modal-actions">
|
||||
<button class="primary" onClick=${sendRename} disabled=${!renameText.trim()}>${t("common.save")}</button>
|
||||
<button onClick=${() => setRenameTarget(null)}>${t("common.back")}</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: showNew
|
||||
? html`
|
||||
<div class="modal-picker-form">
|
||||
<input
|
||||
type="text"
|
||||
placeholder=${t("modal.pickerNewPlaceholder")}
|
||||
value=${newText}
|
||||
onInput=${(e: Event) => setNewText((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<div class="modal-actions">
|
||||
<button class="primary" onClick=${sendNew}>${t("common.add")}</button>
|
||||
<button onClick=${() => setShowNew(false)}>${t("common.back")}</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="modal-actions">
|
||||
${
|
||||
has("pick") && selected
|
||||
? html`<button
|
||||
class="primary"
|
||||
onClick=${() => onResolve("picker", { action: "pick", id: selected.id })}
|
||||
>${t("modal.pickerPick")}</button>`
|
||||
: null
|
||||
}
|
||||
${
|
||||
has("install") && selected
|
||||
? html`<button
|
||||
class="primary"
|
||||
onClick=${() => onResolve("picker", { action: "install", id: selected.id })}
|
||||
>${t("modal.pickerInstall")}</button>`
|
||||
: null
|
||||
}
|
||||
${
|
||||
has("uninstall") && selected
|
||||
? html`<button
|
||||
onClick=${() => onResolve("picker", { action: "uninstall", id: selected.id })}
|
||||
>${t("modal.pickerUninstall")}</button>`
|
||||
: null
|
||||
}
|
||||
${
|
||||
has("rename") && selected
|
||||
? html`<button onClick=${() => startRename(selected.id)}>${t("modal.pickerRename")}</button>`
|
||||
: null
|
||||
}
|
||||
${
|
||||
has("delete") && selected
|
||||
? html`<button
|
||||
class="danger"
|
||||
onClick=${() => onResolve("picker", { action: "delete", id: selected.id })}
|
||||
>${t("common.delete")}</button>`
|
||||
: null
|
||||
}
|
||||
${
|
||||
has("new")
|
||||
? html`<button onClick=${() => setShowNew(true)}>${t("modal.pickerNew")}</button>`
|
||||
: null
|
||||
}
|
||||
<button onClick=${() => onResolve("picker", { action: "cancel" })}>${t("modal.cancel")}</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
<//>
|
||||
`;
|
||||
}
|
||||
|
||||
export function ViewerModal({
|
||||
modal,
|
||||
onResolve,
|
||||
}: {
|
||||
modal: ViewerModalSpec;
|
||||
onResolve: OnResolve;
|
||||
}) {
|
||||
useLang();
|
||||
return html`
|
||||
<${ModalCard}
|
||||
accent="#67e8f9"
|
||||
icon="◇"
|
||||
title=${modal.title}
|
||||
subtitle=${modal.meta}
|
||||
>
|
||||
${
|
||||
modal.steps && modal.steps.length > 0
|
||||
? html`
|
||||
<ol class="modal-viewer-steps">
|
||||
${modal.steps.map(
|
||||
(s: ViewerStep) => html`
|
||||
<li key=${s.id} class=${`modal-viewer-step modal-viewer-step-${s.status}`}>
|
||||
<span class="modal-viewer-step-mark">${s.status === "done" ? "✓" : "·"}</span>
|
||||
<span class="modal-viewer-step-title">${s.title}</span>
|
||||
</li>
|
||||
`,
|
||||
)}
|
||||
</ol>
|
||||
`
|
||||
: null
|
||||
}
|
||||
${
|
||||
modal.body
|
||||
? html`<div class="md modal-viewer-body" dangerouslySetInnerHTML=${{ __html: marked.parse(modal.body) }}></div>`
|
||||
: null
|
||||
}
|
||||
<div class="modal-actions">
|
||||
<button onClick=${() => onResolve("viewer", { action: "close" })}>${t("modal.viewerClose")}</button>
|
||||
</div>
|
||||
<//>
|
||||
`;
|
||||
}
|
||||
|
||||
export function RevisionModal({ modal, onResolve }: { modal: RevisionSpec; onResolve: OnResolve }) {
|
||||
useLang();
|
||||
const riskColor = (r: string | undefined) =>
|
||||
r === "high" ? "#f87171" : r === "med" ? "#fbbf24" : r === "low" ? "#86efac" : "#9ca3af";
|
||||
return html`
|
||||
<${ModalCard}
|
||||
accent="#c4b5fd"
|
||||
icon="✎"
|
||||
title=${t("modal.revisionTitle")}
|
||||
subtitle=${modal.summary || modal.reason}
|
||||
>
|
||||
<div class="modal-revise-reason">${modal.reason}</div>
|
||||
<ol class="modal-revise-steps">
|
||||
${modal.remainingSteps.map(
|
||||
(s: RevisionStep) => html`
|
||||
<li key=${s.id}>
|
||||
<span class="modal-revise-dot" style=${`background:${riskColor(s.risk)}`}></span>
|
||||
<span class="modal-revise-id">${s.id}</span>
|
||||
<span class="modal-revise-title">${s.title}</span>
|
||||
<span class="modal-revise-action">${s.action}</span>
|
||||
</li>
|
||||
`,
|
||||
)}
|
||||
</ol>
|
||||
<div class="modal-actions">
|
||||
<button class="primary" onClick=${() => onResolve("revision", "accept")}>${t("modal.accept")}</button>
|
||||
<button class="danger" onClick=${() => onResolve("revision", "reject")}>${t("modal.reject")}</button>
|
||||
</div>
|
||||
<//>
|
||||
`;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+602
-20
@@ -23,6 +23,111 @@ export const en = {
|
||||
themeToggle: "Toggle theme",
|
||||
themeLight: "Light",
|
||||
themeDark: "Dark",
|
||||
skill: {
|
||||
scope: {
|
||||
builtin: "Built-in",
|
||||
global: "Global",
|
||||
project: "Project",
|
||||
},
|
||||
runAs: {
|
||||
subagent: "Subagent",
|
||||
inline: "Inline",
|
||||
},
|
||||
generic: "Skill",
|
||||
},
|
||||
exportReasoningSummary: "Reasoning Summary",
|
||||
exportToolLabel: "Tool Calls",
|
||||
exportUserLabel: "User Messages",
|
||||
empty: {
|
||||
currentWorkspace: "Current workspace",
|
||||
selectWorkspace: "Select a workspace to get started",
|
||||
suggestion0: "Start a new conversation",
|
||||
suggestion1: "Open settings",
|
||||
suggestion2: "View overview",
|
||||
suggestion3: "Browse sessions",
|
||||
welcome: "Welcome to Reasonix",
|
||||
},
|
||||
errorLabel: "Error",
|
||||
connecting: "Connecting…",
|
||||
session: {
|
||||
format: "Session",
|
||||
new: "New Session",
|
||||
},
|
||||
setup: {
|
||||
choose: "Choose a workspace",
|
||||
description: "Select a workspace to start working",
|
||||
notSelected: "No workspace selected",
|
||||
saveAndStart: "Save & Start",
|
||||
welcome: "Welcome",
|
||||
workspace: "Workspace",
|
||||
},
|
||||
tab: {
|
||||
newTab: "+ New Tab",
|
||||
newTabTitle: "Open a new tab",
|
||||
},
|
||||
titlebar: {
|
||||
clearChat: "Clear Chat",
|
||||
close: "Close",
|
||||
commandPalette: "Command Palette",
|
||||
contextPanel: "Context Panel",
|
||||
copyMd: "Copy as Markdown",
|
||||
exportMd: "Export as Markdown",
|
||||
maximize: "Maximize",
|
||||
minimize: "Minimize",
|
||||
more: "More",
|
||||
restore: "Restore",
|
||||
settings: "Settings",
|
||||
sidebar: "Sidebar",
|
||||
windowControls: "Window Controls",
|
||||
},
|
||||
toast: {
|
||||
cleared: "Chat cleared",
|
||||
copied: "Copied to clipboard",
|
||||
copiedMd: "Copied as Markdown",
|
||||
emptySession: "No messages to export",
|
||||
exportDialogTitle: "Export Chat",
|
||||
exportedMd: "Exported as Markdown",
|
||||
exportFailed: "Export failed",
|
||||
langSwitched: "Language switched",
|
||||
modelSwitched: "Model → {model}",
|
||||
effortSwitched: "Effort → {effort}",
|
||||
modeSwitched: "Mode switched",
|
||||
newSession: "New session created",
|
||||
},
|
||||
yolo: {
|
||||
toast: "YOLO mode enabled",
|
||||
},
|
||||
cmd: {
|
||||
abort: "Abort",
|
||||
btw: "BTW",
|
||||
clearChat: "Clear Chat",
|
||||
compact: "Compact",
|
||||
copyLast: "Copy Last",
|
||||
exportMd: "Export as Markdown",
|
||||
feedback: "Feedback",
|
||||
help: "Help",
|
||||
newSession: "New Session",
|
||||
retry: "Retry",
|
||||
switchModel: "Switch Model",
|
||||
toggleCurrency: "Toggle Currency",
|
||||
toggleLang: "Toggle Language",
|
||||
toggleTheme: "Toggle Theme",
|
||||
},
|
||||
header: {
|
||||
abort: "Abort",
|
||||
clickToSelect: "Click to select",
|
||||
copy: "Copy",
|
||||
copyMd: "Copy as Markdown",
|
||||
export: "Export",
|
||||
exportMd: "Export as Markdown",
|
||||
newChat: "New Chat",
|
||||
noWorkspace: "No workspace",
|
||||
running: "Running…",
|
||||
},
|
||||
jumpToBottom: "Jump to bottom",
|
||||
langEN: "English",
|
||||
langZH: "中文",
|
||||
splashSubtitle: "AI-powered coding assistant",
|
||||
},
|
||||
changes: {
|
||||
chatPlaceholder: "Ask about your code...",
|
||||
@@ -101,29 +206,51 @@ export const en = {
|
||||
saved: "saved: {fields}",
|
||||
sectionApi: "DeepSeek API",
|
||||
apiKey: "API key",
|
||||
apiKeyNotSet: "Not set",
|
||||
apiKeySave: "Save key",
|
||||
apiKeySet: "Set",
|
||||
apiSection: "API Configuration",
|
||||
notSet: "(not set)",
|
||||
replace: "replace",
|
||||
pasteKey: "paste a fresh sk-… token",
|
||||
saveKey: "Save key",
|
||||
baseUrl: "base url",
|
||||
baseUrlPlaceholder: "https://api.deepseek.com (default)",
|
||||
baseUrlHint: "Base URL for API requests",
|
||||
sectionDefaults: "Defaults",
|
||||
preset: "preset",
|
||||
presetAuto: "auto — flash → pro on hard turns",
|
||||
presetFlash: "flash — always flash, no auto-escalate",
|
||||
presetPro: "pro — always pro",
|
||||
effortSection: "Reasoning effort",
|
||||
modelCustom: "Custom model id",
|
||||
modelCustomHint: "Any OpenAI-compatible id your endpoint serves (vLLM, Ollama, Together, …).",
|
||||
modelCustomActive: "Currently running on a custom id: {model}",
|
||||
appliesNextTurn: "applies next turn",
|
||||
effort: "effort",
|
||||
effortMax: "max (default — best)",
|
||||
effortHigh: "high (cheaper / faster)",
|
||||
effortMax: "max (DeepSeek extension — rejected by stock OpenAI / vLLM)",
|
||||
effortHigh: "high (safe default — vLLM / Azure compatible)",
|
||||
effortMedium: "medium",
|
||||
effortLow: "low (fastest)",
|
||||
webSearch: "web search",
|
||||
webSearchNote: "web_fetch + web_search tools",
|
||||
sectionCompute: "Compute",
|
||||
proNext: "/pro one-shot",
|
||||
proArm: "Arm for next turn",
|
||||
proArmed: "Armed — disarms after next turn",
|
||||
proNextNote: "next turn runs on deepseek-v4-pro, then auto-disarms",
|
||||
webSearchEngine: "search engine",
|
||||
webSearchEngineBing: "bing — default, works from CN without proxy",
|
||||
webSearchEngineBingIntl: "bing-intl — international index via www.bing.com",
|
||||
webSearchEngineSearxng: "searxng — self-hosted (set endpoint via /se searxng <url>)",
|
||||
webSearchEngineMetaso: "metaso — 100/day free (CN-friendly, free key at metaso.cn)",
|
||||
webSearchEngineBaidu: "baidu — Baidu AI Search (set BAIDU_API_KEY or QIANFAN_API_KEY)",
|
||||
webSearchEngineTavily: "tavily — 1000/mo free (set TAVILY_API_KEY)",
|
||||
webSearchEnginePerplexity: "perplexity — AI-native (set PERPLEXITY_API_KEY)",
|
||||
webSearchEngineExa: "exa — AI-native 1000/mo free (set EXA_API_KEY)",
|
||||
webSearchEngineBrave: "brave — independent index, 2000/mo free (set BRAVE_SEARCH_API_KEY)",
|
||||
webSearchEngineOllama: "ollama — Ollama cloud web search (set OLLAMA_API_KEY)",
|
||||
webSearchEngineNote: "applies to the next web_search call",
|
||||
webSearchApiKeySignup: "(get key)",
|
||||
webSearchApiKeyClear: "Clear",
|
||||
webSearchApiKey: {
|
||||
baidu: "Baidu API key",
|
||||
},
|
||||
sectionBudget: "Budget",
|
||||
budget: "Budget",
|
||||
budgetHint: "Set spending limits",
|
||||
budgetPlaceholder: "Enter budget amount",
|
||||
budgetOf: "of",
|
||||
budgetSetCap: "set a cap",
|
||||
budgetCustom: "custom",
|
||||
@@ -149,21 +276,153 @@ export const en = {
|
||||
skillPaths: "skill paths",
|
||||
skillPathsPlaceholder: "/path/to/skills-a, /path/to/skills-b",
|
||||
skillPathsNote: "comma-separated · next /new or new session",
|
||||
skillsEmpty: "No skills loaded",
|
||||
skillsLoaded: "Skills loaded",
|
||||
subagentModelFlash: "flash",
|
||||
subagentModelPro: "pro",
|
||||
subagentModelHint: "Model used when this skill spawns its isolated subagent",
|
||||
sectionRuntime: "Runtime",
|
||||
activeModel: "active model",
|
||||
model: "Model",
|
||||
modelFlashDesc: "Flash — always flash",
|
||||
modelProDesc: "Pro — always pro",
|
||||
modelPricingLine: "${hit} hit · ${miss} miss · ${out} out per 1M tok",
|
||||
editMode: "edit mode",
|
||||
editModeNote: "switch from the Chat tab header",
|
||||
editModeHint: "Configure edit mode",
|
||||
showSystemEvents: "system events",
|
||||
showSystemEventsHint: "Show quiet inline notices for context compaction, abort, rate-limit.",
|
||||
shown: "shown",
|
||||
hidden: "hidden",
|
||||
sectionLanguage: "Language",
|
||||
language: "language",
|
||||
languageHint: "Switch language",
|
||||
langEn: "English",
|
||||
langZhCn: "简体中文",
|
||||
appMode: "App mode",
|
||||
appearanceSection: "Appearance",
|
||||
behaviorSection: "Behavior",
|
||||
ruleAutoApprovalHint: "Configure auto-approval rules",
|
||||
ruleAutoApprovalSection: "Auto-approval Rules",
|
||||
theme: "Theme",
|
||||
themeDark: "Dark",
|
||||
themeHint: "Switch between light and dark themes",
|
||||
themeLight: "Light",
|
||||
themeStyle: "Theme style",
|
||||
themeStyleHint: "Choose theme style",
|
||||
themeStyleGraphite: "Graphite",
|
||||
themeStyleGraphiteDesc: "Cool dark neutrals — best for long sessions.",
|
||||
themeStyleEmber: "Ember",
|
||||
themeStyleEmberDesc: "Warm dark with a stronger Reasonix orange accent.",
|
||||
themeStyleAurora: "Aurora",
|
||||
themeStyleAuroraDesc: "Teal-green dark for a softer low-light workspace.",
|
||||
themeStyleSandstone: "Sandstone",
|
||||
themeStyleSandstoneDesc: "Warm light tones — easy on bright rooms.",
|
||||
themeStylePorcelain: "Porcelain",
|
||||
themeStylePorcelainDesc: "Clean light with extra contrast.",
|
||||
themeStyleLinen: "Linen",
|
||||
themeStyleLinenDesc: "Editorial warm light with paper-like surfaces.",
|
||||
themeStyleGlacier: "Glacier",
|
||||
themeStyleGlacierDesc: "Cool light with crisp blue accents.",
|
||||
themeStyleMidnight: "Midnight",
|
||||
themeStyleMidnightDesc: "Deep dark with cooler highlights.",
|
||||
pageGeneralLabel: "General",
|
||||
pageGeneralDesc: "Theme, fonts, language and workspace.",
|
||||
pageModelsLabel: "Models",
|
||||
pageModelsDesc: "Default model and reasoning effort.",
|
||||
pageMcpLabel: "MCP servers",
|
||||
pageMcpDesc: "Connect external tool servers.",
|
||||
pageSkillsLabel: "Skills",
|
||||
pageSkillsDesc: "User-defined skills you can run with /skill.",
|
||||
pageMemoryLabel: "Memory",
|
||||
pageMemoryDesc: "Long-lived notes carried across sessions.",
|
||||
pageRulesLabel: "Rules",
|
||||
pageRulesDesc: "Auto-approval rules for shell + path access.",
|
||||
pageBillingLabel: "Billing",
|
||||
pageBillingDesc: "API key, balance and per-session budget.",
|
||||
pageShortcutsLabel: "Shortcuts",
|
||||
pageShortcutsDesc: "Keyboard shortcuts reference.",
|
||||
fontFamily: "Font family",
|
||||
fontFamilyHint: "Choose font family",
|
||||
fontFamilySans: "Sans-serif",
|
||||
fontFamilySerif: "Serif",
|
||||
fontFamilySystem: "System",
|
||||
fontScale: "Font scale",
|
||||
fontScaleHint: "Adjust font size",
|
||||
fontScaleLarge: "Large",
|
||||
fontScaleMedium: "Medium",
|
||||
fontScaleSmall: "Small",
|
||||
currentWorkspace: "Current workspace",
|
||||
defaultModelCurrent: "Current model",
|
||||
editor: "Editor",
|
||||
editorHint: "Configure editor preferences",
|
||||
maxOutput: "Max output",
|
||||
reasoningEffort: "Reasoning effort",
|
||||
reasoningEffortHint: "Adjust reasoning depth",
|
||||
sessionCost: "Session cost",
|
||||
balanceAvailable: "Available",
|
||||
balanceLabel: "Balance",
|
||||
balanceLow: "Low balance",
|
||||
cacheHitRate: "Cache hit rate",
|
||||
ctxWindow: "Context window",
|
||||
notSelected: "Not selected",
|
||||
parseError: "Parse error",
|
||||
workspaceChange: "Change",
|
||||
workspaceSection: "Workspace",
|
||||
mcpAdd: "Add",
|
||||
mcpAddSection: "Add MCP Server",
|
||||
mcpBridged: "Bridged",
|
||||
mcpConfigured: "Configured",
|
||||
mcpEmpty: "No MCP servers",
|
||||
mcpNotBridged: "Not bridged",
|
||||
mcpRemove: "Remove",
|
||||
mcpSpecFormat: "JSON format",
|
||||
mcpSpecLabel: "Server spec",
|
||||
memoryDesc: "Configure memory settings",
|
||||
memorySection: "Memory",
|
||||
shortcutAbort: "Abort",
|
||||
shortcutCloseTab: "Close Tab",
|
||||
shortcutCommandPalette: "Command Palette",
|
||||
shortcutFocusComposer: "Focus Composer",
|
||||
shortcutNewChat: "New Chat",
|
||||
shortcutNewTab: "New Tab",
|
||||
shortcutSettings: "Settings",
|
||||
shortcutSwitchTab: "Switch Tab",
|
||||
qqAccessAllowlist: "Allowlist",
|
||||
qqAccessOpen: "Open",
|
||||
qqAccessOwner: "Owner",
|
||||
qqAccessOwnerWithAllowlist: "Owner + Allowlist",
|
||||
qqAccessRuntime: "Runtime",
|
||||
qqAppId: "App ID",
|
||||
qqApplyAction: "Apply",
|
||||
qqApplyLabel: "Apply",
|
||||
qqAppSecret: "App Secret",
|
||||
qqBack: "Back",
|
||||
qqConfigure: "Configure",
|
||||
qqConfigureHint: "Set up QQ integration",
|
||||
qqConfigureTitle: "QQ Configuration",
|
||||
qqConnected: "Connected",
|
||||
qqConnecting: "Connecting…",
|
||||
qqDisconnected: "Disconnected",
|
||||
qqEnvironment: "Environment",
|
||||
qqFailed: "Failed",
|
||||
qqProduction: "Production",
|
||||
qqSandbox: "Sandbox",
|
||||
qqSave: "Save",
|
||||
qqSaveAndConnect: "Save & Connect",
|
||||
qqSection: "QQ Integration",
|
||||
qqSummaryAppId: "App ID",
|
||||
qqSummaryDetail: "Details",
|
||||
qqSummaryMissing: "Not configured",
|
||||
qqTitle: "QQ Bot",
|
||||
},
|
||||
chat: {
|
||||
modeMirror: "TUI mirror",
|
||||
modeView: "session view",
|
||||
placeholder: "Type a prompt — Enter sends, Shift+Enter for a newline · / @ for pickers",
|
||||
placeholderBusy: "wait for the current turn to finish…",
|
||||
placeholderSteerBusy: "type to steer — commands and pickers are disabled while busy",
|
||||
steerHint: "message steers the current turn (added as instruction)",
|
||||
send: "Send",
|
||||
new: "New",
|
||||
clear: "Clear",
|
||||
@@ -185,15 +444,10 @@ export const en = {
|
||||
slashCommands: "slash commands",
|
||||
projectFiles: "project files",
|
||||
effortTitle: "reasoning_effort — applies next turn",
|
||||
effortMaxTitle: "max (default — best quality)",
|
||||
effortHighTitle: "high (cheaper / faster)",
|
||||
presetTitle: "preset — model commitment",
|
||||
presetAutoTitle:
|
||||
"auto — flash baseline; auto-escalates to pro on hard turns (NEEDS_PRO / failure threshold)",
|
||||
presetFlashTitle:
|
||||
"flash — always flash; no auto-escalate. /pro still works for one-shot manual",
|
||||
presetProTitle:
|
||||
"pro — always pro; ~3× flash cost (5/31 discount). Locks in on hard architecture work.",
|
||||
effortMaxTitle: "max (DeepSeek extension)",
|
||||
effortHighTitle: "high (safe default — vLLM / Azure compatible)",
|
||||
effortMediumTitle: "medium",
|
||||
effortLowTitle: "low (fastest)",
|
||||
editGateTitle: "edit gate — Shift+Tab cycles in TUI",
|
||||
editReviewTitle: "review — both edits and non-allowlisted shell ask first",
|
||||
editAutoTitle: "auto — edits auto-apply, shell still asks",
|
||||
@@ -694,6 +948,12 @@ export const en = {
|
||||
shellBgTitle: "background process",
|
||||
shellSubtitle: "model wants to run a shell command",
|
||||
shellBgSubtitle: "long-running — keeps running after approval",
|
||||
pathTitle: "file outside sandbox",
|
||||
pathSubtitle: "{intent} via {tool}",
|
||||
pathRead: "read",
|
||||
pathWrite: "write",
|
||||
pathSandboxLabel: "sandbox",
|
||||
pathAllowPrefixLabel: "allow prefix",
|
||||
runOnce: "Run once",
|
||||
alwaysAllow: 'Always allow "{prefix}"',
|
||||
deny: "Deny",
|
||||
@@ -758,4 +1018,326 @@ export const en = {
|
||||
hoursAgo: "{n}h ago",
|
||||
daysAgo: "{n}d ago",
|
||||
},
|
||||
cards: {
|
||||
applied: "Applied",
|
||||
cacheHit: "Cache hit",
|
||||
checkpointRewind: "Checkpoint Rewind",
|
||||
cost: "Cost",
|
||||
diffApply: "Apply",
|
||||
diffApplyChanges: "Apply Changes",
|
||||
diffAwaiting: "Awaiting review",
|
||||
diffDiscard: "Discard",
|
||||
done: "Done",
|
||||
elapsed: "Elapsed",
|
||||
error: "Error",
|
||||
errorName: "Error",
|
||||
failed: "Failed",
|
||||
hits: "Hits",
|
||||
memoryCountSuffix: "memories",
|
||||
memoryName: "Memory",
|
||||
output: "Output",
|
||||
planDefaultTitle: "Plan",
|
||||
prompt: "Prompt",
|
||||
compactionMeta: "{chars} chars",
|
||||
compactionName: "Compacted history",
|
||||
reasoningComplete: "Reasoning complete",
|
||||
reasoningName: "Reasoning",
|
||||
result: "Result",
|
||||
running: "Running",
|
||||
searchName: "Search",
|
||||
shellAlwaysAllow: "Always Allow",
|
||||
shellAwaiting: "Awaiting approval",
|
||||
shellExecuteHint: "Execute this command?",
|
||||
shellReject: "Reject",
|
||||
shellRun: "Run",
|
||||
shellRunning: "Running",
|
||||
streaming: "Streaming",
|
||||
subagentDone: "Subagent done",
|
||||
subagentDoneProgress: "Subagent done ({pct}%)",
|
||||
subagentFailed: "Subagent failed",
|
||||
subagentRunning: "Subagent running",
|
||||
},
|
||||
planBadge: {
|
||||
blocked: "Blocked",
|
||||
done: "Done",
|
||||
failed: "Failed",
|
||||
pending: "Pending",
|
||||
running: "Running",
|
||||
},
|
||||
composer: {
|
||||
atHeader: "Mention files",
|
||||
close: "Close",
|
||||
commandPalette: "Command Palette",
|
||||
commands: "Commands",
|
||||
commandsLabel: "Commands",
|
||||
confirm: "Confirm",
|
||||
imageFilterName: "Images",
|
||||
insertFile: "Insert File",
|
||||
insertImage: "Insert Image",
|
||||
interrupt: "Interrupt",
|
||||
mentionFiles: "Mention Files",
|
||||
mentionLabel: "Mention",
|
||||
newline: "New Line",
|
||||
noMatches: "No matches",
|
||||
placeholder: "Type a prompt…",
|
||||
queue: "Queue",
|
||||
queueCount: "{count} queued",
|
||||
select: "Select",
|
||||
send: "Send",
|
||||
slashHeader: "Slash Commands",
|
||||
switchModel: "Switch Model",
|
||||
switchEffort: "Switch Reasoning Effort",
|
||||
workspaceRoot: "Workspace Root",
|
||||
},
|
||||
contextPanel: {
|
||||
allow: "Allow",
|
||||
ask: "Ask",
|
||||
autoApproveTitle: "Auto-approve",
|
||||
cacheKey: "Cache",
|
||||
contextTokens: "Context tokens",
|
||||
fileInContext: "In context",
|
||||
fileModified: "Modified",
|
||||
filesCount: "{count} files",
|
||||
filesTab: "Files",
|
||||
filesTitle: "Files",
|
||||
freeKey: "Free",
|
||||
itemCount: "{count} items",
|
||||
mcpConfigured: "Configured",
|
||||
mcpConnecting: "Connecting…",
|
||||
mcpDisabled: "Disabled",
|
||||
mcpEmpty: "No MCP servers",
|
||||
mcpFailed: "Failed",
|
||||
mcpReady: "Ready",
|
||||
mcpReadyAll: "All ready",
|
||||
mcpReadySome: "Some ready",
|
||||
mcpTitle: "MCP",
|
||||
mcpTools: "Tools",
|
||||
memoryTab: "Memory",
|
||||
memoryTitle: "Memory",
|
||||
noFilesMsg: "No files in context",
|
||||
noMemoriesMsg: "No memories",
|
||||
reservedKey: "Reserved",
|
||||
ruleReadOnly: "Read-only",
|
||||
ruleReview: "Review",
|
||||
ruleShellAllowlist: "Shell allowlist",
|
||||
rulesTab: "Rules",
|
||||
ruleWritesAsk: "Writes ask",
|
||||
ruleYolo: "YOLO",
|
||||
scopeGlobal: "Global",
|
||||
scopeProject: "Project",
|
||||
toolsTab: "Tools",
|
||||
usedKey: "Used",
|
||||
},
|
||||
dragDrop: {
|
||||
overlay: "Drop files here",
|
||||
},
|
||||
extraCards: {
|
||||
approve: "Approve",
|
||||
balance: "Balance",
|
||||
cache: "Cache",
|
||||
cacheHit: "Cache hit",
|
||||
contextWindow: "Context window",
|
||||
doctorTitle: "Diagnostics",
|
||||
output: "Output",
|
||||
prompt: "Prompt",
|
||||
reasoning: "Reasoning",
|
||||
reject: "Reject",
|
||||
sessionCost: "Session cost",
|
||||
sessionUsage: "Session usage",
|
||||
topToolsUsage: "Top tools",
|
||||
unknownKind: "Unknown",
|
||||
memoryUser: "User",
|
||||
memoryFeedback: "Feedback",
|
||||
memoryProject: "Project",
|
||||
memoryReference: "Reference",
|
||||
},
|
||||
jobs: {
|
||||
empty: "No background jobs",
|
||||
exited: "Exited",
|
||||
kbClose: "Close",
|
||||
kbToggle: "Toggle",
|
||||
running: "Running",
|
||||
stop: "Stop",
|
||||
stopAll: "Stop All",
|
||||
stopAllTip: "Stop all jobs",
|
||||
stopOne: "Stop",
|
||||
title: "Background Jobs",
|
||||
},
|
||||
live: {
|
||||
deliveredWaiting: "Waiting for next message",
|
||||
reasoning: "Reasoning",
|
||||
running: "Running",
|
||||
secondsAgo: "{n}s ago",
|
||||
you: "You",
|
||||
},
|
||||
markdown: {
|
||||
copied: "Copied!",
|
||||
copy: "Copy",
|
||||
externalLinkTitle: "Open in new tab",
|
||||
filePillTitle: "Open file",
|
||||
localLinkTitle: "Open link",
|
||||
},
|
||||
palette: {
|
||||
abort: "Abort",
|
||||
abortHint: "Abort current request",
|
||||
about: "About",
|
||||
clearChat: "Clear Chat",
|
||||
clearChatHint: "Clear conversation",
|
||||
closeTab: "Close Tab",
|
||||
closeTabHint: "Close current tab",
|
||||
copyConv: "Copy Conversation",
|
||||
copyConvHint: "Copy conversation as Markdown",
|
||||
copyLast: "Copy Last Response",
|
||||
copyLastHint: "Copy last assistant message",
|
||||
countSuffix: "items",
|
||||
empty: "No matching commands",
|
||||
exportMd: "Export as Markdown",
|
||||
exportMdHint: "Export chat as Markdown file",
|
||||
focusComposer: "Focus Composer",
|
||||
footClose: "Close",
|
||||
footMove: "Move",
|
||||
footRun: "Run",
|
||||
groupAction: "Actions",
|
||||
groupNav: "Navigation",
|
||||
groupSettings: "Settings",
|
||||
groupWorkspace: "Workspace",
|
||||
newChat: "New Chat",
|
||||
newChatHint: "Start a new conversation",
|
||||
newTab: "New Tab",
|
||||
newTabHint: "Open a new tab",
|
||||
pickWorkspace: "Switch Workspace",
|
||||
pickWorkspaceHint: "Change workspace",
|
||||
searchPlaceholder: "Search commands…",
|
||||
settings: "Settings",
|
||||
settingsHint: "Open settings",
|
||||
},
|
||||
panel: {
|
||||
renderError: "Panel crashed",
|
||||
},
|
||||
sidebarPanel: {
|
||||
approvalRules: "Approval Rules",
|
||||
cancel: "Cancel",
|
||||
commandPalette: "Command Palette",
|
||||
daysAgo: "{n}d ago",
|
||||
delete: "Delete",
|
||||
deleteSession: "Delete Session",
|
||||
hoursAgo: "{n}h ago",
|
||||
justNow: "Just now",
|
||||
loading: "Loading…",
|
||||
messageCount: "{count} messages",
|
||||
minutesAgo: "{n}m ago",
|
||||
newChat: "New Chat",
|
||||
noMatches: "No matches",
|
||||
noSessions: "No sessions",
|
||||
recent: "Recent",
|
||||
searchSessions: "Search sessions…",
|
||||
sessionTitle: "Sessions",
|
||||
settings: "Settings",
|
||||
weeksAgo: "{n}w ago",
|
||||
},
|
||||
statusbar: {
|
||||
balance: "Balance",
|
||||
busy: "Busy",
|
||||
cache: "Cache",
|
||||
cacheHit: "Cache hit",
|
||||
jobs: "Jobs",
|
||||
jobsTip: "Background jobs",
|
||||
offline: "Offline",
|
||||
online: "Online",
|
||||
switchCurrency: "Switch currency",
|
||||
switchTheme: "Switch theme",
|
||||
switchWorkspace: "Switch workspace",
|
||||
themeDark: "Dark",
|
||||
themeLight: "Light",
|
||||
themeStyleGraphite: "Graphite",
|
||||
themeStyleEmber: "Ember",
|
||||
themeStyleAurora: "Aurora",
|
||||
themeStyleSandstone: "Sandstone",
|
||||
themeStylePorcelain: "Porcelain",
|
||||
themeStyleLinen: "Linen",
|
||||
themeStyleGlacier: "Glacier",
|
||||
themeStyleMidnight: "Midnight",
|
||||
thisTurn: "This turn",
|
||||
tokens: "Tokens",
|
||||
},
|
||||
thread: {
|
||||
activePlan: "Active plan",
|
||||
allowRead: "Allow read",
|
||||
allowWrite: "Allow write",
|
||||
alwaysAllow: "Always allow",
|
||||
alwaysAllowPrefix: "Always allow {prefix}",
|
||||
approve: "Approve",
|
||||
approveRewrite: "Approve rewrite",
|
||||
cancel: "Cancel",
|
||||
checkpointKind: "Checkpoint",
|
||||
checkpointSub: "Save current state",
|
||||
checkpointTitle: "Create checkpoint",
|
||||
collapse: "Collapse",
|
||||
continue: "Continue",
|
||||
copyMessage: "Copy message",
|
||||
copyResponse: "Copy response",
|
||||
execute: "Execute",
|
||||
keepOriginal: "Keep original",
|
||||
keepSteps: "Keep steps",
|
||||
optionCount: "{count} options",
|
||||
pathAccessKind: "Path access",
|
||||
planConfirmationKind: "Plan approval",
|
||||
planRevisionKind: "Plan revision",
|
||||
planRunning: "Plan in progress",
|
||||
planStepCount: "Step {done}/{total}",
|
||||
readPathOutsideSandbox: "Read path outside sandbox",
|
||||
refine: "Refine",
|
||||
reject: "Reject",
|
||||
revise: "Revise",
|
||||
revisionMeta: "Revision",
|
||||
rewritePlan: "Rewrite plan",
|
||||
risk: "Risk assessment",
|
||||
riskMedium: "Medium risk",
|
||||
runBackgroundCommand: "Run in background",
|
||||
runCommand: "Run command",
|
||||
shellConfirmationKind: "Shell confirmation",
|
||||
startPlan: "Start plan",
|
||||
stop: "Stop",
|
||||
subagent: "Subagent",
|
||||
userChoiceKind: "User choice",
|
||||
writePathOutsideSandbox: "Write path outside sandbox",
|
||||
you: "You",
|
||||
},
|
||||
workdir: {
|
||||
browse: "Browse…",
|
||||
cancel: "Cancel",
|
||||
empty: "No workspaces",
|
||||
emptyDir: "No subdirectories.",
|
||||
go: "Go",
|
||||
goTip: "Navigate to this path",
|
||||
loading: "Loading…",
|
||||
openHere: "Open this folder",
|
||||
pathPlaceholder: "Absolute path…",
|
||||
promptWeb: "Enter the absolute path of the workspace to open:",
|
||||
searchPlaceholder: "Search workspaces…",
|
||||
title: "Workspace",
|
||||
},
|
||||
about: {
|
||||
close: "Close",
|
||||
repo: "Repository",
|
||||
sidebarLabel: "About",
|
||||
tagline: "AI-powered coding assistant",
|
||||
version: "Version",
|
||||
},
|
||||
effort: {
|
||||
lowDesc: "fastest — minimal reasoning",
|
||||
mediumDesc: "balanced",
|
||||
highDesc: "default — safe for vLLM / Azure",
|
||||
maxDesc: "DeepSeek extension; rejected by stock OpenAI / vLLM",
|
||||
},
|
||||
editMode: {
|
||||
plan: "Plan",
|
||||
planHint: "read-only · all write tools refuse to dispatch",
|
||||
review: "Review",
|
||||
reviewHint: "both edits and non-allowlisted shell ask first",
|
||||
auto: "Auto",
|
||||
autoHint: "edits auto-apply, shell still asks",
|
||||
yolo: "YOLO",
|
||||
yoloHint: "edits AND shell auto-run, allowlist bypassed",
|
||||
},
|
||||
};
|
||||
+115
-6
@@ -1,8 +1,117 @@
|
||||
import { createT } from "../lib/i18n.js";
|
||||
import { en } from "./en.js";
|
||||
import { zhCN } from "./zh-CN.js";
|
||||
import { useEffect, useState } from "react";
|
||||
import { de } from "./de";
|
||||
import { en } from "./en";
|
||||
import { ja } from "./ja";
|
||||
import { ru } from "./ru";
|
||||
import { zhCN } from "./zh-CN";
|
||||
|
||||
export { getLang, initLangFromServer, setLang, useLang, onLangChange } from "../lib/i18n.js";
|
||||
export type { DashboardLang } from "../lib/i18n.js";
|
||||
export type Lang = "en" | "zh-CN" | "de" | "ja" | "ru";
|
||||
|
||||
export const t = createT({ en, "zh-CN": zhCN });
|
||||
const SUPPORTED_LANGS: readonly Lang[] = ["en", "zh-CN", "de", "ja", "ru"];
|
||||
const SUPPORTED: ReadonlySet<Lang> = new Set<Lang>(SUPPORTED_LANGS);
|
||||
const LANG_LABELS: Record<Lang, string> = {
|
||||
en: "English",
|
||||
"zh-CN": "简体中文",
|
||||
de: "Deutsch",
|
||||
ja: "日本語",
|
||||
ru: "Русский",
|
||||
};
|
||||
const STORAGE_KEY = "reasonix.lang";
|
||||
|
||||
type Listener = () => void;
|
||||
const listeners: Listener[] = [];
|
||||
|
||||
function detectDefault(): Lang {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored && SUPPORTED.has(stored as Lang)) return stored as Lang;
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
const nav = typeof navigator !== "undefined" ? navigator.language : "";
|
||||
const lower = nav.toLowerCase();
|
||||
if (lower.startsWith("zh")) return "zh-CN";
|
||||
if (lower.startsWith("de")) return "de";
|
||||
if (lower.startsWith("ja")) return "ja";
|
||||
if (lower.startsWith("ru")) return "ru";
|
||||
return "en";
|
||||
}
|
||||
|
||||
let currentLang: Lang = detectDefault();
|
||||
|
||||
function syncHtmlLang(lang: Lang): void {
|
||||
if (typeof document === "undefined") return;
|
||||
try {
|
||||
document.documentElement.lang = lang;
|
||||
} catch {
|
||||
/* document not ready */
|
||||
}
|
||||
}
|
||||
|
||||
syncHtmlLang(currentLang);
|
||||
|
||||
export function getLang(): Lang {
|
||||
return currentLang;
|
||||
}
|
||||
|
||||
export function getSupportedLangs(): readonly Lang[] {
|
||||
return SUPPORTED_LANGS;
|
||||
}
|
||||
|
||||
export function getLangLabel(lang: Lang): string {
|
||||
return LANG_LABELS[lang];
|
||||
}
|
||||
|
||||
export function setLang(lang: Lang): void {
|
||||
if (!SUPPORTED.has(lang) || lang === currentLang) return;
|
||||
currentLang = lang;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, lang);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
syncHtmlLang(lang);
|
||||
for (const cb of listeners) cb();
|
||||
}
|
||||
|
||||
export function useLang(): Lang {
|
||||
const [, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
const cb = () => setTick((n) => n + 1);
|
||||
listeners.push(cb);
|
||||
return () => {
|
||||
const idx = listeners.indexOf(cb);
|
||||
if (idx >= 0) listeners.splice(idx, 1);
|
||||
};
|
||||
}, []);
|
||||
return currentLang;
|
||||
}
|
||||
|
||||
const dicts = { en, "zh-CN": zhCN, de, ja, ru } as const;
|
||||
|
||||
type Dict = typeof en;
|
||||
type Path<T, K extends keyof T = keyof T> = K extends string
|
||||
? T[K] extends Record<string, unknown>
|
||||
? `${K}.${Path<T[K]>}`
|
||||
: K
|
||||
: never;
|
||||
export type TKey = Path<Dict>;
|
||||
|
||||
function resolve(dict: Dict, key: string): string {
|
||||
const parts = key.split(".");
|
||||
let cursor: unknown = dict;
|
||||
for (const p of parts) {
|
||||
if (cursor && typeof cursor === "object" && p in (cursor as Record<string, unknown>)) {
|
||||
cursor = (cursor as Record<string, unknown>)[p];
|
||||
} else {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
return typeof cursor === "string" ? cursor : key;
|
||||
}
|
||||
|
||||
export function t(key: TKey, params?: Record<string, string | number>): string {
|
||||
const raw = resolve(dicts[currentLang], key);
|
||||
if (!params) return raw;
|
||||
return raw.replace(/\{(\w+)\}/g, (_, k) => (k in params ? String(params[k]) : `{${k}}`));
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
import { en } from "./en";
|
||||
|
||||
export const ja: typeof en = {
|
||||
...en,
|
||||
app: {
|
||||
...en.app,
|
||||
sectionWorkspace: "ワークスペース",
|
||||
sectionObserve: "監視",
|
||||
sectionConfigure: "設定",
|
||||
tabChat: "チャット",
|
||||
tabPlans: "プラン",
|
||||
tabSessions: "セッション",
|
||||
tabOverview: "概要",
|
||||
tabUsage: "使用量",
|
||||
tabSystem: "システム",
|
||||
tabSemantic: "セマンティック",
|
||||
tabTools: "ツール",
|
||||
tabPermissions: "権限",
|
||||
tabMcp: "MCP",
|
||||
tabSkills: "スキル",
|
||||
tabMemory: "メモリ",
|
||||
tabHooks: "フック",
|
||||
tabSettings: "設定",
|
||||
sectionChanges: "変更",
|
||||
tabChanges: "変更",
|
||||
footer: "127.0.0.1 のみ · トークン認証",
|
||||
themeToggle: "テーマ切替",
|
||||
themeLight: "ライト",
|
||||
themeDark: "ダーク",
|
||||
skill: {
|
||||
...en.app.skill,
|
||||
scope: {
|
||||
...en.app.skill.scope,
|
||||
builtin: "ビルトイン",
|
||||
global: "グローバル",
|
||||
project: "プロジェクト",
|
||||
},
|
||||
runAs: {
|
||||
...en.app.skill.runAs,
|
||||
subagent: "サブエージェント",
|
||||
inline: "インライン",
|
||||
},
|
||||
generic: "スキル",
|
||||
},
|
||||
exportReasoningSummary: "推論サマリー",
|
||||
exportToolLabel: "ツール呼び出し",
|
||||
exportUserLabel: "ユーザーメッセージ",
|
||||
empty: {
|
||||
...en.app.empty,
|
||||
currentWorkspace: "現在のワークスペース",
|
||||
selectWorkspace: "ワークスペースを選択して開始",
|
||||
suggestion0: "新しい会話を始める",
|
||||
suggestion1: "設定を開く",
|
||||
suggestion2: "概要を表示",
|
||||
suggestion3: "セッションを閲覧",
|
||||
welcome: "Reasonix へようこそ",
|
||||
},
|
||||
errorLabel: "エラー",
|
||||
connecting: "接続中…",
|
||||
session: {
|
||||
...en.app.session,
|
||||
format: "セッション",
|
||||
new: "新しいセッション",
|
||||
},
|
||||
setup: {
|
||||
...en.app.setup,
|
||||
choose: "ワークスペースを選択",
|
||||
description: "作業を開始するワークスペースを選択",
|
||||
notSelected: "ワークスペース未選択",
|
||||
saveAndStart: "保存して開始",
|
||||
welcome: "ようこそ",
|
||||
workspace: "ワークスペース",
|
||||
},
|
||||
tab: {
|
||||
...en.app.tab,
|
||||
newTab: "+ 新しいタブ",
|
||||
newTabTitle: "新しいタブを開く",
|
||||
},
|
||||
titlebar: {
|
||||
...en.app.titlebar,
|
||||
clearChat: "チャットクリア",
|
||||
close: "閉じる",
|
||||
commandPalette: "コマンドパレット",
|
||||
contextPanel: "コンテキストパネル",
|
||||
copyMd: "Markdownでコピー",
|
||||
exportMd: "Markdownでエクスポート",
|
||||
maximize: "最大化",
|
||||
minimize: "最小化",
|
||||
more: "その他",
|
||||
restore: "元に戻す",
|
||||
settings: "設定",
|
||||
sidebar: "サイドバー",
|
||||
windowControls: "ウィンドウ操作",
|
||||
},
|
||||
toast: {
|
||||
...en.app.toast,
|
||||
cleared: "チャットをクリアしました",
|
||||
copied: "クリップボードにコピーしました",
|
||||
copiedMd: "Markdownでコピーしました",
|
||||
emptySession: "エクスポートするメッセージがありません",
|
||||
exportDialogTitle: "チャットをエクスポート",
|
||||
exportedMd: "Markdownでエクスポートしました",
|
||||
exportFailed: "エクスポートに失敗しました",
|
||||
langSwitched: "言語を切り替えました",
|
||||
modelSwitched: "モデル → {model}",
|
||||
effortSwitched: "努力 → {effort}",
|
||||
modeSwitched: "モードを切り替えました",
|
||||
newSession: "新しいセッションを作成しました",
|
||||
},
|
||||
yolo: {
|
||||
...en.app.yolo,
|
||||
toast: "YOLOモードが有効です",
|
||||
},
|
||||
cmd: {
|
||||
...en.app.cmd,
|
||||
abort: "中断",
|
||||
btw: "BTW",
|
||||
clearChat: "チャットクリア",
|
||||
compact: "圧縮",
|
||||
copyLast: "最後をコピー",
|
||||
exportMd: "Markdownでエクスポート",
|
||||
feedback: "フィードバック",
|
||||
help: "ヘルプ",
|
||||
newSession: "新しいセッション",
|
||||
retry: "再試行",
|
||||
switchModel: "モデル切替",
|
||||
toggleCurrency: "通貨切替",
|
||||
toggleLang: "言語切替",
|
||||
toggleTheme: "テーマ切替",
|
||||
},
|
||||
header: {
|
||||
...en.app.header,
|
||||
abort: "中断",
|
||||
clickToSelect: "クリックで選択",
|
||||
copy: "コピー",
|
||||
copyMd: "Markdownでコピー",
|
||||
export: "エクスポート",
|
||||
exportMd: "Markdownでエクスポート",
|
||||
newChat: "新しいチャット",
|
||||
noWorkspace: "ワークスペースなし",
|
||||
running: "実行中…",
|
||||
},
|
||||
jumpToBottom: "最下部にジャンプ",
|
||||
langEN: "English",
|
||||
langZH: "中文",
|
||||
splashSubtitle: "AI搭載コーディングアシスタント",
|
||||
},
|
||||
changes: {
|
||||
...en.changes,
|
||||
chatPlaceholder: "コードについて質問…",
|
||||
chatWelcome: "変更 — プロジェクトファイルについて質問してください。",
|
||||
chatSend: "送信",
|
||||
viewerPlaceholder: "表示するファイルを選択",
|
||||
treeEmpty: "(空)",
|
||||
tabClose: "タブを閉じる",
|
||||
newConversation: "新規",
|
||||
clearConversation: "クリア",
|
||||
newTitle: "/new — 会話コンテキストを消去",
|
||||
clearTitle: "/clear — 表示スクロールバックを消去",
|
||||
newConfirmBusy: "ターンが実行中です。中断して新しい会話を始めますか?",
|
||||
newConfirm: "現在の会話をクリアして新しく始めますか?",
|
||||
newToast: "新しい会話",
|
||||
clearToast: "スクロールバックをクリアしました",
|
||||
newFailed: "/new 失敗: {error}",
|
||||
clearFailed: "/clear 失敗: {error}",
|
||||
chatSendBtn: "送信",
|
||||
fileTreeTitle: "ファイル",
|
||||
codeViewerTitle: "コードビューア",
|
||||
chatPanelTitle: "チャット",
|
||||
loadingFiles: "プロジェクトファイルを読み込み中…",
|
||||
review: "レビュー",
|
||||
allFiles: "すべてのファイル",
|
||||
changes: "変更",
|
||||
commentLabel: "行にコメント",
|
||||
commentPlaceholder: "コメントを追加…",
|
||||
commentCancel: "キャンセル",
|
||||
commentSubmit: "コメント",
|
||||
commentLine: "行",
|
||||
commentEdit: "編集",
|
||||
commentDelete: "削除",
|
||||
diffSourceGit: "Gitの変更",
|
||||
diffSourceSession: "前回のセッション",
|
||||
diffSourceCheckpoint: "チェックポイント",
|
||||
checkpointEmpty: "このワークスペースにチェックポイントはまだありません。",
|
||||
restoreBtn: "復元",
|
||||
restoreConfirm: '"{name}" を復元しますか?現在のファイルが上書きされます。',
|
||||
deleteBtn: "削除",
|
||||
deleteConfirm:
|
||||
'チェックポイント "{name}" を削除しますか?スナップショットは削除され、ファイルはそのままです。',
|
||||
createBtn: "スナップショット",
|
||||
createPlaceholder: "スナップショット名…",
|
||||
backToList: "リストに戻る",
|
||||
reviewEmpty: "保留中のレビューはありません。",
|
||||
diffStyleUnified: "統合",
|
||||
diffStyleSplit: "分割",
|
||||
expandAll: "すべて展開",
|
||||
collapseAll: "すべて折りたたみ",
|
||||
createFailed: "作成に失敗しました",
|
||||
restoreFailed: "復元に失敗しました",
|
||||
deleteFailed: "削除に失敗しました",
|
||||
},
|
||||
common: {
|
||||
...en.common,
|
||||
loading: "読み込み中…",
|
||||
loadingFailed: "{name} 失敗: {error}",
|
||||
back: "← 戻る",
|
||||
save: "保存",
|
||||
remove: "削除",
|
||||
cancel: "キャンセル",
|
||||
delete: "削除",
|
||||
add: "追加",
|
||||
confirm: "確認",
|
||||
noData: "まだ {name} がありません。",
|
||||
all: "すべて",
|
||||
yes: "はい",
|
||||
no: "いいえ",
|
||||
on: "オン",
|
||||
off: "オフ",
|
||||
enabled: "有効",
|
||||
disabled: "無効",
|
||||
},
|
||||
settings: {
|
||||
...en.settings,
|
||||
title: "設定",
|
||||
loading: "設定を読み込み中…",
|
||||
saved: "保存: {fields}",
|
||||
sectionApi: "DeepSeek API",
|
||||
apiKey: "APIキー",
|
||||
apiKeyNotSet: "未設定",
|
||||
apiKeySave: "キーを保存",
|
||||
apiKeySet: "設定済み",
|
||||
apiSection: "API設定",
|
||||
notSet: "(未設定)",
|
||||
replace: "置換",
|
||||
pasteKey: "新しい sk-… トークンを貼り付け",
|
||||
saveKey: "キーを保存",
|
||||
baseUrl: "ベースURL",
|
||||
baseUrlPlaceholder: "https://api.deepseek.com (デフォルト)",
|
||||
baseUrlHint: "APIリクエストのベースURL",
|
||||
sectionDefaults: "デフォルト",
|
||||
effortSection: "推論努力",
|
||||
modelCustom: "カスタムモデルID",
|
||||
modelCustomHint: "エンドポイントが提供するOpenAI互換ID (vLLM, Ollama, Together, …)。",
|
||||
modelCustomActive: "現在カスタムIDで実行中: {model}",
|
||||
appliesNextTurn: "次のターンから適用",
|
||||
effort: "努力",
|
||||
effortMax: "max (DeepSeek拡張 — 標準OpenAI / vLLMでは拒否)",
|
||||
effortHigh: "high (安全なデフォルト — vLLM / Azure互換)",
|
||||
effortMedium: "medium",
|
||||
effortLow: "low (最速)",
|
||||
webSearch: "Web検索",
|
||||
webSearchNote: "web_fetch + web_search ツール",
|
||||
webSearchEngine: "検索エンジン",
|
||||
webSearchEngineBing: "bing — デフォルト、プロキシなしで中国から利用可",
|
||||
webSearchEngineBingIntl: "bing-intl — www.bing.com 経由の国際版インデックス",
|
||||
webSearchEngineSearxng: "searxng — セルフホスト (/se searxng <url> でエンドポイント設定)",
|
||||
webSearchEngineMetaso: "metaso — 100回/日無料 (中国対応、metaso.cnで無料キー取得)",
|
||||
webSearchEngineBaidu: "baidu — Baidu AI Search (BAIDU_API_KEY または QIANFAN_API_KEY を設定)",
|
||||
webSearchEngineTavily: "tavily — 1000回/月無料 (TAVILY_API_KEY を設定)",
|
||||
webSearchEnginePerplexity: "perplexity — AIネイティブ (PERPLEXITY_API_KEY を設定)",
|
||||
webSearchEngineExa: "exa — AIネイティブ 1000回/月無料 (EXA_API_KEY を設定)",
|
||||
webSearchEngineBrave: "brave — 独立インデックス、2000回/月無料 (BRAVE_SEARCH_API_KEY を設定)",
|
||||
webSearchEngineOllama: "ollama — Ollama クラウド Web 検索 (OLLAMA_API_KEY を設定)",
|
||||
webSearchEngineNote: "次の web_search 呼び出しに適用",
|
||||
webSearchApiKeySignup: "(キーを取得)",
|
||||
webSearchApiKeyClear: "クリア",
|
||||
webSearchApiKey: {
|
||||
...en.settings.webSearchApiKey,
|
||||
baidu: "Baidu APIキー",
|
||||
},
|
||||
sectionBudget: "予算",
|
||||
budget: "予算",
|
||||
budgetHint: "支出制限を設定",
|
||||
budgetPlaceholder: "予算額を入力",
|
||||
budgetOf: "/",
|
||||
budgetSetCap: "上限を設定",
|
||||
budgetCustom: "カスタム",
|
||||
budgetBumpHint: "上限を引き上げて続行",
|
||||
budgetClear: "上限をクリア",
|
||||
budgetIdleLine: "80%で警告 · 100%超過で拒否",
|
||||
budgetWarnLine: "上限接近 — ループは100%超過で拒否",
|
||||
budgetRefusing: "上限到達 — 引き上げまたはクリアまで次のターンは拒否",
|
||||
sectionLoop: "ループ",
|
||||
loopIdleHint: "固定間隔でプロンプトを自動再送信。",
|
||||
loopCostHint: "各イテレーションのコスト ~{cost} (前回ターン)。",
|
||||
loopInterval: "間隔",
|
||||
loopCustom: "カスタム",
|
||||
loopRangeError: "間隔は5秒〜6時間の範囲である必要があります",
|
||||
loopPrompt: "プロンプト",
|
||||
loopPromptPlaceholder: "例: デプロイ状態を確認してエラーを報告",
|
||||
loopStart: "ループ開始",
|
||||
loopStop: "停止",
|
||||
loopRunning: "実行中",
|
||||
loopIter: "反復 {iter}",
|
||||
loopFiresIn: "{remaining} 後に実行",
|
||||
sectionSkills: "スキル",
|
||||
skillPaths: "スキルパス",
|
||||
skillPathsPlaceholder: "/path/to/skills-a, /path/to/skills-b",
|
||||
skillPathsNote: "カンマ区切り · 次の /new または新規セッション",
|
||||
skillsEmpty: "スキルが読み込まれていません",
|
||||
skillsLoaded: "スキル読み込み済み",
|
||||
subagentModelFlash: "flash",
|
||||
subagentModelPro: "pro",
|
||||
subagentModelHint: "このスキルが分離サブエージェントを生成するときに使用するモデル",
|
||||
sectionRuntime: "ランタイム",
|
||||
activeModel: "アクティブモデル",
|
||||
model: "モデル",
|
||||
modelFlashDesc: "Flash — 常にflash",
|
||||
modelProDesc: "Pro — 常にpro",
|
||||
modelPricingLine: "${hit} ヒット · ${miss} ミス · ${out} 出力 / 1M tok",
|
||||
editMode: "編集モード",
|
||||
editModeNote: "チャットタブヘッダーから切り替え",
|
||||
editModeHint: "編集モードを設定",
|
||||
showSystemEvents: "システムイベント",
|
||||
showSystemEventsHint: "コンテキスト圧縮、中断、レート制限の通知を表示。",
|
||||
shown: "表示",
|
||||
hidden: "非表示",
|
||||
sectionLanguage: "言語",
|
||||
language: "言語",
|
||||
languageHint: "言語を切り替え",
|
||||
langEn: "English",
|
||||
langZhCn: "简体中文",
|
||||
appMode: "アプリモード",
|
||||
appearanceSection: "外観",
|
||||
behaviorSection: "動作",
|
||||
ruleAutoApprovalHint: "自動承認ルールを設定",
|
||||
ruleAutoApprovalSection: "自動承認ルール",
|
||||
theme: "テーマ",
|
||||
themeDark: "ダーク",
|
||||
themeHint: "ライト/ダークテーマを切り替え",
|
||||
themeLight: "ライト",
|
||||
themeStyle: "テーマスタイル",
|
||||
themeStyleHint: "テーマスタイルを選択",
|
||||
themeStyleGraphite: "グラファイト",
|
||||
themeStyleGraphiteDesc: "クールなダークニュートラル — 長時間のセッションに最適。",
|
||||
themeStyleEmber: "エンバー",
|
||||
themeStyleEmberDesc: "Reasonixのオレンジを強めた暖かいダーク。",
|
||||
themeStyleAurora: "オーロラ",
|
||||
themeStyleAuroraDesc: "低照度でも柔らかいティールグリーンのダーク。",
|
||||
themeStyleSandstone: "サンドストーン",
|
||||
themeStyleSandstoneDesc: "温かみのあるライトトーン — 明るい部屋に最適。",
|
||||
themeStylePorcelain: "ポーセリン",
|
||||
themeStylePorcelainDesc: "クリーンなライト、高コントラスト。",
|
||||
themeStyleLinen: "リネン",
|
||||
themeStyleLinenDesc: "紙のような表面を持つ編集調の暖かいライト。",
|
||||
themeStyleGlacier: "グレイシャー",
|
||||
themeStyleGlacierDesc: "くっきりしたブルーアクセントのクールなライト。",
|
||||
themeStyleMidnight: "ミッドナイト",
|
||||
themeStyleMidnightDesc: "深いダーク、クールなハイライト。",
|
||||
pageGeneralLabel: "一般",
|
||||
pageGeneralDesc: "テーマ、フォント、言語、ワークスペース。",
|
||||
pageModelsLabel: "モデル",
|
||||
pageModelsDesc: "デフォルトモデルと推論努力。",
|
||||
pageMcpLabel: "MCPサーバー",
|
||||
pageMcpDesc: "外部ツールサーバーに接続。",
|
||||
pageSkillsLabel: "スキル",
|
||||
pageSkillsDesc: "/skill で実行できるユーザー定義スキル。",
|
||||
pageMemoryLabel: "メモリ",
|
||||
pageMemoryDesc: "セッションをまたいで保持される長期メモ。",
|
||||
pageRulesLabel: "ルール",
|
||||
pageRulesDesc: "シェル+パスアクセスの自動承認ルール。",
|
||||
pageBillingLabel: "請求",
|
||||
pageBillingDesc: "APIキー、残高、セッションごとの予算。",
|
||||
pageShortcutsLabel: "ショートカット",
|
||||
pageShortcutsDesc: "キーボードショートカットリファレンス。",
|
||||
fontFamily: "フォントファミリー",
|
||||
fontFamilyHint: "フォントファミリーを選択",
|
||||
fontFamilySans: "サンセリフ",
|
||||
fontFamilySerif: "セリフ",
|
||||
fontFamilySystem: "システム",
|
||||
fontScale: "フォントスケール",
|
||||
fontScaleHint: "フォントサイズを調整",
|
||||
fontScaleLarge: "大",
|
||||
fontScaleMedium: "中",
|
||||
fontScaleSmall: "小",
|
||||
currentWorkspace: "現在のワークスペース",
|
||||
defaultModelCurrent: "現在のモデル",
|
||||
editor: "エディタ",
|
||||
editorHint: "エディタ設定を構成",
|
||||
maxOutput: "最大出力",
|
||||
reasoningEffort: "推論努力",
|
||||
reasoningEffortHint: "推論の深さを調整",
|
||||
sessionCost: "セッションコスト",
|
||||
balanceAvailable: "利用可能",
|
||||
balanceLabel: "残高",
|
||||
balanceLow: "残高不足",
|
||||
cacheHitRate: "キャッシュヒット率",
|
||||
ctxWindow: "コンテキストウィンドウ",
|
||||
notSelected: "未選択",
|
||||
parseError: "パースエラー",
|
||||
workspaceChange: "変更",
|
||||
workspaceSection: "ワークスペース",
|
||||
mcpAdd: "追加",
|
||||
mcpAddSection: "MCPサーバーを追加",
|
||||
mcpBridged: "ブリッジ済み",
|
||||
mcpConfigured: "設定済み",
|
||||
mcpEmpty: "MCPサーバーなし",
|
||||
mcpNotBridged: "ブリッジ未接続",
|
||||
mcpRemove: "削除",
|
||||
mcpSpecFormat: "JSON形式",
|
||||
mcpSpecLabel: "サーバー仕様",
|
||||
memoryDesc: "メモリ設定を構成",
|
||||
memorySection: "メモリ",
|
||||
shortcutAbort: "中断",
|
||||
shortcutCloseTab: "タブを閉じる",
|
||||
shortcutCommandPalette: "コマンドパレット",
|
||||
shortcutFocusComposer: "入力欄にフォーカス",
|
||||
shortcutNewChat: "新しいチャット",
|
||||
shortcutNewTab: "新しいタブ",
|
||||
shortcutSettings: "設定",
|
||||
shortcutSwitchTab: "タブ切替",
|
||||
qqAccessAllowlist: "許可リスト",
|
||||
qqAccessOpen: "オープン",
|
||||
qqAccessOwner: "所有者",
|
||||
qqAccessOwnerWithAllowlist: "所有者 + 許可リスト",
|
||||
qqAccessRuntime: "ランタイム",
|
||||
qqAppId: "App ID",
|
||||
qqApplyAction: "申請",
|
||||
qqApplyLabel: "申請",
|
||||
qqAppSecret: "App Secret",
|
||||
qqBack: "戻る",
|
||||
qqConfigure: "設定",
|
||||
qqConfigureHint: "QQ統合を設定",
|
||||
qqConfigureTitle: "QQ設定",
|
||||
qqConnected: "接続済み",
|
||||
qqConnecting: "接続中…",
|
||||
qqDisconnected: "切断済み",
|
||||
qqEnvironment: "環境",
|
||||
qqFailed: "失敗",
|
||||
qqProduction: "本番",
|
||||
qqSandbox: "サンドボックス",
|
||||
qqSave: "保存",
|
||||
qqSaveAndConnect: "保存して接続",
|
||||
qqSection: "QQ統合",
|
||||
qqSummaryAppId: "App ID",
|
||||
qqSummaryDetail: "詳細",
|
||||
qqSummaryMissing: "未設定",
|
||||
qqTitle: "QQボット",
|
||||
},
|
||||
chat: {
|
||||
...en.chat,
|
||||
modeMirror: "TUIミラー",
|
||||
modeView: "セッションビュー",
|
||||
placeholder: "プロンプトを入力 — Enterで送信、Shift+Enterで改行 · / @ でピッカー",
|
||||
placeholderBusy: "現在のターンの完了を待っています…",
|
||||
placeholderSteerBusy: "入力で操作 — ビジー中はコマンドとピッカーが無効です",
|
||||
steerHint: "メッセージが現在のターンを操作します(指示として追加)",
|
||||
send: "送信",
|
||||
new: "新規",
|
||||
clear: "クリア",
|
||||
newTitle: "/new — 会話コンテキストを消去(ループログ + スクロールバック)",
|
||||
clearTitle: "/clear — 表示スクロールバックのみ消去(コンテキストは保持)",
|
||||
noConversation: "まだ会話がありません。下のプロンプトを送信して開始してください。",
|
||||
newConfirmBusy: "ターンが実行中です。中断して新しい会話を始めますか?",
|
||||
newConfirm: "現在の会話をクリアして新しく始めますか?",
|
||||
newToast: "新しい会話",
|
||||
clearToast: "スクロールバックをクリアしました",
|
||||
newFailed: "/new 失敗: {error}",
|
||||
clearFailed: "/clear 失敗: {error}",
|
||||
eventStreamError: "イベントストリームが中断されました — 再接続中…",
|
||||
semanticBanner: "このプロジェクトではセマンティック検索が有効になっていません。",
|
||||
semanticBannerDesc:
|
||||
"インデックスを一度構築すると、モデルは正確な文字列のgrepではなく意味でコードを見つけられるようになります(「認証失敗をどのように処理しているか?」など)。",
|
||||
semanticBannerBtn: "構築する →",
|
||||
semanticBannerDismiss: "閉じる(再表示しない)",
|
||||
slashCommands: "スラッシュコマンド",
|
||||
projectFiles: "プロジェクトファイル",
|
||||
effortTitle: "reasoning_effort — 次のターンから適用",
|
||||
effortMaxTitle: "max (DeepSeek拡張)",
|
||||
effortHighTitle: "high (安全なデフォルト — vLLM / Azure互換)",
|
||||
effortMediumTitle: "medium",
|
||||
effortLowTitle: "low (最速)",
|
||||
editGateTitle: "編集ゲート — TUIではShift+Tabで切替",
|
||||
editReviewTitle: "review — 編集と未許可シェルの両方が最初に確認",
|
||||
editAutoTitle: "auto — 編集は自動適用、シェルは確認あり",
|
||||
editYoloTitle: "yolo — 編集とシェルの両方が自動実行、許可リストバイパス",
|
||||
railSession: "セッション",
|
||||
railTurns: "ターン",
|
||||
railPromptTok: "プロンプトtok",
|
||||
railCost: "コスト",
|
||||
railCacheHit: "キャッシュヒット",
|
||||
railToolBudget: "ツール予算",
|
||||
railSpend: "消費",
|
||||
railActivePlan: "アクティブプラン",
|
||||
railProgress: "進捗",
|
||||
statusModel: "モデル",
|
||||
statusCtx: "ctx",
|
||||
statusCache: "キャッシュ",
|
||||
statusTurn: "ターン",
|
||||
statusSession: "セッション",
|
||||
statusBalance: "残高",
|
||||
statusTurns: "{count} ターン",
|
||||
waitingStats: "· · · ライブ統計待ち",
|
||||
inflightPhase: "{phase}",
|
||||
inflightRunning: "実行中",
|
||||
inflightThinking: "思考中",
|
||||
inflightStreaming: "ストリーミング中",
|
||||
inflightWaiting: "待機中",
|
||||
inflightReasoning: "推論 {count} ch",
|
||||
inflightOut: "出力 {count} ch",
|
||||
abortBtn: "中断 (Esc)",
|
||||
confirmBtn: "適用 (y)",
|
||||
rejectBtn: "拒否 (n)",
|
||||
applyRestBtn: "残りを適用 (a)",
|
||||
flipAutoBtn: "AUTOに切替 (A)",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { en } from "./en";
|
||||
|
||||
export const ru: typeof en = {
|
||||
...en,
|
||||
};
|
||||
+606
-21
@@ -23,6 +23,111 @@ export const zhCN = {
|
||||
themeToggle: "切换主题",
|
||||
themeLight: "浅色",
|
||||
themeDark: "深色",
|
||||
skill: {
|
||||
scope: {
|
||||
builtin: "内置",
|
||||
global: "全局",
|
||||
project: "项目",
|
||||
},
|
||||
runAs: {
|
||||
subagent: "子代理",
|
||||
inline: "内联",
|
||||
},
|
||||
generic: "技能",
|
||||
},
|
||||
exportReasoningSummary: "推理摘要",
|
||||
exportToolLabel: "工具调用",
|
||||
exportUserLabel: "用户消息",
|
||||
empty: {
|
||||
currentWorkspace: "当前工作区",
|
||||
selectWorkspace: "选择一个工作区开始",
|
||||
suggestion0: "开始新对话",
|
||||
suggestion1: "打开设置",
|
||||
suggestion2: "查看概览",
|
||||
suggestion3: "浏览会话",
|
||||
welcome: "欢迎使用 Reasonix",
|
||||
},
|
||||
errorLabel: "错误",
|
||||
connecting: "连接中…",
|
||||
session: {
|
||||
format: "会话",
|
||||
new: "新建会话",
|
||||
},
|
||||
setup: {
|
||||
choose: "选择工作区",
|
||||
description: "选择一个工作区开始工作",
|
||||
notSelected: "未选择工作区",
|
||||
saveAndStart: "保存并开始",
|
||||
welcome: "欢迎",
|
||||
workspace: "工作区",
|
||||
},
|
||||
tab: {
|
||||
newTab: "+ 新建标签",
|
||||
newTabTitle: "打开新标签",
|
||||
},
|
||||
titlebar: {
|
||||
clearChat: "清除对话",
|
||||
close: "关闭",
|
||||
commandPalette: "命令面板",
|
||||
contextPanel: "上下文面板",
|
||||
copyMd: "复制为 Markdown",
|
||||
exportMd: "导出为 Markdown",
|
||||
maximize: "最大化",
|
||||
minimize: "最小化",
|
||||
more: "更多",
|
||||
restore: "还原",
|
||||
settings: "设置",
|
||||
sidebar: "侧边栏",
|
||||
windowControls: "窗口控制",
|
||||
},
|
||||
toast: {
|
||||
cleared: "对话已清除",
|
||||
copied: "已复制到剪贴板",
|
||||
copiedMd: "已复制为 Markdown",
|
||||
emptySession: "无消息可导出",
|
||||
exportDialogTitle: "导出对话",
|
||||
exportedMd: "已导出为 Markdown",
|
||||
exportFailed: "导出失败",
|
||||
langSwitched: "语言已切换",
|
||||
modelSwitched: "模型 → {model}",
|
||||
effortSwitched: "强度 → {effort}",
|
||||
modeSwitched: "模式已切换",
|
||||
newSession: "新会话已创建",
|
||||
},
|
||||
yolo: {
|
||||
toast: "YOLO 模式已启用",
|
||||
},
|
||||
cmd: {
|
||||
abort: "中止",
|
||||
btw: "顺便说一下",
|
||||
clearChat: "清除对话",
|
||||
compact: "紧凑",
|
||||
copyLast: "复制上条",
|
||||
exportMd: "导出为 Markdown",
|
||||
feedback: "反馈",
|
||||
help: "帮助",
|
||||
newSession: "新建会话",
|
||||
retry: "重试",
|
||||
switchModel: "切换模型",
|
||||
toggleCurrency: "切换货币",
|
||||
toggleLang: "切换语言",
|
||||
toggleTheme: "切换主题",
|
||||
},
|
||||
header: {
|
||||
abort: "中止",
|
||||
clickToSelect: "点击选择",
|
||||
copy: "复制",
|
||||
copyMd: "复制为 Markdown",
|
||||
export: "导出",
|
||||
exportMd: "导出为 Markdown",
|
||||
newChat: "新建对话",
|
||||
noWorkspace: "无工作区",
|
||||
running: "运行中…",
|
||||
},
|
||||
jumpToBottom: "跳到底部",
|
||||
langEN: "English",
|
||||
langZH: "中文",
|
||||
splashSubtitle: "AI 驱动的编程助手",
|
||||
},
|
||||
changes: {
|
||||
chatPlaceholder: "询问代码问题…",
|
||||
@@ -56,6 +161,8 @@ export const zhCN = {
|
||||
commentLine: "第",
|
||||
commentEdit: "编辑",
|
||||
commentDelete: "删除",
|
||||
diffSourceGit: "Git 变更",
|
||||
diffSourceSession: "上一轮变更",
|
||||
diffSourceCheckpoint: "检查点",
|
||||
checkpointEmpty: "暂无检查点",
|
||||
restoreBtn: "恢复",
|
||||
@@ -66,8 +173,6 @@ export const zhCN = {
|
||||
createPlaceholder: "检查点名称…",
|
||||
backToList: "返回列表",
|
||||
reviewEmpty: "暂无可审查的更改",
|
||||
diffSourceGit: "Git 变更",
|
||||
diffSourceSession: "上一轮变更",
|
||||
diffStyleUnified: "统一视图",
|
||||
diffStyleSplit: "分栏视图",
|
||||
expandAll: "全部展开",
|
||||
@@ -101,29 +206,51 @@ export const zhCN = {
|
||||
saved: "已保存:{fields}",
|
||||
sectionApi: "DeepSeek API",
|
||||
apiKey: "API 密钥",
|
||||
apiKeyNotSet: "未设置",
|
||||
apiKeySave: "保存密钥",
|
||||
apiKeySet: "已设置",
|
||||
apiSection: "API 配置",
|
||||
notSet: "(未设置)",
|
||||
replace: "替换",
|
||||
pasteKey: "粘贴新的 sk-… 令牌",
|
||||
saveKey: "保存密钥",
|
||||
baseUrl: "基础 URL",
|
||||
baseUrlPlaceholder: "https://api.deepseek.com(默认)",
|
||||
baseUrlHint: "API 请求的基础 URL",
|
||||
sectionDefaults: "默认设置",
|
||||
preset: "预设",
|
||||
presetAuto: "auto — flash 基线,困难时自动升级为 pro",
|
||||
presetFlash: "flash — 始终使用 flash,不自动升级",
|
||||
presetPro: "pro — 始终使用 pro",
|
||||
effortSection: "推理强度",
|
||||
modelCustom: "自定义模型 ID",
|
||||
modelCustomHint: "任何 OpenAI 兼容的 ID(vLLM、Ollama、Together …)。",
|
||||
modelCustomActive: "当前运行自定义 ID:{model}",
|
||||
appliesNextTurn: "下一轮生效",
|
||||
effort: "推理强度",
|
||||
effortMax: "max(默认 — 最佳质量)",
|
||||
effortHigh: "high(更便宜 / 更快)",
|
||||
effortMax: "max(DeepSeek 扩展 — 标准 OpenAI / vLLM 会拒绝)",
|
||||
effortHigh: "high(安全默认 — vLLM / Azure 兼容)",
|
||||
effortMedium: "medium",
|
||||
effortLow: "low(最快)",
|
||||
webSearch: "网页搜索",
|
||||
webSearchNote: "web_fetch + web_search 工具",
|
||||
sectionCompute: "计算",
|
||||
proNext: "/pro 单轮",
|
||||
proArm: "为下一轮装备",
|
||||
proArmed: "已装备 — 下一轮后自动解除",
|
||||
proNextNote: "下一轮使用 deepseek-v4-pro,之后自动解除",
|
||||
webSearchEngine: "搜索引擎",
|
||||
webSearchEngineBing: "bing — 默认,国内裸 IP 直连,无需代理",
|
||||
webSearchEngineBingIntl: "bing-intl — 国际版索引,走 www.bing.com",
|
||||
webSearchEngineSearxng: "searxng — 自托管(端点用 /se searxng <url> 配置)",
|
||||
webSearchEngineMetaso: "metaso — 每日 100 次免费(国内友好,metaso.cn 可领免费 key)",
|
||||
webSearchEngineBaidu: "baidu — 百度 AI Search(设置 BAIDU_API_KEY 或 QIANFAN_API_KEY)",
|
||||
webSearchEngineTavily: "tavily — 每月 1000 次免费(设置 TAVILY_API_KEY)",
|
||||
webSearchEnginePerplexity: "perplexity — AI 直接回答(设置 PERPLEXITY_API_KEY)",
|
||||
webSearchEngineExa: "exa — AI 直接回答,每月 1000 次免费(设置 EXA_API_KEY)",
|
||||
webSearchEngineBrave: "brave — 独立索引,每月 2000 次免费(设置 BRAVE_SEARCH_API_KEY)",
|
||||
webSearchEngineOllama: "ollama — Ollama 云端网页搜索(设置 OLLAMA_API_KEY)",
|
||||
webSearchEngineNote: "下一次 web_search 调用生效",
|
||||
webSearchApiKeySignup: "(获取 key)",
|
||||
webSearchApiKeyClear: "清除",
|
||||
webSearchApiKey: {
|
||||
baidu: "百度 API key",
|
||||
},
|
||||
sectionBudget: "预算",
|
||||
budget: "预算",
|
||||
budgetHint: "设置支出限额",
|
||||
budgetPlaceholder: "输入预算金额",
|
||||
budgetOf: "/",
|
||||
budgetSetCap: "设置上限",
|
||||
budgetCustom: "自定义",
|
||||
@@ -149,21 +276,153 @@ export const zhCN = {
|
||||
skillPaths: "技能路径",
|
||||
skillPathsPlaceholder: "/path/to/skills-a, /path/to/skills-b",
|
||||
skillPathsNote: "英文逗号分隔 · 下次 /new 或新会话生效",
|
||||
skillsEmpty: "无已加载技能",
|
||||
skillsLoaded: "技能已加载",
|
||||
subagentModelFlash: "flash",
|
||||
subagentModelPro: "pro",
|
||||
subagentModelHint: "技能派生独立 subagent 时使用的模型",
|
||||
sectionRuntime: "运行时",
|
||||
activeModel: "当前模型",
|
||||
model: "模型",
|
||||
modelFlashDesc: "Flash — 始终 flash",
|
||||
modelProDesc: "Pro — 始终 pro",
|
||||
modelPricingLine: "${hit} 命中 · ${miss} 未命中 · ${out} 输出 / 100 万 tok",
|
||||
editMode: "编辑模式",
|
||||
editModeNote: "在对话标签页头部切换",
|
||||
editModeHint: "配置编辑模式",
|
||||
showSystemEvents: "系统事件",
|
||||
showSystemEventsHint: "在对话流中以细线显示压缩 / 中断 / 限速等系统事件",
|
||||
shown: "显示",
|
||||
hidden: "隐藏",
|
||||
appMode: "应用模式",
|
||||
sectionLanguage: "语言",
|
||||
language: "语言",
|
||||
languageHint: "切换语言",
|
||||
langEn: "English",
|
||||
langZhCn: "简体中文",
|
||||
appearanceSection: "外观",
|
||||
behaviorSection: "行为",
|
||||
ruleAutoApprovalHint: "配置自动审批规则",
|
||||
ruleAutoApprovalSection: "自动审批规则",
|
||||
theme: "主题",
|
||||
themeDark: "深色",
|
||||
themeHint: "在浅色和深色主题之间切换",
|
||||
themeLight: "浅色",
|
||||
themeStyle: "主题风格",
|
||||
themeStyleHint: "选择主题风格",
|
||||
themeStyleGraphite: "石墨",
|
||||
themeStyleGraphiteDesc: "冷色深色调,适合长时间使用。",
|
||||
themeStyleEmber: "余烬",
|
||||
themeStyleEmberDesc: "暖黑深色调,强化 Reasonix 橙色品牌感。",
|
||||
themeStyleAurora: "极光",
|
||||
themeStyleAuroraDesc: "青绿色深色调,低光环境更柔和。",
|
||||
themeStyleSandstone: "砂岩",
|
||||
themeStyleSandstoneDesc: "暖色浅色调,明亮环境更舒适。",
|
||||
themeStylePorcelain: "瓷白",
|
||||
themeStylePorcelainDesc: "高对比度浅色,简洁清爽。",
|
||||
themeStyleLinen: "亚麻",
|
||||
themeStyleLinenDesc: "偏纸张质感的温暖浅色调。",
|
||||
themeStyleGlacier: "冰川",
|
||||
themeStyleGlacierDesc: "冷白浅色调,蓝色强调更清爽。",
|
||||
themeStyleMidnight: "午夜",
|
||||
themeStyleMidnightDesc: "深色配冷色高光,更深邃。",
|
||||
pageGeneralLabel: "通用",
|
||||
pageGeneralDesc: "主题、字体、语言与工作目录。",
|
||||
pageModelsLabel: "模型",
|
||||
pageModelsDesc: "默认模型与推理强度。",
|
||||
pageMcpLabel: "MCP 服务器",
|
||||
pageMcpDesc: "接入外部工具服务器。",
|
||||
pageSkillsLabel: "技能",
|
||||
pageSkillsDesc: "可通过 /skill 调用的自定义技能。",
|
||||
pageMemoryLabel: "记忆",
|
||||
pageMemoryDesc: "跨会话保留的长期笔记。",
|
||||
pageRulesLabel: "规则",
|
||||
pageRulesDesc: "Shell 与路径访问的自动审批规则。",
|
||||
pageBillingLabel: "账单",
|
||||
pageBillingDesc: "API key、余额与会话预算。",
|
||||
pageShortcutsLabel: "快捷键",
|
||||
pageShortcutsDesc: "键盘快捷键参考。",
|
||||
fontFamily: "字体",
|
||||
fontFamilyHint: "选择字体",
|
||||
fontFamilySans: "无衬线",
|
||||
fontFamilySerif: "衬线",
|
||||
fontFamilySystem: "系统",
|
||||
fontScale: "字体大小",
|
||||
fontScaleHint: "调整字体大小",
|
||||
fontScaleLarge: "大",
|
||||
fontScaleMedium: "中",
|
||||
fontScaleSmall: "小",
|
||||
currentWorkspace: "当前工作区",
|
||||
defaultModelCurrent: "当前模型",
|
||||
editor: "编辑器",
|
||||
editorHint: "配置编辑器偏好",
|
||||
maxOutput: "最大输出",
|
||||
reasoningEffort: "推理强度",
|
||||
reasoningEffortHint: "调整推理深度",
|
||||
sessionCost: "会话成本",
|
||||
balanceAvailable: "可用",
|
||||
balanceLabel: "余额",
|
||||
balanceLow: "余额不足",
|
||||
cacheHitRate: "缓存命中率",
|
||||
ctxWindow: "上下文窗口",
|
||||
notSelected: "未选择",
|
||||
parseError: "解析错误",
|
||||
workspaceChange: "更改",
|
||||
workspaceSection: "工作区",
|
||||
mcpAdd: "添加",
|
||||
mcpAddSection: "添加 MCP 服务器",
|
||||
mcpBridged: "已桥接",
|
||||
mcpConfigured: "已配置",
|
||||
mcpEmpty: "无 MCP 服务器",
|
||||
mcpNotBridged: "未桥接",
|
||||
mcpRemove: "移除",
|
||||
mcpSpecFormat: "JSON 格式",
|
||||
mcpSpecLabel: "服务器规格",
|
||||
memoryDesc: "配置记忆设置",
|
||||
memorySection: "记忆",
|
||||
shortcutAbort: "中止",
|
||||
shortcutCloseTab: "关闭标签",
|
||||
shortcutCommandPalette: "命令面板",
|
||||
shortcutFocusComposer: "聚焦编辑器",
|
||||
shortcutNewChat: "新建对话",
|
||||
shortcutNewTab: "新建标签",
|
||||
shortcutSettings: "设置",
|
||||
shortcutSwitchTab: "切换标签",
|
||||
qqAccessAllowlist: "允许列表",
|
||||
qqAccessOpen: "开放",
|
||||
qqAccessOwner: "所有者",
|
||||
qqAccessOwnerWithAllowlist: "所有者 + 允许列表",
|
||||
qqAccessRuntime: "运行时",
|
||||
qqAppId: "App ID",
|
||||
qqApplyAction: "应用",
|
||||
qqApplyLabel: "应用",
|
||||
qqAppSecret: "App Secret",
|
||||
qqBack: "返回",
|
||||
qqConfigure: "配置",
|
||||
qqConfigureHint: "设置 QQ 集成",
|
||||
qqConfigureTitle: "QQ 配置",
|
||||
qqConnected: "已连接",
|
||||
qqConnecting: "连接中…",
|
||||
qqDisconnected: "已断开",
|
||||
qqEnvironment: "环境",
|
||||
qqFailed: "失败",
|
||||
qqProduction: "生产环境",
|
||||
qqSandbox: "沙箱环境",
|
||||
qqSave: "保存",
|
||||
qqSaveAndConnect: "保存并连接",
|
||||
qqSection: "QQ 集成",
|
||||
qqSummaryAppId: "App ID",
|
||||
qqSummaryDetail: "详情",
|
||||
qqSummaryMissing: "未配置",
|
||||
qqTitle: "QQ 机器人",
|
||||
},
|
||||
chat: {
|
||||
modeMirror: "TUI 镜像",
|
||||
modeView: "会话视图",
|
||||
placeholder: "输入提示词 — Enter 发送,Shift+Enter 换行 · / @ 打开选择器",
|
||||
placeholderBusy: "请等待当前轮次完成…",
|
||||
placeholderSteerBusy: "输入消息以引导当前任务 — 忙碌时不支持命令和选择器",
|
||||
steerHint: "消息将作为附加指示引导当前轮次",
|
||||
send: "发送",
|
||||
new: "新建",
|
||||
clear: "清除",
|
||||
@@ -179,18 +438,16 @@ export const zhCN = {
|
||||
eventStreamError: "事件流中断 — 正在重连…",
|
||||
semanticBanner: "此项目未启用语义搜索。",
|
||||
semanticBannerDesc:
|
||||
"构建一次索引,模型即可按含义查找代码(“哪里处理认证失败?”),而不仅依赖精确字符串的 grep。",
|
||||
"构建一次索引,模型即可按含义查找代码(\u201C哪里处理认证失败?\u201D),而不仅依赖精确字符串的 grep。",
|
||||
semanticBannerBtn: "构建 →",
|
||||
semanticBannerDismiss: "关闭(不再显示)",
|
||||
slashCommands: "斜杠命令",
|
||||
projectFiles: "项目文件",
|
||||
effortTitle: "reasoning_effort — 下一轮生效",
|
||||
effortMaxTitle: "max(默认 — 最佳质量)",
|
||||
effortHighTitle: "high(更便宜 / 更快)",
|
||||
presetTitle: "预设 — 模型承诺",
|
||||
presetAutoTitle: "auto — flash 基线;困难轮次自动升级为 pro(NEEDS_PRO / 失败阈值)",
|
||||
presetFlashTitle: "flash — 始终 flash;不自动升级。/pro 仍可用于一次性手动提升",
|
||||
presetProTitle: "pro — 始终 pro;约 3 倍 flash 成本(5/31 折扣)。锁定困难的架构工作。",
|
||||
effortMaxTitle: "max(DeepSeek 扩展)",
|
||||
effortHighTitle: "high(安全默认 — vLLM / Azure 兼容)",
|
||||
effortMediumTitle: "medium",
|
||||
effortLowTitle: "low(最快)",
|
||||
editGateTitle: "编辑门控 — Shift+Tab 在 TUI 中循环",
|
||||
editReviewTitle: "review — 编辑和非允许列表的 shell 命令都会先询问",
|
||||
editAutoTitle: "auto — 编辑自动应用,shell 仍会询问",
|
||||
@@ -584,7 +841,7 @@ export const zhCN = {
|
||||
builtWith: "构建来源",
|
||||
currentTarget: "当前目标",
|
||||
incompatibleHint:
|
||||
"磁盘上的这个索引是为不同的 provider 或 model 构建的。运行“完全重建”即可替换。",
|
||||
"磁盘上的这个索引是为不同的 provider 或 model 构建的。运行\u201C完全重建\u201D即可替换。",
|
||||
runIndexHint: "运行索引以启用 semantic_search。",
|
||||
reIndex: "重建索引",
|
||||
build: "构建",
|
||||
@@ -667,6 +924,12 @@ export const zhCN = {
|
||||
shellBgTitle: "后台进程",
|
||||
shellSubtitle: "模型想要运行一条 Shell 命令",
|
||||
shellBgSubtitle: "长时间运行 — 批准后继续运行",
|
||||
pathTitle: "沙箱外文件访问",
|
||||
pathSubtitle: "{intent} · 通过 {tool}",
|
||||
pathRead: "读取",
|
||||
pathWrite: "写入",
|
||||
pathSandboxLabel: "沙箱",
|
||||
pathAllowPrefixLabel: "允许前缀",
|
||||
runOnce: "运行一次",
|
||||
alwaysAllow: '始终允许 "{prefix}"',
|
||||
deny: "拒绝",
|
||||
@@ -730,4 +993,326 @@ export const zhCN = {
|
||||
hoursAgo: "{n} 小时前",
|
||||
daysAgo: "{n} 天前",
|
||||
},
|
||||
cards: {
|
||||
applied: "已应用",
|
||||
cacheHit: "缓存命中",
|
||||
checkpointRewind: "检查点回退",
|
||||
cost: "费用",
|
||||
diffApply: "应用",
|
||||
diffApplyChanges: "应用更改",
|
||||
diffAwaiting: "待审阅",
|
||||
diffDiscard: "丢弃",
|
||||
done: "完成",
|
||||
elapsed: "耗时",
|
||||
error: "错误",
|
||||
errorName: "错误",
|
||||
failed: "失败",
|
||||
hits: "命中",
|
||||
memoryCountSuffix: "条记忆",
|
||||
memoryName: "记忆",
|
||||
output: "输出",
|
||||
planDefaultTitle: "计划",
|
||||
prompt: "提示词",
|
||||
compactionMeta: "{chars} 字符",
|
||||
compactionName: "已压缩历史",
|
||||
reasoningComplete: "推理完成",
|
||||
reasoningName: "推理",
|
||||
result: "结果",
|
||||
running: "运行中",
|
||||
searchName: "搜索",
|
||||
shellAlwaysAllow: "始终允许",
|
||||
shellAwaiting: "待审批",
|
||||
shellExecuteHint: "执行此命令?",
|
||||
shellReject: "拒绝",
|
||||
shellRun: "运行",
|
||||
shellRunning: "运行中",
|
||||
streaming: "输出中",
|
||||
subagentDone: "子代理完成",
|
||||
subagentDoneProgress: "子代理完成 ({pct}%)",
|
||||
subagentFailed: "子代理失败",
|
||||
subagentRunning: "子代理运行中",
|
||||
},
|
||||
planBadge: {
|
||||
blocked: "已阻塞",
|
||||
done: "完成",
|
||||
failed: "失败",
|
||||
pending: "待处理",
|
||||
running: "运行中",
|
||||
},
|
||||
composer: {
|
||||
atHeader: "提及文件",
|
||||
close: "关闭",
|
||||
commandPalette: "命令面板",
|
||||
commands: "命令",
|
||||
commandsLabel: "命令",
|
||||
confirm: "确认",
|
||||
imageFilterName: "图片",
|
||||
insertFile: "插入文件",
|
||||
insertImage: "插入图片",
|
||||
interrupt: "中断",
|
||||
mentionFiles: "提及文件",
|
||||
mentionLabel: "提及",
|
||||
newline: "换行",
|
||||
noMatches: "无匹配",
|
||||
placeholder: "输入提示词…",
|
||||
queue: "队列",
|
||||
queueCount: "{count} 个排队",
|
||||
select: "选择",
|
||||
send: "发送",
|
||||
slashHeader: "斜杠命令",
|
||||
switchModel: "切换模型",
|
||||
switchEffort: "切换推理强度",
|
||||
workspaceRoot: "工作区根目录",
|
||||
},
|
||||
contextPanel: {
|
||||
allow: "允许",
|
||||
ask: "询问",
|
||||
autoApproveTitle: "自动审批",
|
||||
cacheKey: "缓存",
|
||||
contextTokens: "上下文 tokens",
|
||||
fileInContext: "在上下文中",
|
||||
fileModified: "已修改",
|
||||
filesCount: "{count} 个文件",
|
||||
filesTab: "文件",
|
||||
filesTitle: "文件",
|
||||
freeKey: "空闲",
|
||||
itemCount: "{count} 个项目",
|
||||
mcpConfigured: "已配置",
|
||||
mcpConnecting: "连接中…",
|
||||
mcpDisabled: "已禁用",
|
||||
mcpEmpty: "无 MCP 服务器",
|
||||
mcpFailed: "失败",
|
||||
mcpReady: "就绪",
|
||||
mcpReadyAll: "全部就绪",
|
||||
mcpReadySome: "部分就绪",
|
||||
mcpTitle: "MCP",
|
||||
mcpTools: "工具",
|
||||
memoryTab: "记忆",
|
||||
memoryTitle: "记忆",
|
||||
noFilesMsg: "上下文中无文件",
|
||||
noMemoriesMsg: "无记忆",
|
||||
reservedKey: "保留",
|
||||
ruleReadOnly: "只读",
|
||||
ruleReview: "审阅",
|
||||
ruleShellAllowlist: "Shell 允许列表",
|
||||
rulesTab: "规则",
|
||||
ruleWritesAsk: "写入需询问",
|
||||
ruleYolo: "YOLO",
|
||||
scopeGlobal: "全局",
|
||||
scopeProject: "项目",
|
||||
toolsTab: "工具",
|
||||
usedKey: "已用",
|
||||
},
|
||||
dragDrop: {
|
||||
overlay: "拖放文件到此处",
|
||||
},
|
||||
extraCards: {
|
||||
approve: "批准",
|
||||
balance: "余额",
|
||||
cache: "缓存",
|
||||
cacheHit: "缓存命中",
|
||||
contextWindow: "上下文窗口",
|
||||
doctorTitle: "诊断",
|
||||
output: "输出",
|
||||
prompt: "提示词",
|
||||
reasoning: "推理",
|
||||
reject: "拒绝",
|
||||
sessionCost: "会话成本",
|
||||
sessionUsage: "会话用量",
|
||||
topToolsUsage: "热门工具",
|
||||
unknownKind: "未知",
|
||||
memoryUser: "用户",
|
||||
memoryFeedback: "反馈",
|
||||
memoryProject: "项目",
|
||||
memoryReference: "参考",
|
||||
},
|
||||
jobs: {
|
||||
empty: "无后台任务",
|
||||
exited: "已退出",
|
||||
kbClose: "关闭",
|
||||
kbToggle: "切换",
|
||||
running: "运行中",
|
||||
stop: "停止",
|
||||
stopAll: "全部停止",
|
||||
stopAllTip: "停止所有任务",
|
||||
stopOne: "停止",
|
||||
title: "后台任务",
|
||||
},
|
||||
live: {
|
||||
deliveredWaiting: "等待下一条消息",
|
||||
reasoning: "推理",
|
||||
running: "运行中",
|
||||
secondsAgo: "{n} 秒前",
|
||||
you: "你",
|
||||
},
|
||||
markdown: {
|
||||
copied: "已复制!",
|
||||
copy: "复制",
|
||||
externalLinkTitle: "在新标签中打开",
|
||||
filePillTitle: "打开文件",
|
||||
localLinkTitle: "打开链接",
|
||||
},
|
||||
palette: {
|
||||
abort: "中止",
|
||||
abortHint: "中止当前请求",
|
||||
about: "关于",
|
||||
clearChat: "清除对话",
|
||||
clearChatHint: "清除对话",
|
||||
closeTab: "关闭标签",
|
||||
closeTabHint: "关闭当前标签",
|
||||
copyConv: "复制对话",
|
||||
copyConvHint: "复制对话为 Markdown",
|
||||
copyLast: "复制上条回复",
|
||||
copyLastHint: "复制上条助手消息",
|
||||
countSuffix: "个项目",
|
||||
empty: "无匹配命令",
|
||||
exportMd: "导出为 Markdown",
|
||||
exportMdHint: "导出对话为 Markdown 文件",
|
||||
focusComposer: "聚焦编辑器",
|
||||
footClose: "关闭",
|
||||
footMove: "移动",
|
||||
footRun: "运行",
|
||||
groupAction: "操作",
|
||||
groupNav: "导航",
|
||||
groupSettings: "设置",
|
||||
groupWorkspace: "工作区",
|
||||
newChat: "新建对话",
|
||||
newChatHint: "开始新对话",
|
||||
newTab: "新建标签",
|
||||
newTabHint: "打开新标签",
|
||||
pickWorkspace: "切换工作区",
|
||||
pickWorkspaceHint: "更改工作区",
|
||||
searchPlaceholder: "搜索命令…",
|
||||
settings: "设置",
|
||||
settingsHint: "打开设置",
|
||||
},
|
||||
panel: {
|
||||
renderError: "面板崩溃",
|
||||
},
|
||||
sidebarPanel: {
|
||||
approvalRules: "审批规则",
|
||||
cancel: "取消",
|
||||
commandPalette: "命令面板",
|
||||
daysAgo: "{n} 天前",
|
||||
delete: "删除",
|
||||
deleteSession: "删除会话",
|
||||
hoursAgo: "{n} 小时前",
|
||||
justNow: "刚刚",
|
||||
loading: "加载中…",
|
||||
messageCount: "{count} 条消息",
|
||||
minutesAgo: "{n} 分钟前",
|
||||
newChat: "新建对话",
|
||||
noMatches: "无匹配",
|
||||
noSessions: "无会话",
|
||||
recent: "最近",
|
||||
searchSessions: "搜索会话…",
|
||||
sessionTitle: "会话",
|
||||
settings: "设置",
|
||||
weeksAgo: "{n} 周前",
|
||||
},
|
||||
statusbar: {
|
||||
balance: "余额",
|
||||
busy: "忙碌",
|
||||
cache: "缓存",
|
||||
cacheHit: "缓存命中",
|
||||
jobs: "任务",
|
||||
jobsTip: "后台任务",
|
||||
offline: "离线",
|
||||
online: "在线",
|
||||
switchCurrency: "切换货币",
|
||||
switchTheme: "切换主题",
|
||||
switchWorkspace: "切换工作区",
|
||||
themeDark: "深色",
|
||||
themeLight: "浅色",
|
||||
themeStyleGraphite: "石墨",
|
||||
themeStyleEmber: "余烬",
|
||||
themeStyleAurora: "极光",
|
||||
themeStyleSandstone: "砂岩",
|
||||
themeStylePorcelain: "瓷白",
|
||||
themeStyleLinen: "亚麻",
|
||||
themeStyleGlacier: "冰川",
|
||||
themeStyleMidnight: "午夜",
|
||||
thisTurn: "本轮",
|
||||
tokens: "Tokens",
|
||||
},
|
||||
thread: {
|
||||
activePlan: "活跃计划",
|
||||
allowRead: "允许读取",
|
||||
allowWrite: "允许写入",
|
||||
alwaysAllow: "始终允许",
|
||||
alwaysAllowPrefix: "始终允许 {prefix}",
|
||||
approve: "批准",
|
||||
approveRewrite: "批准重写",
|
||||
cancel: "取消",
|
||||
checkpointKind: "检查点",
|
||||
checkpointSub: "保存当前状态",
|
||||
checkpointTitle: "创建检查点",
|
||||
collapse: "折叠",
|
||||
continue: "继续",
|
||||
copyMessage: "复制消息",
|
||||
copyResponse: "复制回复",
|
||||
execute: "执行",
|
||||
keepOriginal: "保留原始",
|
||||
keepSteps: "保留步骤",
|
||||
optionCount: "{count} 个选项",
|
||||
pathAccessKind: "路径访问",
|
||||
planConfirmationKind: "计划审批",
|
||||
planRevisionKind: "计划修订",
|
||||
planRunning: "计划进行中",
|
||||
planStepCount: "步骤 {done}/{total}",
|
||||
readPathOutsideSandbox: "沙箱外读取路径",
|
||||
refine: "优化",
|
||||
reject: "拒绝",
|
||||
revise: "修订",
|
||||
revisionMeta: "修订",
|
||||
rewritePlan: "重写计划",
|
||||
risk: "风险评估",
|
||||
riskMedium: "中等风险",
|
||||
runBackgroundCommand: "后台运行",
|
||||
runCommand: "运行命令",
|
||||
shellConfirmationKind: "Shell 确认",
|
||||
startPlan: "启动计划",
|
||||
stop: "停止",
|
||||
subagent: "子代理",
|
||||
userChoiceKind: "用户选择",
|
||||
writePathOutsideSandbox: "沙箱外写入路径",
|
||||
you: "你",
|
||||
},
|
||||
workdir: {
|
||||
browse: "浏览…",
|
||||
cancel: "取消",
|
||||
empty: "无工作区",
|
||||
emptyDir: "无子目录。",
|
||||
go: "前往",
|
||||
goTip: "跳转到该路径",
|
||||
loading: "加载中…",
|
||||
openHere: "打开此目录",
|
||||
pathPlaceholder: "绝对路径…",
|
||||
promptWeb: "输入要打开的工作区绝对路径:",
|
||||
searchPlaceholder: "搜索工作区…",
|
||||
title: "工作区",
|
||||
},
|
||||
about: {
|
||||
close: "关闭",
|
||||
repo: "仓库",
|
||||
sidebarLabel: "关于",
|
||||
tagline: "AI 驱动的编程助手",
|
||||
version: "版本",
|
||||
},
|
||||
effort: {
|
||||
lowDesc: "最快 — 极少推理",
|
||||
mediumDesc: "平衡",
|
||||
highDesc: "默认 — vLLM / Azure 安全",
|
||||
maxDesc: "DeepSeek 扩展;标准 OpenAI / vLLM 会拒绝",
|
||||
},
|
||||
editMode: {
|
||||
plan: "计划",
|
||||
planHint: "只读模式 · 所有写工具都不会派发",
|
||||
review: "审阅",
|
||||
reviewHint: "编辑和非允许列表的 shell 命令都会先询问",
|
||||
auto: "自动",
|
||||
autoHint: "编辑自动应用,shell 仍会询问",
|
||||
yolo: "YOLO",
|
||||
yoloHint: "编辑和 shell 都自动运行,绕过允许列表",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
type IconProps = SVGProps<SVGSVGElement> & { size?: number };
|
||||
|
||||
function Ic({ size = 14, children, ...rest }: IconProps & { children: React.ReactNode }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export const I = {
|
||||
plus: (p: IconProps) => (<Ic {...p}><path d="M12 5v14M5 12h14" /></Ic>),
|
||||
search: (p: IconProps) => (<Ic {...p}><circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" /></Ic>),
|
||||
send: (p: IconProps) => (<Ic {...p}><path d="M4 12 20 4l-6 16-3-7-7-1Z" /></Ic>),
|
||||
chev: (p: IconProps) => (<Ic {...p}><path d="m6 9 6 6 6-6" /></Ic>),
|
||||
chevR: (p: IconProps) => (<Ic {...p}><path d="m9 6 6 6-6 6" /></Ic>),
|
||||
check: (p: IconProps) => (<Ic {...p}><path d="m5 12 5 5L20 7" /></Ic>),
|
||||
x: (p: IconProps) => (<Ic {...p}><path d="M6 6l12 12M18 6 6 18" /></Ic>),
|
||||
terminal: (p: IconProps) => (<Ic {...p}><rect x="3" y="4" width="18" height="16" rx="2" /><path d="m7 9 3 3-3 3M13 15h4" /></Ic>),
|
||||
brain: (p: IconProps) => (
|
||||
<Ic {...p}>
|
||||
<path d="M9 4a3 3 0 0 0-3 3v0a3 3 0 0 0-2 5 3 3 0 0 0 2 5 3 3 0 0 0 3 3h0a3 3 0 0 0 3-3V4" />
|
||||
<path d="M15 4a3 3 0 0 1 3 3 3 3 0 0 1 2 5 3 3 0 0 1-2 5 3 3 0 0 1-3 3" />
|
||||
</Ic>
|
||||
),
|
||||
list: (p: IconProps) => (<Ic {...p}><path d="M4 6h2M4 12h2M4 18h2M9 6h11M9 12h11M9 18h11" /></Ic>),
|
||||
diff: (p: IconProps) => (<Ic {...p}><path d="M7 4v11a3 3 0 0 0 3 3h2M17 20V9a3 3 0 0 0-3-3h-2" /><circle cx="7" cy="20" r="2" /><circle cx="17" cy="4" r="2" /></Ic>),
|
||||
globe: (p: IconProps) => (<Ic {...p}><circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" /></Ic>),
|
||||
link: (p: IconProps) => (<Ic {...p}><path d="M10 14a4 4 0 0 1 0-6l3-3a4 4 0 0 1 6 6l-2 2" /><path d="M14 10a4 4 0 0 1 0 6l-3 3a4 4 0 0 1-6-6l2-2" /></Ic>),
|
||||
wrench: (p: IconProps) => (<Ic {...p}><path d="M14 7a4 4 0 1 0 4 4l3 3-3 3-3-3a4 4 0 0 1-4-4l-3-3-3 3 3 3a4 4 0 0 0 6 0" /></Ic>),
|
||||
bot: (p: IconProps) => (<Ic {...p}><rect x="4" y="7" width="16" height="12" rx="3" /><path d="M12 3v4M8 12h.01M16 12h.01M9 16h6" /></Ic>),
|
||||
archive: (p: IconProps) => (<Ic {...p}><rect x="3" y="3" width="18" height="5" rx="1" /><path d="M5 8v11a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8M10 12h4" /></Ic>),
|
||||
bookmark: (p: IconProps) => (<Ic {...p}><path d="M6 3h12v18l-6-4-6 4Z" /></Ic>),
|
||||
warning: (p: IconProps) => (<Ic {...p}><path d="M12 3 2 21h20Z" /><path d="M12 10v5M12 18h.01" /></Ic>),
|
||||
zap: (p: IconProps) => (<Ic {...p}><path d="M13 3 4 14h7l-1 7 9-11h-7Z" /></Ic>),
|
||||
database: (p: IconProps) => (<Ic {...p}><ellipse cx="12" cy="5" rx="8" ry="3" /><path d="M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6" /></Ic>),
|
||||
cpu: (p: IconProps) => (<Ic {...p}><rect x="5" y="5" width="14" height="14" rx="2" /><rect x="9" y="9" width="6" height="6" /><path d="M9 2v3M15 2v3M9 19v3M15 19v3M2 9h3M2 15h3M19 9h3M19 15h3" /></Ic>),
|
||||
coin: (p: IconProps) => (<Ic {...p}><circle cx="12" cy="12" r="9" /><path d="M12 7v10M9 10h4a2 2 0 0 1 0 4H9" /></Ic>),
|
||||
file: (p: IconProps) => (<Ic {...p}><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9Z" /><path d="M14 3v6h6" /></Ic>),
|
||||
folder: (p: IconProps) => (<Ic {...p}><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" /></Ic>),
|
||||
image: (p: IconProps) => (<Ic {...p}><rect x="3" y="4" width="18" height="16" rx="2" /><circle cx="9" cy="10" r="2" /><path d="m3 18 5-5 4 4 3-3 6 6" /></Ic>),
|
||||
paperclip: (p: IconProps) => (<Ic {...p}><path d="M21 12 12 21a5 5 0 0 1-7-7L14 5a3 3 0 0 1 4 4l-9 9a1 1 0 0 1-2-2l8-8" /></Ic>),
|
||||
mic: (p: IconProps) => (<Ic {...p}><rect x="9" y="3" width="6" height="12" rx="3" /><path d="M5 11a7 7 0 0 0 14 0M12 18v3M9 21h6" /></Ic>),
|
||||
sun: (p: IconProps) => (<Ic {...p}><circle cx="12" cy="12" r="4" /><path d="M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1.5 1.5M17.5 17.5 19 19M5 19l1.5-1.5M17.5 6.5 19 5" /></Ic>),
|
||||
moon: (p: IconProps) => (<Ic {...p}><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z" /></Ic>),
|
||||
panel_l: (p: IconProps) => (<Ic {...p}><rect x="3" y="4" width="18" height="16" rx="2" /><path d="M9 4v16" /></Ic>),
|
||||
panel_r: (p: IconProps) => (<Ic {...p}><rect x="3" y="4" width="18" height="16" rx="2" /><path d="M15 4v16" /></Ic>),
|
||||
cog: (p: IconProps) => (<Ic {...p}><path d="M12 3.6 19.2 7.8v8.4L12 20.4 4.8 16.2V7.8Z" /><circle cx="12" cy="12" r="3.7" /></Ic>),
|
||||
stop: (p: IconProps) => (<Ic {...p}><rect x="6" y="6" width="12" height="12" rx="2" /></Ic>),
|
||||
play: (p: IconProps) => (<Ic {...p}><path d="M7 5v14l12-7Z" /></Ic>),
|
||||
more: (p: IconProps) => (<Ic {...p}><circle cx="6" cy="12" r="1.6" fill="currentColor" /><circle cx="12" cy="12" r="1.6" fill="currentColor" /><circle cx="18" cy="12" r="1.6" fill="currentColor" /></Ic>),
|
||||
pin: (p: IconProps) => (<Ic {...p}><path d="M15 3 9 9l-4 1 9 9 1-4 6-6ZM4 20l5-5" /></Ic>),
|
||||
rotate: (p: IconProps) => (<Ic {...p}><path d="M3 12a9 9 0 1 0 3-6.7L3 8" /><path d="M3 3v5h5" /></Ic>),
|
||||
branch: (p: IconProps) => (<Ic {...p}><circle cx="6" cy="4" r="2" /><circle cx="6" cy="20" r="2" /><circle cx="18" cy="8" r="2" /><path d="M6 6v12M6 14a6 6 0 0 0 6-6h4" /></Ic>),
|
||||
at: (p: IconProps) => (<Ic {...p}><circle cx="12" cy="12" r="4" /><path d="M16 8v6a3 3 0 0 0 5-2 9 9 0 1 0-4 7" /></Ic>),
|
||||
slash: (p: IconProps) => (<Ic {...p}><path d="M16 4 8 20" /></Ic>),
|
||||
layers: (p: IconProps) => (<Ic {...p}><path d="m12 3 9 5-9 5-9-5Z" /><path d="m3 13 9 5 9-5M3 18l9 5 9-5" /></Ic>),
|
||||
download: (p: IconProps) => (<Ic {...p}><path d="M12 4v12M6 12l6 6 6-6M4 20h16" /></Ic>),
|
||||
upload: (p: IconProps) => (<Ic {...p}><path d="M12 20V8M6 12l6-6 6 6M4 4h16" /></Ic>),
|
||||
history: (p: IconProps) => (<Ic {...p}><path d="M3 12a9 9 0 1 0 3-6.7L3 8M12 7v5l3 2" /></Ic>),
|
||||
shield: (p: IconProps) => (<Ic {...p}><path d="M12 3 4 6v6c0 5 3.5 8 8 9 4.5-1 8-4 8-9V6Z" /><path d="m9 12 2 2 4-4" /></Ic>),
|
||||
warn: (p: IconProps) => (<Ic {...p}><path d="M12 3 2 21h20Z" /><path d="M12 10v5M12 18h.01" /></Ic>),
|
||||
help: (p: IconProps) => (<Ic {...p}><circle cx="12" cy="12" r="9" /><path d="M9.5 9a2.5 2.5 0 0 1 5 0c0 1.5-2.5 2-2.5 4M12 17h.01" /></Ic>),
|
||||
refresh: (p: IconProps) => (<Ic {...p}><path d="M3 12a9 9 0 0 1 15-6.7L21 8M21 4v4h-4M21 12a9 9 0 0 1-15 6.7L3 16M3 20v-4h4" /></Ic>),
|
||||
copy: (p: IconProps) => (<Ic {...p}><rect x="8" y="8" width="12" height="12" rx="2" /><path d="M4 16V6a2 2 0 0 1 2-2h10" /></Ic>),
|
||||
};
|
||||
|
||||
export type IconKey = keyof typeof I;
|
||||
@@ -1,48 +0,0 @@
|
||||
export const TOKEN: string =
|
||||
document.querySelector('meta[name="reasonix-token"]')?.getAttribute("content") ?? "";
|
||||
|
||||
export const MODE: "standalone" | "attached" =
|
||||
(document.querySelector('meta[name="reasonix-mode"]')?.getAttribute("content") as
|
||||
| "standalone"
|
||||
| "attached"
|
||||
| null) ?? "standalone";
|
||||
|
||||
export interface ApiOptions {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
export interface ApiError extends Error {
|
||||
status: number;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
export async function api<T = unknown>(path: string, opts: ApiOptions = {}): Promise<T> {
|
||||
const method = opts.method ?? "GET";
|
||||
const url = `/api${path}${path.includes("?") ? "&" : "?"}token=${TOKEN}`;
|
||||
const headers: Record<string, string> = { ...(opts.headers ?? {}) };
|
||||
headers["X-Reasonix-Token"] = TOKEN;
|
||||
if (opts.body !== undefined) headers["Content-Type"] = "application/json";
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
let parsed: unknown = null;
|
||||
try {
|
||||
parsed = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
parsed = { error: text };
|
||||
}
|
||||
if (!res.ok) {
|
||||
const errMsg =
|
||||
(parsed as { error?: string } | null)?.error ?? `${res.status} ${res.statusText}`;
|
||||
const err = new Error(errMsg) as ApiError;
|
||||
err.status = res.status;
|
||||
err.body = parsed;
|
||||
throw err;
|
||||
}
|
||||
return parsed as T;
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
export type BudgetState =
|
||||
| { kind: "off"; spent: number }
|
||||
| { kind: "running"; cap: number; spent: number; pct: number }
|
||||
| { kind: "warn"; cap: number; spent: number; pct: number }
|
||||
| { kind: "exhausted"; cap: number; spent: number; pct: number };
|
||||
|
||||
export function deriveBudgetState(
|
||||
cap: number | null | undefined,
|
||||
spent: number | null | undefined,
|
||||
): BudgetState {
|
||||
const safeSpent = typeof spent === "number" && spent >= 0 ? spent : 0;
|
||||
if (typeof cap !== "number" || cap <= 0) {
|
||||
return { kind: "off", spent: safeSpent };
|
||||
}
|
||||
const pct = (safeSpent / cap) * 100;
|
||||
if (pct >= 100) return { kind: "exhausted", cap, spent: safeSpent, pct };
|
||||
if (pct >= 80) return { kind: "warn", cap, spent: safeSpent, pct };
|
||||
return { kind: "running", cap, spent: safeSpent, pct };
|
||||
}
|
||||
|
||||
/** Default quick-cap menu — round dollar amounts users actually pick. */
|
||||
export const QUICK_CAPS_USD: ReadonlyArray<number> = [1, 5, 10, 25, 50];
|
||||
|
||||
/** 1.5× / 2× / 4× the current cap, snapped to a "nice" round number per bucket. */
|
||||
export function bumpSuggestions(currentCap: number): number[] {
|
||||
if (currentCap <= 0) return [];
|
||||
return [niceUp(currentCap * 1.5), niceUp(currentCap * 2), niceUp(currentCap * 4)];
|
||||
}
|
||||
|
||||
function niceUp(n: number): number {
|
||||
// Subtract a tiny epsilon before ceil so FP noise (0.4 * 1.5 = 0.6000…01)
|
||||
// doesn't bump a value to the next bucket.
|
||||
const eps = 1e-9;
|
||||
if (n < 1) return Math.ceil((n - eps) * 10) / 10;
|
||||
if (n < 10) return Math.ceil((n - eps) * 2) / 2;
|
||||
if (n < 100) return Math.ceil(n - eps);
|
||||
return Math.ceil((n - eps) / 5) * 5;
|
||||
}
|
||||
|
||||
/** Tone class shared between the cockpit tile and the settings gauge. */
|
||||
export function budgetTone(state: BudgetState): "" | "warn" | "err" {
|
||||
if (state.kind === "exhausted") return "err";
|
||||
if (state.kind === "warn") return "warn";
|
||||
return "";
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
/** Browser extensions (userscripts, ad blockers) inject scripts that throw into our window. Their errors land in the global handlers as if they were ours — see #818. Filter them by origin. */
|
||||
const THIRD_PARTY_ORIGIN_PREFIXES = [
|
||||
"chrome-extension://",
|
||||
"moz-extension://",
|
||||
"safari-web-extension://",
|
||||
"safari-extension://",
|
||||
"ms-browser-extension://",
|
||||
];
|
||||
|
||||
export function isThirdPartyError(error: unknown, filename?: string): boolean {
|
||||
const hay = `${filename ?? ""}\n${(error as { stack?: string } | null)?.stack ?? ""}`;
|
||||
return THIRD_PARTY_ORIGIN_PREFIXES.some((prefix) => hay.includes(prefix));
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import htm from "htm";
|
||||
import { h } from "preact";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { isThirdPartyError } from "./bus-filter.js";
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
export const appBus = new EventTarget();
|
||||
export const toastBus = new EventTarget();
|
||||
|
||||
export type ToastKind = "info" | "success" | "warn" | "error";
|
||||
|
||||
export function showToast(text: string, kind: ToastKind = "info", ttl = 3000): void {
|
||||
toastBus.dispatchEvent(new CustomEvent("toast", { detail: { text, kind, ttl } }));
|
||||
}
|
||||
|
||||
export interface ErrorReport {
|
||||
error: unknown;
|
||||
source: string;
|
||||
info?: string;
|
||||
ts: number;
|
||||
}
|
||||
|
||||
export function reportAppError(error: unknown, source: string, info?: string): void {
|
||||
console.error(`[reasonix dashboard] ${source}:`, error, info);
|
||||
appBus.dispatchEvent(
|
||||
new CustomEvent("error", { detail: { error, source, info, ts: Date.now() } }),
|
||||
);
|
||||
}
|
||||
|
||||
window.addEventListener("error", (ev) => {
|
||||
if (!ev.error) return;
|
||||
if (isThirdPartyError(ev.error, ev.filename)) return;
|
||||
reportAppError(ev.error, "window", ev.message);
|
||||
});
|
||||
|
||||
window.addEventListener("unhandledrejection", (ev) => {
|
||||
if (isThirdPartyError(ev.reason)) return;
|
||||
reportAppError(ev.reason, "promise");
|
||||
});
|
||||
|
||||
interface Toast {
|
||||
id: string;
|
||||
text: string;
|
||||
kind: ToastKind;
|
||||
ttl: number;
|
||||
}
|
||||
|
||||
export function ToastStack() {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
useEffect(() => {
|
||||
const onToast = (ev: Event) => {
|
||||
const detail = (ev as CustomEvent).detail as Omit<Toast, "id">;
|
||||
const id = `${Date.now()}-${Math.random()}`;
|
||||
const t: Toast = { id, ...detail };
|
||||
setToasts((prev) => [...prev, t]);
|
||||
setTimeout(() => setToasts((prev) => prev.filter((x) => x.id !== id)), t.ttl);
|
||||
};
|
||||
toastBus.addEventListener("toast", onToast);
|
||||
return () => toastBus.removeEventListener("toast", onToast);
|
||||
}, []);
|
||||
if (toasts.length === 0) return null;
|
||||
return html`
|
||||
<div class="toast-stack">
|
||||
${toasts.map((t) => html`<div key=${t.id} class="toast ${t.kind}">${t.text}</div>`)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
export type HunkLineType = "add" | "del" | "ctx";
|
||||
|
||||
export interface HunkLine {
|
||||
type: HunkLineType;
|
||||
content: string;
|
||||
oldLineNum?: number;
|
||||
newLineNum?: number;
|
||||
}
|
||||
|
||||
export interface Hunk {
|
||||
oldStart: number;
|
||||
oldLines: number;
|
||||
newStart: number;
|
||||
newLines: number;
|
||||
lines: HunkLine[];
|
||||
}
|
||||
|
||||
/** Parse a unified diff patch string into structured hunk arrays. No external deps. */
|
||||
export function parseHunks(patch: string): Hunk[] {
|
||||
if (!patch) return [];
|
||||
|
||||
const hunks: Hunk[] = [];
|
||||
const rawLines = patch.split("\n");
|
||||
let cursor = 0;
|
||||
|
||||
// Skip file headers (--- a/..., +++ b/...)
|
||||
while (cursor < rawLines.length) {
|
||||
const line = rawLines[cursor]!;
|
||||
|
||||
// Start of a hunk: @@ -oldStart[,oldLines] +newStart[,newLines] @@
|
||||
const m = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line);
|
||||
if (m) {
|
||||
const oldStart = parseInt(m[1]!, 10);
|
||||
const oldLen = m[2] !== undefined ? parseInt(m[2], 10) : 1;
|
||||
const newStart = parseInt(m[3]!, 10);
|
||||
const newLen = m[4] !== undefined ? parseInt(m[4], 10) : 1;
|
||||
|
||||
const lines: HunkLine[] = [];
|
||||
let oldNum = oldStart;
|
||||
let newNum = newStart;
|
||||
|
||||
cursor++;
|
||||
while (
|
||||
cursor < rawLines.length &&
|
||||
!rawLines[cursor]!.startsWith("@@ ") &&
|
||||
!rawLines[cursor]!.startsWith("diff ") &&
|
||||
!rawLines[cursor]!.startsWith("--- ") &&
|
||||
!rawLines[cursor]!.startsWith("index ")
|
||||
) {
|
||||
const l = rawLines[cursor]!;
|
||||
if (l.startsWith("\\")) {
|
||||
cursor++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const ch = l[0]!;
|
||||
const content = l.slice(1);
|
||||
|
||||
if (ch === "-") {
|
||||
lines.push({ type: "del", content, oldLineNum: oldNum });
|
||||
oldNum++;
|
||||
} else if (ch === "+") {
|
||||
lines.push({ type: "add", content, newLineNum: newNum });
|
||||
newNum++;
|
||||
} else {
|
||||
lines.push({ type: "ctx", content, oldLineNum: oldNum, newLineNum: newNum });
|
||||
oldNum++;
|
||||
newNum++;
|
||||
}
|
||||
cursor++;
|
||||
}
|
||||
|
||||
hunks.push({ oldStart, oldLines: oldLen, newStart, newLines: newLen, lines });
|
||||
} else {
|
||||
cursor++;
|
||||
}
|
||||
}
|
||||
|
||||
return hunks;
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import htm from "htm";
|
||||
import { Component, type ComponentChildren, h } from "preact";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { t } from "../i18n/index.js";
|
||||
import { MODE } from "./api.js";
|
||||
import { type ErrorReport, appBus, reportAppError } from "./bus.js";
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
const REPO_URL = "https://github.com/esengine/reasonix";
|
||||
|
||||
function buildIssueBody({ error, source, info }: ErrorReport): string {
|
||||
const ua = typeof navigator === "object" ? navigator.userAgent : "(unknown)";
|
||||
const errMsg = (error as Error)?.message ?? String(error);
|
||||
const stack = (error as Error)?.stack ?? "(no stack)";
|
||||
return [
|
||||
"**What happened**",
|
||||
"(describe what you were doing — typing, switching tabs, clicking a tool path, etc.)",
|
||||
"",
|
||||
"**Error**",
|
||||
"```",
|
||||
`${source}: ${errMsg}`,
|
||||
info ? `info: ${info}` : null,
|
||||
"",
|
||||
stack,
|
||||
"```",
|
||||
"",
|
||||
"**Environment**",
|
||||
`- Reasonix: ${MODE}`,
|
||||
`- Browser: ${ua}`,
|
||||
`- URL: ${location.pathname} (token redacted)`,
|
||||
"",
|
||||
"_Reported from the local dashboard's error overlay._",
|
||||
]
|
||||
.filter((l): l is string => l !== null)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function ErrorOverlay() {
|
||||
const [err, setErr] = useState<ErrorReport | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onError = (ev: Event) => {
|
||||
setErr((ev as CustomEvent).detail as ErrorReport);
|
||||
setCopied(false);
|
||||
};
|
||||
appBus.addEventListener("error", onError);
|
||||
return () => appBus.removeEventListener("error", onError);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!err) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setErr(null);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [err]);
|
||||
|
||||
if (!err) return null;
|
||||
const error = err.error as Error | undefined;
|
||||
const errMsg = error?.message ?? String(error);
|
||||
const stack = error?.stack ?? "(no stack)";
|
||||
|
||||
const issueUrl = `${REPO_URL}/issues/new?title=${encodeURIComponent(`[dashboard] ${errMsg.slice(0, 80)}`)}&body=${encodeURIComponent(buildIssueBody(err))}`;
|
||||
|
||||
const copyDetails = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(buildIssueBody(err));
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
/* clipboard blocked — user can still hit "report on GitHub" */
|
||||
}
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="error-overlay">
|
||||
<div class="error-overlay-card">
|
||||
<div class="error-overlay-head">
|
||||
<span class="error-overlay-icon">✦</span>
|
||||
<div>
|
||||
<div class="error-overlay-title">${t("error.title")}</div>
|
||||
<div class="error-overlay-subtitle">${err.source} error · ${errMsg}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<pre class="error-overlay-trace">${stack}</pre>
|
||||
|
||||
${
|
||||
err.info
|
||||
? html`<div class="error-overlay-info"><strong>info:</strong> ${err.info}</div>`
|
||||
: null
|
||||
}
|
||||
|
||||
<div class="error-overlay-help">
|
||||
${t("error.body")}
|
||||
</div>
|
||||
|
||||
<div class="error-overlay-actions">
|
||||
<button class="primary" onClick=${copyDetails}>
|
||||
${copied ? t("error.copied") : t("error.copyDetails")}
|
||||
</button>
|
||||
<a class="button" href=${issueUrl} target="_blank" rel="noopener noreferrer">
|
||||
${t("error.reportOnGithub")}
|
||||
</a>
|
||||
<button onClick=${() => setErr(null)} style="margin-left: auto;">${t("error.dismiss")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ComponentChildren;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
caught: boolean;
|
||||
lastErr: Error | null;
|
||||
attempts: number;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { caught: false, lastErr: null, attempts: 0 };
|
||||
}
|
||||
static override getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
|
||||
return { caught: true, lastErr: error };
|
||||
}
|
||||
override componentDidCatch(error: Error, info: { componentStack?: string }) {
|
||||
reportAppError(error, "render", info?.componentStack ?? "");
|
||||
const attempts = (this.state.attempts ?? 0) + 1;
|
||||
if (attempts >= 3) {
|
||||
this.setState({ attempts });
|
||||
return;
|
||||
}
|
||||
setTimeout(() => this.setState({ caught: false, attempts }), 100);
|
||||
}
|
||||
override render() {
|
||||
if (this.state.caught) {
|
||||
if ((this.state.attempts ?? 0) >= 3) {
|
||||
return html`
|
||||
<div class="boot" style="flex-direction: column; gap: 12px;">
|
||||
<div>${t("error.crashing", { name: "the error overlay has the trace" })}</div>
|
||||
<button onClick=${() => this.setState({ caught: false, attempts: 0 })}>
|
||||
${t("error.retry")}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return html`<div class="boot">${t("error.recovering")}</div>`;
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
import { h } from "preact";
|
||||
import { useState, useCallback, useEffect } from "preact/hooks";
|
||||
import htm from "htm";
|
||||
import { api, MODE } from "./api.js";
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
export interface TreeNode {
|
||||
name: string;
|
||||
path: string;
|
||||
isDir: boolean;
|
||||
children?: TreeNode[];
|
||||
}
|
||||
|
||||
export interface OpenFile {
|
||||
path: string;
|
||||
name: string;
|
||||
content: string;
|
||||
language: string;
|
||||
}
|
||||
|
||||
const EXT_ICONS: Record<string, string> = {
|
||||
ts: "TS", tsx: "TS", js: "JS", jsx: "JS",
|
||||
json: "{}", css: "#", scss: "#", html: "<>",
|
||||
md: "MD", py: "PY", rs: "RS", go: "GO",
|
||||
yaml: "Y", yml: "Y", toml: "T", xml: "<>",
|
||||
svg: "<>", png: "[]", jpg: "[]", ico: "[]",
|
||||
sh: "$", bash: "$", ps1: "$", bat: "$",
|
||||
sql: "DB", graphql: "GQ", proto: "PB",
|
||||
dockerfile: "D", makefile: "MK",
|
||||
};
|
||||
|
||||
const EXT_LANG: Record<string, string> = {
|
||||
ts: "typescript", tsx: "typescript", js: "javascript", jsx: "javascript",
|
||||
json: "json", css: "css", scss: "scss", html: "html",
|
||||
md: "markdown", py: "python", rs: "rust", go: "go",
|
||||
yaml: "yaml", yml: "yaml", toml: "toml", xml: "xml",
|
||||
sh: "bash", bash: "bash", ps1: "powershell", bat: "batch",
|
||||
sql: "sql", graphql: "graphql", proto: "protobuf",
|
||||
dockerfile: "dockerfile", makefile: "makefile",
|
||||
};
|
||||
|
||||
export function getFileIcon(name: string): { icon: string; cls: string } {
|
||||
const ext = name.split(".").pop()?.toLowerCase() ?? "";
|
||||
const icon = EXT_ICONS[ext] ?? "·";
|
||||
const cls = ext || "file";
|
||||
return { icon, cls };
|
||||
}
|
||||
|
||||
export function getLanguage(name: string): string {
|
||||
const ext = name.split(".").pop()?.toLowerCase() ?? "";
|
||||
return EXT_LANG[ext] ?? ext;
|
||||
}
|
||||
|
||||
function isBinaryExt(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase() ?? "";
|
||||
const binary = new Set(["png", "jpg", "jpeg", "gif", "ico", "svg", "woff", "woff2", "ttf", "eot", "mp4", "webm", "mp3", "wav", "zip", "tar", "gz", "7z", "pdf"]);
|
||||
return binary.has(ext);
|
||||
}
|
||||
|
||||
interface ProjectTreeResult {
|
||||
tree: TreeNode[];
|
||||
}
|
||||
|
||||
export function useProjectTree() {
|
||||
const [tree, setTree] = useState<TreeNode[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (MODE === "standalone") {
|
||||
setLoading(false);
|
||||
setTree(createDemoTree());
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
api<ProjectTreeResult>("/project-tree")
|
||||
.then((r) => {
|
||||
if (!cancelled) {
|
||||
setTree(r.tree);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
setError((err as Error).message);
|
||||
setLoading(false);
|
||||
setTree(createDemoTree());
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
return { tree, loading, error };
|
||||
}
|
||||
|
||||
interface FileReadResult {
|
||||
content: string;
|
||||
path: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface FileLoadingState {
|
||||
[path: string]: boolean;
|
||||
}
|
||||
|
||||
export function useFileTreeState(initialTree: TreeNode[]) {
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [openFiles, setOpenFiles] = useState<OpenFile[]>([]);
|
||||
const [activeFilePath, setActiveFilePath] = useState<string | null>(null);
|
||||
const [loadingFiles, setLoadingFiles] = useState<FileLoadingState>({});
|
||||
|
||||
const toggleExpand = useCallback((path: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(path)) next.delete(path);
|
||||
else next.add(path);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const openFile = useCallback(async (node: TreeNode) => {
|
||||
if (node.isDir) {
|
||||
toggleExpand(node.path);
|
||||
return;
|
||||
}
|
||||
if (isBinaryExt(node.name)) return;
|
||||
|
||||
const existing = openFiles.find((f) => f.path === node.path);
|
||||
if (existing) {
|
||||
setActiveFilePath(node.path);
|
||||
return;
|
||||
}
|
||||
|
||||
if (loadingFiles[node.path]) return;
|
||||
|
||||
setLoadingFiles((prev) => ({ ...prev, [node.path]: true }));
|
||||
|
||||
const lang = getLanguage(node.name);
|
||||
|
||||
if (MODE === "standalone") {
|
||||
const mockContent = generateMockContent(node.name, lang);
|
||||
setOpenFiles((prev) => [...prev, { path: node.path, name: node.name, content: mockContent, language: lang }]);
|
||||
setActiveFilePath(node.path);
|
||||
setLoadingFiles((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[node.path];
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const encodedPath = node.path.split("/").map(encodeURIComponent).join("/");
|
||||
const data = await api<FileReadResult>(`/file/${encodedPath}`);
|
||||
setOpenFiles((prev) => [...prev, { path: node.path, name: node.name, content: data.content, language: lang }]);
|
||||
setActiveFilePath(node.path);
|
||||
} catch (err) {
|
||||
console.error(`[file-tree] failed to load ${node.path}:`, err);
|
||||
setOpenFiles((prev) => [...prev, { path: node.path, name: node.name, content: `// Failed to load file: ${(err as Error).message}\n`, language: lang }]);
|
||||
setActiveFilePath(node.path);
|
||||
} finally {
|
||||
setLoadingFiles((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[node.path];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [openFiles, toggleExpand, loadingFiles]);
|
||||
|
||||
const closeFile = useCallback((path: string) => {
|
||||
setOpenFiles((prev) => {
|
||||
const next = prev.filter((f) => f.path !== path);
|
||||
if (activeFilePath === path) {
|
||||
const lastFile = next[next.length - 1];
|
||||
setActiveFilePath(lastFile ? lastFile.path : null);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [activeFilePath]);
|
||||
|
||||
const activeFile = openFiles.find((f) => f.path === activeFilePath) ?? null;
|
||||
|
||||
return { expanded, openFiles, activeFilePath, activeFile, toggleExpand, openFile, closeFile, setActiveFilePath, loadingFiles };
|
||||
}
|
||||
|
||||
function generateMockContent(name: string, lang: string): string {
|
||||
const ext = name.split(".").pop()?.toLowerCase() ?? "";
|
||||
if (ext === "json") return JSON.stringify({ name: "example", version: "1.0.0", dependencies: { react: "^18.0.0" } }, null, 2);
|
||||
if (ext === "md") return "# Example Document\n\nThis is a sample markdown file.\n\n## Section\n\n- Item 1\n- Item 2\n\n```js\nconsole.log(\"hello\");\n```";
|
||||
if (ext === "css") return "/* styles */\n.container {\n display: flex;\n padding: 16px;\n color: var(--fg-1);\n}";
|
||||
if (ext === "html") return "<!doctype html>\n<html>\n<head><title>Example</title></head>\n<body>\n <h1>Hello</h1>\n</body>\n</html>";
|
||||
if (ext === "py") return "def hello():\n print('Hello, World!')\n\nif __name__ == '__main__':\n hello()";
|
||||
if (ext === "yaml" || ext === "yml") return "name: example\nversion: '1.0'\nservices:\n app:\n image: node:18\n ports:\n - '3000:3000'";
|
||||
return `// ${name}\n// Language: ${lang}\n\nexport function example() {\n return \"Hello from ${name}\";\n}\n`;
|
||||
}
|
||||
|
||||
export function createDemoTree(): TreeNode[] {
|
||||
return [
|
||||
{
|
||||
name: "src", path: "src", isDir: true, children: [
|
||||
{ name: "index.ts", path: "src/index.ts", isDir: false },
|
||||
{ name: "app.tsx", path: "src/app.tsx", isDir: false },
|
||||
{ name: "config.ts", path: "src/config.ts", isDir: false },
|
||||
{
|
||||
name: "components", path: "src/components", isDir: true, children: [
|
||||
{ name: "Header.tsx", path: "src/components/Header.tsx", isDir: false },
|
||||
{ name: "Sidebar.tsx", path: "src/components/Sidebar.tsx", isDir: false },
|
||||
{ name: "Button.tsx", path: "src/components/Button.tsx", isDir: false },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "lib", path: "src/lib", isDir: true, children: [
|
||||
{ name: "api.ts", path: "src/lib/api.ts", isDir: false },
|
||||
{ name: "format.ts", path: "src/lib/format.ts", isDir: false },
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "tests", path: "tests", isDir: true, children: [
|
||||
{ name: "app.test.ts", path: "tests/app.test.ts", isDir: false },
|
||||
{ name: "utils.test.ts", path: "tests/utils.test.ts", isDir: false },
|
||||
]
|
||||
},
|
||||
{ name: "package.json", path: "package.json", isDir: false },
|
||||
{ name: "tsconfig.json", path: "tsconfig.json", isDir: false },
|
||||
{ name: "README.md", path: "README.md", isDir: false },
|
||||
{ name: "styles.css", path: "styles.css", isDir: false },
|
||||
{ name: "index.html", path: "index.html", isDir: false },
|
||||
];
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { t } from "../i18n/index.js";
|
||||
|
||||
export function fmtUsd(n: number | null | undefined): string {
|
||||
if (n === null || n === undefined) return "—";
|
||||
if (n === 0) return "$0";
|
||||
return `$${n.toFixed(n < 0.01 ? 6 : 4)}`;
|
||||
}
|
||||
|
||||
/** Keep in sync with src/cli/ui/theme/tokens.ts USD_TO_CNY. */
|
||||
const USD_TO_CNY = 7.2;
|
||||
|
||||
/** USD-internal cost rendered in the wallet's display currency. Undefined currency → CNY (matches CLI default). */
|
||||
export function fmtCost(
|
||||
usd: number | null | undefined,
|
||||
currency: string | null | undefined,
|
||||
fractionDigits?: number,
|
||||
): string {
|
||||
if (usd === null || usd === undefined) return "—";
|
||||
const cur = currency ?? "CNY";
|
||||
const amount = cur === "CNY" ? usd * USD_TO_CNY : usd;
|
||||
if (amount === 0) return cur === "CNY" ? "¥0" : "$0";
|
||||
const sym = cur === "CNY" ? "¥" : cur === "USD" ? "$" : `${cur} `;
|
||||
const digits = fractionDigits ?? (Math.abs(amount) < 0.01 ? 6 : 4);
|
||||
return `${sym}${amount.toFixed(digits)}`;
|
||||
}
|
||||
|
||||
export function fmtPct(n: number | null | undefined): string {
|
||||
if (n === null || n === undefined) return "—";
|
||||
return `${(n * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
export function fmtNum(n: number | null | undefined): string {
|
||||
if (n === null || n === undefined) return "—";
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
export function fmtBytes(n: number | null | undefined): string {
|
||||
if (n === null || n === undefined) return "—";
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
export function fmtCompactNum(n: number | null | undefined): string {
|
||||
if (n === null || n === undefined) return "—";
|
||||
if (Math.abs(n) < 1000) return String(n);
|
||||
if (Math.abs(n) < 1_000_000) {
|
||||
const v = n / 1000;
|
||||
return `${v % 1 === 0 ? v.toFixed(0) : v.toFixed(1)}K`;
|
||||
}
|
||||
if (Math.abs(n) < 1_000_000_000) {
|
||||
const v = n / 1_000_000;
|
||||
return `${v % 1 === 0 ? v.toFixed(0) : v.toFixed(1)}M`;
|
||||
}
|
||||
return `${(n / 1_000_000_000).toFixed(1)}B`;
|
||||
}
|
||||
|
||||
export function fmtRelativeTime(iso: string | number | null | undefined): string {
|
||||
if (!iso) return "—";
|
||||
const ms = typeof iso === "number" ? iso : Date.parse(iso);
|
||||
if (!Number.isFinite(ms)) return "—";
|
||||
const dSec = (Date.now() - ms) / 1000;
|
||||
if (dSec < 60) return t("fmt.justNow");
|
||||
if (dSec < 3600) return t("fmt.minutesAgo", { n: Math.floor(dSec / 60) });
|
||||
if (dSec < 86400) return t("fmt.hoursAgo", { n: Math.floor(dSec / 3600) });
|
||||
if (dSec < 30 * 86400) return t("fmt.daysAgo", { n: Math.floor(dSec / 86400) });
|
||||
return new Date(ms).toISOString().slice(0, 10);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
import htm from "htm";
|
||||
import { h } from "preact";
|
||||
|
||||
export const html = htm.bind(h);
|
||||
@@ -1,117 +0,0 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { TOKEN, api } from "./api.js";
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
export type DashboardLang = "en" | "zh-CN";
|
||||
|
||||
// [dashboardCode, backendCode] — add new languages here.
|
||||
const LANG_REGISTRY: [DashboardLang, string][] = [
|
||||
["en", "EN"],
|
||||
["zh-CN", "zh-CN"],
|
||||
];
|
||||
|
||||
const SUPPORTED = new Set(LANG_REGISTRY.map(([d]) => d));
|
||||
const TO_BACKEND = new Map(LANG_REGISTRY);
|
||||
const FROM_BACKEND = new Map(LANG_REGISTRY.map(([d, b]) => [b, d]));
|
||||
|
||||
const STORAGE_KEY = "rx.lang";
|
||||
const listeners: Listener[] = [];
|
||||
let currentLang: DashboardLang = loadFromStorage() ?? "en";
|
||||
|
||||
function loadFromStorage(): DashboardLang | null {
|
||||
try {
|
||||
const v = localStorage.getItem(STORAGE_KEY);
|
||||
if (v !== null && SUPPORTED.has(v as DashboardLang)) return v as DashboardLang;
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toBackendLang(lang: DashboardLang): string {
|
||||
return TO_BACKEND.get(lang) ?? lang;
|
||||
}
|
||||
|
||||
function fromBackendLang(raw: string): DashboardLang {
|
||||
return (FROM_BACKEND.get(raw) as DashboardLang) ?? "en";
|
||||
}
|
||||
|
||||
/** Adopt server lang on startup; localStorage is render-cache only, never pushed back. */
|
||||
export async function initLangFromServer(): Promise<void> {
|
||||
try {
|
||||
const res = await api<{ lang?: string }>("/settings");
|
||||
const serverLang = res.lang ? fromBackendLang(res.lang) : null;
|
||||
if (!serverLang || serverLang === currentLang) return;
|
||||
currentLang = serverLang;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, serverLang);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
for (const cb of listeners) cb();
|
||||
} catch {
|
||||
/* offline — keep last-known value rendering */
|
||||
}
|
||||
}
|
||||
|
||||
export function getLang(): DashboardLang {
|
||||
return currentLang;
|
||||
}
|
||||
|
||||
export function setLang(lang: DashboardLang): void {
|
||||
if (!SUPPORTED.has(lang)) return;
|
||||
currentLang = lang;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, lang);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
for (const cb of listeners) cb();
|
||||
// keepalive ensures the request completes even during page unload (refresh).
|
||||
fetch(`/api/settings?token=${TOKEN}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-Reasonix-Token": TOKEN },
|
||||
body: JSON.stringify({ lang: toBackendLang(lang) }),
|
||||
keepalive: true,
|
||||
}).catch((err) => console.error("[reasonix dashboard] lang persist:", err));
|
||||
}
|
||||
|
||||
export function onLangChange(cb: Listener): () => void {
|
||||
listeners.push(cb);
|
||||
return () => {
|
||||
const i = listeners.indexOf(cb);
|
||||
if (i >= 0) listeners.splice(i, 1);
|
||||
};
|
||||
}
|
||||
|
||||
export function useLang(): DashboardLang {
|
||||
const [lang, setLangState] = useState<DashboardLang>(currentLang);
|
||||
useEffect(() => onLangChange(() => setLangState(currentLang)), []);
|
||||
return lang;
|
||||
}
|
||||
|
||||
type Nested = { [k: string]: string | Nested };
|
||||
|
||||
function get(translations: Nested | undefined, path: string): string | undefined {
|
||||
let val: string | Nested | undefined = translations;
|
||||
for (const part of path.split(".")) {
|
||||
if (val === undefined || typeof val === "string") return undefined;
|
||||
val = val[part];
|
||||
}
|
||||
return typeof val === "string" ? val : undefined;
|
||||
}
|
||||
|
||||
export function createT(translations: Record<string, Nested>) {
|
||||
return function t(path: string, params?: Record<string, string | number>): string {
|
||||
let val = get(translations[currentLang] ?? translations.en, path);
|
||||
if (val === undefined) val = get(translations.en, path);
|
||||
if (val === undefined) return path;
|
||||
if (!params) return val;
|
||||
let result = val;
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
result = result.replaceAll(`{${k}}`, String(v));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { useState, useCallback } from "preact/hooks";
|
||||
|
||||
export interface LineComment {
|
||||
id: string;
|
||||
file: string;
|
||||
lineNumber: number;
|
||||
content: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface LineCommentDraft {
|
||||
file: string;
|
||||
lineNumber: number;
|
||||
content: string;
|
||||
editingId?: string;
|
||||
}
|
||||
|
||||
export function useLineComments() {
|
||||
const [comments, setComments] = useState<LineComment[]>([]);
|
||||
const [draft, setDraft] = useState<LineCommentDraft | null>(null);
|
||||
|
||||
const addComment = useCallback((file: string, lineNumber: number, content: string) => {
|
||||
const id = `comment-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
setComments((prev) => [...prev, { id, file, lineNumber, content, timestamp: Date.now() }]);
|
||||
setDraft(null);
|
||||
}, []);
|
||||
|
||||
const updateComment = useCallback((id: string, content: string) => {
|
||||
setComments((prev) => prev.map((c) => (c.id === id ? { ...c, content } : c)));
|
||||
}, []);
|
||||
|
||||
const deleteComment = useCallback((id: string) => {
|
||||
setComments((prev) => prev.filter((c) => c.id !== id));
|
||||
}, []);
|
||||
|
||||
const startDraft = useCallback((file: string, lineNumber: number) => {
|
||||
setDraft({ file, lineNumber, content: "" });
|
||||
}, []);
|
||||
|
||||
const editComment = useCallback((id: string, content: string) => {
|
||||
const comment = comments.find((c) => c.id === id);
|
||||
if (comment) {
|
||||
setDraft({ file: comment.file, lineNumber: comment.lineNumber, content, editingId: id });
|
||||
}
|
||||
}, [comments]);
|
||||
|
||||
const cancelDraft = useCallback(() => {
|
||||
setDraft(null);
|
||||
}, []);
|
||||
|
||||
const setDraftContent = useCallback((content: string) => {
|
||||
setDraft((prev) => (prev ? { ...prev, content } : null));
|
||||
}, []);
|
||||
|
||||
const submitDraft = useCallback(() => {
|
||||
if (draft && draft.content.trim()) {
|
||||
if (draft.editingId) {
|
||||
updateComment(draft.editingId, draft.content.trim());
|
||||
} else {
|
||||
addComment(draft.file, draft.lineNumber, draft.content.trim());
|
||||
}
|
||||
setDraft(null);
|
||||
}
|
||||
}, [draft, addComment, updateComment]);
|
||||
|
||||
const commentsForFile = useCallback(
|
||||
(file: string) => comments.filter((c) => c.file === file),
|
||||
[comments],
|
||||
);
|
||||
|
||||
const commentsForLine = useCallback(
|
||||
(file: string, lineNumber: number) => comments.filter((c) => c.file === file && c.lineNumber === lineNumber),
|
||||
[comments],
|
||||
);
|
||||
|
||||
return {
|
||||
comments,
|
||||
draft,
|
||||
addComment,
|
||||
updateComment,
|
||||
deleteComment,
|
||||
startDraft,
|
||||
editComment,
|
||||
cancelDraft,
|
||||
setDraftContent,
|
||||
submitDraft,
|
||||
commentsForFile,
|
||||
commentsForLine,
|
||||
};
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
export type IntervalUnit = "s" | "m" | "h";
|
||||
|
||||
export interface LoopRunStatus {
|
||||
prompt: string;
|
||||
intervalMs: number;
|
||||
iter: number;
|
||||
/** Wall-clock ms until the next fire — server reports a remaining duration, not an absolute. */
|
||||
nextFireMs: number;
|
||||
}
|
||||
|
||||
/** Quick-pick intervals in ms — covers the 95% of cases users actually run. */
|
||||
export const INTERVAL_PRESETS_MS: ReadonlyArray<{ ms: number; label: string }> = [
|
||||
{ ms: 30_000, label: "30s" },
|
||||
{ ms: 60_000, label: "1m" },
|
||||
{ ms: 5 * 60_000, label: "5m" },
|
||||
{ ms: 15 * 60_000, label: "15m" },
|
||||
{ ms: 60 * 60_000, label: "1h" },
|
||||
{ ms: 6 * 60 * 60_000, label: "6h" },
|
||||
];
|
||||
|
||||
const UNIT_TO_MS: Record<IntervalUnit, number> = {
|
||||
s: 1_000,
|
||||
m: 60_000,
|
||||
h: 60 * 60_000,
|
||||
};
|
||||
|
||||
const MIN_INTERVAL_MS = 5_000;
|
||||
const MAX_INTERVAL_MS = 6 * 60 * 60_000;
|
||||
|
||||
/** Convert a "30" + "s" pair to ms, returning null if out of [5s, 6h]. */
|
||||
export function parseCustomInterval(value: string, unit: IntervalUnit): number | null {
|
||||
const n = Number.parseFloat(value);
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
const ms = Math.round(n * UNIT_TO_MS[unit]);
|
||||
if (ms < MIN_INTERVAL_MS || ms > MAX_INTERVAL_MS) return null;
|
||||
return ms;
|
||||
}
|
||||
|
||||
/** Human-friendly "5m 12s" / "12s" / "2h 45m" — shows two largest non-zero units. */
|
||||
export function formatRemaining(ms: number): string {
|
||||
const safe = Math.max(0, Math.floor(ms / 1000));
|
||||
const h = Math.floor(safe / 3600);
|
||||
const m = Math.floor((safe % 3600) / 60);
|
||||
const s = safe % 60;
|
||||
if (h > 0) return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
||||
if (m > 0) return s > 0 ? `${m}m ${s}s` : `${m}m`;
|
||||
return `${s}s`;
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import hljs from "highlight.js/lib/common";
|
||||
import { marked } from "marked";
|
||||
|
||||
export function escapeHtml(s: unknown): string {
|
||||
if (s == null) return "";
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
const SEARCH_REPLACE_RE = /<{7}\s*SEARCH\s*\n([\s\S]*?)\n={7}\s*\n([\s\S]*?)\n>{7}\s*REPLACE/;
|
||||
|
||||
export function renderSearchReplace(search: string, replace: string, file: string): string {
|
||||
const safeSearch = typeof search === "string" ? search : String(search ?? "");
|
||||
const safeReplace = typeof replace === "string" ? replace : String(replace ?? "");
|
||||
const oldLines = safeSearch
|
||||
.split("\n")
|
||||
.map((l) => `<span class="diff-line del">- ${escapeHtml(l)}</span>`)
|
||||
.join("\n");
|
||||
const newLines = safeReplace
|
||||
.split("\n")
|
||||
.map((l) => `<span class="diff-line ins">+ ${escapeHtml(l)}</span>`)
|
||||
.join("\n");
|
||||
const header = file ? `<span class="diff-line hunk">▸ edit ${escapeHtml(file)}</span>\n` : "";
|
||||
return `<pre class="diff-block">${header}${oldLines}\n${newLines}</pre>`;
|
||||
}
|
||||
|
||||
export function renderUnifiedDiff(text: string): string {
|
||||
const safe = typeof text === "string" ? text : String(text ?? "");
|
||||
const lines = safe
|
||||
.split("\n")
|
||||
.map((l) => {
|
||||
if (l.startsWith("+++") || l.startsWith("---")) {
|
||||
return `<span class="diff-line meta">${escapeHtml(l)}</span>`;
|
||||
}
|
||||
if (l.startsWith("+")) return `<span class="diff-line ins">${escapeHtml(l)}</span>`;
|
||||
if (l.startsWith("-")) return `<span class="diff-line del">${escapeHtml(l)}</span>`;
|
||||
if (l.startsWith("@@")) return `<span class="diff-line hunk">${escapeHtml(l)}</span>`;
|
||||
return escapeHtml(l);
|
||||
})
|
||||
.join("\n");
|
||||
return `<pre class="diff-block">${lines}</pre>`;
|
||||
}
|
||||
|
||||
const renderer = new marked.Renderer();
|
||||
renderer.code = function reasonixCode(arg1: unknown, arg2?: string): string {
|
||||
let text: unknown;
|
||||
let lang: string | undefined;
|
||||
if (arg1 && typeof arg1 === "object" && !Array.isArray(arg1)) {
|
||||
text = (arg1 as { text?: unknown }).text;
|
||||
lang = (arg1 as { lang?: string }).lang;
|
||||
} else {
|
||||
text = arg1;
|
||||
lang = arg2;
|
||||
}
|
||||
if (text == null) text = "";
|
||||
const codeText: string = typeof text === "string" ? text : String(text);
|
||||
const sr = SEARCH_REPLACE_RE.exec(codeText);
|
||||
if (sr) {
|
||||
const [, search = "", replace = ""] = sr;
|
||||
const file = typeof lang === "string" && lang.startsWith("edit:") ? lang.slice(5) : "";
|
||||
return renderSearchReplace(search, replace, file);
|
||||
}
|
||||
if (lang === "diff") return renderUnifiedDiff(codeText);
|
||||
if (lang && typeof lang === "string" && hljs.getLanguage(lang)) {
|
||||
try {
|
||||
const h = hljs.highlight(codeText, { language: lang, ignoreIllegals: true }).value;
|
||||
return `<pre><code class="hljs language-${lang}">${h}</code></pre>`;
|
||||
} catch {
|
||||
/* fall through to auto */
|
||||
}
|
||||
}
|
||||
try {
|
||||
const auto = hljs.highlightAuto(codeText);
|
||||
return `<pre><code class="hljs">${auto.value}</code></pre>`;
|
||||
} catch {
|
||||
return `<pre><code>${escapeHtml(codeText)}</code></pre>`;
|
||||
}
|
||||
};
|
||||
|
||||
marked.use({ renderer, gfm: true, breaks: false, pedantic: false });
|
||||
|
||||
export function renderMarkdownToString(text: string): string {
|
||||
return marked.parse(text) as string;
|
||||
}
|
||||
|
||||
const LANG_BY_EXT: Record<string, string> = {
|
||||
ts: "typescript",
|
||||
tsx: "typescript",
|
||||
js: "javascript",
|
||||
jsx: "javascript",
|
||||
mjs: "javascript",
|
||||
cjs: "javascript",
|
||||
py: "python",
|
||||
rs: "rust",
|
||||
go: "go",
|
||||
java: "java",
|
||||
kt: "kotlin",
|
||||
c: "c",
|
||||
h: "c",
|
||||
cpp: "cpp",
|
||||
cc: "cpp",
|
||||
hpp: "cpp",
|
||||
cs: "csharp",
|
||||
swift: "swift",
|
||||
rb: "ruby",
|
||||
php: "php",
|
||||
sh: "bash",
|
||||
bash: "bash",
|
||||
zsh: "bash",
|
||||
fish: "bash",
|
||||
ps1: "powershell",
|
||||
json: "json",
|
||||
yaml: "yaml",
|
||||
yml: "yaml",
|
||||
toml: "ini",
|
||||
xml: "xml",
|
||||
html: "xml",
|
||||
svg: "xml",
|
||||
css: "css",
|
||||
scss: "scss",
|
||||
less: "less",
|
||||
md: "markdown",
|
||||
sql: "sql",
|
||||
vue: "xml",
|
||||
svelte: "xml",
|
||||
tex: "latex",
|
||||
proto: "protobuf",
|
||||
dockerfile: "dockerfile",
|
||||
};
|
||||
|
||||
export function langFromPath(path: string | null | undefined): string | null {
|
||||
if (!path) return null;
|
||||
const lower = path.toLowerCase();
|
||||
if (lower.endsWith("dockerfile")) return "dockerfile";
|
||||
const dot = lower.lastIndexOf(".");
|
||||
if (dot < 0) return null;
|
||||
const ext = lower.slice(dot + 1);
|
||||
return LANG_BY_EXT[ext] ?? null;
|
||||
}
|
||||
|
||||
export function renderHighlightedBlock(text: string, lang: string | null | undefined): string {
|
||||
if (!text) return "";
|
||||
const safeLang = lang && hljs.getLanguage(lang) ? lang : null;
|
||||
try {
|
||||
const out = safeLang
|
||||
? hljs.highlight(text, { language: safeLang, ignoreIllegals: true })
|
||||
: hljs.highlightAuto(text);
|
||||
return `<pre class="md"><code class="hljs ${safeLang ? `language-${safeLang}` : ""}">${out.value}</code></pre>`;
|
||||
} catch {
|
||||
return `<pre><code>${escapeHtml(text)}</code></pre>`;
|
||||
}
|
||||
}
|
||||
|
||||
export function hlLine(text: string | null | undefined, lang: string | null | undefined): string {
|
||||
if (text == null) return "";
|
||||
if (text === "") return "";
|
||||
try {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
return hljs.highlight(text, { language: lang, ignoreIllegals: true }).value;
|
||||
}
|
||||
return hljs.highlightAuto(text).value;
|
||||
} catch {
|
||||
return escapeHtml(text);
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
type SpecRecord = Record<string, unknown>;
|
||||
|
||||
function isRecord(value: unknown): value is SpecRecord {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stringField(obj: SpecRecord, key: string): string | null {
|
||||
const value = obj[key];
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function stringArgs(obj: SpecRecord): string[] {
|
||||
const args = obj.args;
|
||||
if (!Array.isArray(args)) return [];
|
||||
return args.filter((arg): arg is string => typeof arg === "string" && arg.length > 0);
|
||||
}
|
||||
|
||||
export function normalizeMcpSpec(spec: unknown): string | null {
|
||||
if (typeof spec === "string") return spec;
|
||||
if (!isRecord(spec)) return null;
|
||||
|
||||
const raw = stringField(spec, "raw") ?? stringField(spec, "spec");
|
||||
if (raw) return raw;
|
||||
|
||||
const name = stringField(spec, "name") ?? stringField(spec, "label");
|
||||
const command = stringField(spec, "command");
|
||||
const url = stringField(spec, "url");
|
||||
const transport = stringField(spec, "transport");
|
||||
if (name && url) return `${name}=${transport === "streamable-http" ? "streamable+" : ""}${url}`;
|
||||
if (name && command) return `${name}=${[command, ...stringArgs(spec)].join(" ")}`;
|
||||
if (command) return [command, ...stringArgs(spec)].join(" ");
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function mcpSpecLabel(spec: unknown): string {
|
||||
const text = normalizeMcpSpec(spec) ?? "";
|
||||
const eq = text.indexOf("=");
|
||||
return eq > 0 ? text.slice(0, eq) : text;
|
||||
}
|
||||
|
||||
export function mcpSpecCommand(spec: unknown): string {
|
||||
const text = normalizeMcpSpec(spec) ?? "";
|
||||
const eq = text.indexOf("=");
|
||||
return eq > 0 ? text.slice(eq + 1) : text;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { useState, useCallback, useEffect } from "preact/hooks";
|
||||
import { api } from "./api.js";
|
||||
|
||||
export interface FileDiff {
|
||||
file: string;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
patch?: string;
|
||||
status: "added" | "deleted" | "modified";
|
||||
}
|
||||
|
||||
export function useReviewDiffs() {
|
||||
const [diffs, setDiffs] = useState<FileDiff[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loadDiffs = useCallback(async (ep: string = "/review-diffs") => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api<FileDiff[]>(ep);
|
||||
setDiffs(Array.isArray(data) ? data : []);
|
||||
} catch {
|
||||
setDiffs([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const modifiedFiles = useCallback(() => new Set(diffs.map((d) => d.file)), [diffs]);
|
||||
const modifiedCount = useCallback(() => diffs.length, [diffs]);
|
||||
|
||||
return { diffs, loading, modifiedFiles, modifiedCount, reload: loadDiffs };
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
const SESSION_PARAM = "session";
|
||||
|
||||
export function readSessionFromUrl(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const raw = new URLSearchParams(window.location.search).get(SESSION_PARAM);
|
||||
if (!raw) return null;
|
||||
const trimmed = raw.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export function writeSessionToUrl(name: string | null): void {
|
||||
if (typeof window === "undefined") return;
|
||||
const url = new URL(window.location.href);
|
||||
if (name && name.trim().length > 0) {
|
||||
url.searchParams.set(SESSION_PARAM, name);
|
||||
} else {
|
||||
url.searchParams.delete(SESSION_PARAM);
|
||||
}
|
||||
if (url.href === window.location.href) return;
|
||||
window.history.replaceState(null, "", url.href);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,45 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from "preact/hooks";
|
||||
import { type ApiError, api } from "./api.js";
|
||||
|
||||
export interface PollResult<T> {
|
||||
data: T | null;
|
||||
error: ApiError | Error | null;
|
||||
loading: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function usePoll<T = unknown>(path: string, intervalMs = 2000): PollResult<T> {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const next = await api<T>(path);
|
||||
setData(next);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err as Error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [path]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const tick = async () => {
|
||||
if (cancelled) return;
|
||||
await refresh();
|
||||
if (cancelled) return;
|
||||
timer = setTimeout(tick, intervalMs);
|
||||
};
|
||||
tick();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [refresh, intervalMs]);
|
||||
|
||||
return { data, error, loading, refresh };
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
/** Pre-release with same core sorts BELOW the bare version — matches npm `latest` dist-tag semantics. */
|
||||
export function compareVersions(a: string, b: string): number {
|
||||
const [aCore = "0", aPre = ""] = a.split("-", 2);
|
||||
const [bCore = "0", bPre = ""] = b.split("-", 2);
|
||||
const aParts = aCore.split(".").map((p) => Number.parseInt(p, 10) || 0);
|
||||
const bParts = bCore.split(".").map((p) => Number.parseInt(p, 10) || 0);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const diff = (aParts[i] ?? 0) - (bParts[i] ?? 0);
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
if (!aPre && !bPre) return 0;
|
||||
if (!aPre) return 1;
|
||||
if (!bPre) return -1;
|
||||
return aPre < bPre ? -1 : aPre > bPre ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import "@fontsource/geist/400.css";
|
||||
import "@fontsource/geist/500.css";
|
||||
import "@fontsource/geist/600.css";
|
||||
import "@fontsource/geist/700.css";
|
||||
import "@fontsource/geist-mono/400.css";
|
||||
import "@fontsource/geist-mono/500.css";
|
||||
import "@fontsource/geist-mono/600.css";
|
||||
import "@fontsource/inter/400.css";
|
||||
import "@fontsource/inter/500.css";
|
||||
import "@fontsource/inter/600.css";
|
||||
import "@fontsource/inter/700.css";
|
||||
import "./styles.css";
|
||||
import "katex/dist/katex.min.css";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import {
|
||||
defaultStyleForTheme,
|
||||
isTheme,
|
||||
isThemeStyle,
|
||||
themeForStyle,
|
||||
} from "./theme";
|
||||
|
||||
const stored = localStorage.getItem("reasonix.theme");
|
||||
const storedStyle = localStorage.getItem("reasonix.themeStyle");
|
||||
if (isThemeStyle(storedStyle)) {
|
||||
document.documentElement.dataset.themeStyle = storedStyle;
|
||||
document.documentElement.dataset.theme = themeForStyle(storedStyle);
|
||||
} else if (isTheme(stored)) {
|
||||
document.documentElement.dataset.theme = stored;
|
||||
document.documentElement.dataset.themeStyle = defaultStyleForTheme(stored);
|
||||
}
|
||||
|
||||
const platform = /Mac|macOS/i.test(navigator.userAgent)
|
||||
? "macos"
|
||||
: /Windows/i.test(navigator.userAgent)
|
||||
? "windows"
|
||||
: "default";
|
||||
document.documentElement.dataset.platform = platform;
|
||||
document.body.dataset.platform = platform;
|
||||
|
||||
const host = document.getElementById("root");
|
||||
if (!host) throw new Error("#root missing");
|
||||
|
||||
createRoot(host).render(<App />);
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,259 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from "preact/hooks";
|
||||
import { api } from "../lib/api.js";
|
||||
import { fmtRelativeTime } from "../lib/format.js";
|
||||
import { html } from "../lib/html.js";
|
||||
import { t, useLang } from "../i18n/index.js";
|
||||
|
||||
interface HookHandler {
|
||||
command?: string;
|
||||
matcher?: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
interface HookRunRow {
|
||||
hookName: string;
|
||||
phase: "PreToolUse" | "PostToolUse" | "UserPromptSubmit" | "Stop";
|
||||
outcome: "ok" | "blocked" | "modified" | "error";
|
||||
whenMs: number;
|
||||
}
|
||||
|
||||
interface ScopeMeta {
|
||||
path?: string | null;
|
||||
hooks?: Record<string, HookHandler[]>;
|
||||
}
|
||||
|
||||
interface MatrixCell {
|
||||
on: boolean;
|
||||
matcher?: string;
|
||||
}
|
||||
|
||||
interface MatrixRow {
|
||||
scope: "project" | "global";
|
||||
command: string;
|
||||
cells: Record<string, MatrixCell>;
|
||||
}
|
||||
|
||||
function buildMatrix(data: HooksData): MatrixRow[] {
|
||||
const rows = new Map<string, MatrixRow>();
|
||||
for (const scope of ["project", "global"] as const) {
|
||||
const hooks = data[scope].hooks ?? {};
|
||||
for (const [event, handlers] of Object.entries(hooks)) {
|
||||
for (const h of handlers ?? []) {
|
||||
const cmd = h.command ?? "(no command)";
|
||||
const key = `${scope}::${cmd}`;
|
||||
let row = rows.get(key);
|
||||
if (!row) {
|
||||
row = { scope, command: cmd, cells: {} };
|
||||
rows.set(key, row);
|
||||
}
|
||||
row.cells[event] = { on: true, matcher: h.matcher };
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...rows.values()];
|
||||
}
|
||||
|
||||
interface HooksData {
|
||||
resolved: unknown[];
|
||||
events: string[];
|
||||
project: ScopeMeta;
|
||||
global: ScopeMeta;
|
||||
recentRuns?: ReadonlyArray<HookRunRow> | null;
|
||||
}
|
||||
|
||||
export function HooksPanel() {
|
||||
useLang();
|
||||
const [data, setData] = useState<HooksData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
const [eventFilter, setEventFilter] = useState<string>("all");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const r = await api<HooksData>("/hooks");
|
||||
setData(r);
|
||||
setDrafts({
|
||||
project: JSON.stringify(r.project.hooks ?? {}, null, 2),
|
||||
global: JSON.stringify(r.global.hooks ?? {}, null, 2),
|
||||
});
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const saveScope = useCallback(
|
||||
async (scope: "project" | "global") => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(drafts[scope] ?? "{}");
|
||||
} catch (err) {
|
||||
setError(`${scope} JSON: ${(err as Error).message}`);
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api("/hooks/save", { method: "POST", body: { scope, hooks: parsed } });
|
||||
await api("/hooks/reload", { method: "POST", body: {} });
|
||||
setInfo(t("hooks.savedReloaded", { scope }));
|
||||
setTimeout(() => setInfo(null), 3000);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[drafts, load],
|
||||
);
|
||||
|
||||
if (!data && !error)
|
||||
return html`<div class="card" style="color:var(--fg-3)">${t("hooks.loading")}</div>`;
|
||||
if (error && !data) return html`<div class="card accent-err">${error}</div>`;
|
||||
if (!data) return null;
|
||||
|
||||
const sectionH3 = (text: string, sub?: string) => html`
|
||||
<h3 style="margin:18px 0 8px;font-family:var(--font-mono);font-size:11px;color:var(--fg-3);text-transform:uppercase;letter-spacing:.1em">
|
||||
${text}${sub ? html`<span style="margin-left:10px;color:var(--fg-4);font-weight:400;text-transform:none;letter-spacing:0">${sub}</span>` : null}
|
||||
</h3>
|
||||
`;
|
||||
|
||||
const matrixRows = buildMatrix(data);
|
||||
const events =
|
||||
data.events.length > 0
|
||||
? data.events
|
||||
: Array.from(new Set(matrixRows.flatMap((r) => Object.keys(r.cells))));
|
||||
const visibleRows =
|
||||
eventFilter === "all"
|
||||
? matrixRows
|
||||
: matrixRows.filter((r) => r.cells[eventFilter]?.on);
|
||||
const gridCols = `220px repeat(${Math.max(events.length, 1)}, minmax(0, 1fr))`;
|
||||
|
||||
return html`
|
||||
<div style="display:flex;flex-direction:column;gap:6px">
|
||||
<div class="chips">
|
||||
<span
|
||||
class=${`chip-f ${eventFilter === "all" ? "active" : ""}`}
|
||||
onClick=${() => setEventFilter("all")}
|
||||
>${t("hooks.resolved")} <span class="ct">${data.resolved.length}</span></span>
|
||||
${data.events.map(
|
||||
(ev) => html`<span
|
||||
class=${`chip-f ${eventFilter === ev ? "active" : ""}`}
|
||||
onClick=${() => setEventFilter(ev)}
|
||||
>${ev}</span>`,
|
||||
)}
|
||||
</div>
|
||||
${info ? html`<div><span class="pill ok">${info}</span></div>` : null}
|
||||
${error ? html`<div class="card accent-err">${error}</div>` : null}
|
||||
|
||||
${sectionH3(t("hooks.eventMatrix"), t("hooks.matrixSub", { scripts: matrixRows.length, s: matrixRows.length === 1 ? "" : "s", events: events.length }))}${
|
||||
visibleRows.length === 0
|
||||
? html`<div class="card" style="color:var(--fg-3)">
|
||||
${t("hooks.noHooks")}
|
||||
</div>`
|
||||
: html`
|
||||
<div class="card" style="padding:10px 14px;overflow-x:auto">
|
||||
<div class="matrix" style=${`min-width:fit-content`}>
|
||||
<div class="row h" style=${`grid-template-columns:${gridCols}`}>
|
||||
<div>${t("hooks.colScript")}</div>
|
||||
${events.map((ev) => html`<div>${ev}</div>`)}
|
||||
</div>
|
||||
${visibleRows.map(
|
||||
(r) => html`
|
||||
<div class="row" style=${`grid-template-columns:${gridCols}`}>
|
||||
<div class="cell" title=${r.command}>
|
||||
<span style="color:var(--fg-4);font-size:10px;margin-right:6px">${r.scope}</span>
|
||||
<code class="mono" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${r.command}</code>
|
||||
</div>
|
||||
${events.map((ev) => {
|
||||
const c = r.cells[ev];
|
||||
if (!c?.on) return html`<div class="cell off">—</div>`;
|
||||
return html`
|
||||
<div class="cell on" title=${c.matcher ?? ""}>
|
||||
${c.matcher ? html`<span style="font-size:10px;color:var(--c-warn)">${c.matcher}</span>` : "✓"}
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
${(["project", "global"] as const).map((scope) => {
|
||||
const meta = data[scope];
|
||||
return html`
|
||||
${sectionH3(scope, meta.path ?? "(no path)")}
|
||||
${
|
||||
scope === "project" && !meta.path
|
||||
? html`<div class="card" style="color:var(--fg-3)">
|
||||
${t("hooks.noProject")}
|
||||
</div>`
|
||||
: html`
|
||||
<div class="card">
|
||||
<textarea
|
||||
style="width:100%;height:240px;background:var(--bg-input);color:var(--fg-0);border:1px solid var(--bd);border-radius:var(--r);padding:10px;font-family:var(--font-mono);font-size:12.5px;line-height:1.55;resize:vertical"
|
||||
value=${drafts[scope] ?? ""}
|
||||
onInput=${(e: Event) =>
|
||||
setDrafts({ ...drafts, [scope]: (e.target as HTMLTextAreaElement).value })}
|
||||
disabled=${busy}
|
||||
></textarea>
|
||||
<div style="display:flex;gap:6px;margin-top:8px">
|
||||
<button class="btn primary" disabled=${busy} onClick=${() => saveScope(scope)}>
|
||||
${t("hooks.saveReload")}
|
||||
</button>
|
||||
<button class="btn ghost" disabled=${busy} onClick=${load}>${t("hooks.discard")}</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
`;
|
||||
})}
|
||||
|
||||
${sectionH3(t("hooks.recentRuns"), `${data.recentRuns?.length ?? 0}`)}
|
||||
${
|
||||
!data.recentRuns || data.recentRuns.length === 0
|
||||
? html`<div class="card" style="color:var(--fg-3)">
|
||||
${t("hooks.noRuns")}
|
||||
</div>`
|
||||
: html`
|
||||
<div class="card" style="padding:0;overflow-x:auto">
|
||||
<table class="tbl" style="width:100%;font-family:var(--font-mono);font-size:11.5px">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="text-align:left;padding:8px 12px">${t("hooks.colWhen")}</th>
|
||||
<th style="text-align:left;padding:8px 12px">${t("hooks.colPhase")}</th>
|
||||
<th style="text-align:left;padding:8px 12px">${t("hooks.colHook")}</th>
|
||||
<th style="text-align:left;padding:8px 12px">${t("hooks.colOutcome")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${data.recentRuns.map(
|
||||
(r) => html`
|
||||
<tr>
|
||||
<td style="padding:6px 12px;color:var(--fg-3)">${fmtRelativeTime(r.whenMs)}</td>
|
||||
<td style="padding:6px 12px;color:var(--fg-1)">${r.phase}</td>
|
||||
<td style="padding:6px 12px;color:var(--fg-1)">${r.hookName}</td>
|
||||
<td style="padding:6px 12px">
|
||||
<span class=${`pill ${r.outcome === "ok" ? "ok" : r.outcome === "error" ? "err" : "warn"}`}>${r.outcome}</span>
|
||||
</td>
|
||||
</tr>
|
||||
`,
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,725 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from "preact/hooks";
|
||||
import { t, useLang } from "../i18n/index.js";
|
||||
import { api } from "../lib/api.js";
|
||||
import { fmtNum } from "../lib/format.js";
|
||||
import { html } from "../lib/html.js";
|
||||
import {
|
||||
normalizeMcpSpec,
|
||||
mcpSpecCommand as specCommand,
|
||||
mcpSpecLabel as specLabel,
|
||||
} from "../lib/mcp-spec.js";
|
||||
|
||||
interface McpServer {
|
||||
label: string;
|
||||
spec: string;
|
||||
serverInfo?: { name?: string; version?: string };
|
||||
protocolVersion?: string;
|
||||
instructions?: string;
|
||||
toolCount: number;
|
||||
tools: { name: string; description?: string }[];
|
||||
resources: { name: string; uri: string }[];
|
||||
prompts: { name: string; description?: string }[];
|
||||
}
|
||||
|
||||
interface McpData {
|
||||
servers: McpServer[];
|
||||
}
|
||||
|
||||
interface RegistryInstall {
|
||||
runtime: string;
|
||||
packageId?: string;
|
||||
version?: string;
|
||||
transport: string;
|
||||
url?: string;
|
||||
requiredEnv?: string[];
|
||||
extraArgs?: string[];
|
||||
}
|
||||
|
||||
interface RegistryEntryDto {
|
||||
name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
source: "official" | "smithery" | "local";
|
||||
install?: RegistryInstall;
|
||||
popularity?: number;
|
||||
homepage?: string;
|
||||
iconUrl?: string;
|
||||
}
|
||||
|
||||
/** Mirror of src/mcp/registry-fetch.ts:specStringFor — kept in sync to detect already-installed state without an extra round-trip. */
|
||||
function specForEntry(e: RegistryEntryDto): string | null {
|
||||
if (!e.install) return null;
|
||||
const localName = e.name.split("/").pop() ?? e.name;
|
||||
const safe = localName.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/^-+|-+$/g, "") || "mcp";
|
||||
const trail = e.install.extraArgs?.length ? ` ${e.install.extraArgs.join(" ")}` : "";
|
||||
if (e.install.runtime === "remote" && e.install.url) {
|
||||
if (e.install.transport === "streamable-http") return `${safe}=streamable+${e.install.url}`;
|
||||
return `${safe}=${e.install.url}`;
|
||||
}
|
||||
if (e.install.runtime === "npm" && e.install.packageId) {
|
||||
const pinned = e.install.version
|
||||
? `${e.install.packageId}@${e.install.version}`
|
||||
: e.install.packageId;
|
||||
return `${safe}=npx -y ${pinned}${trail}`;
|
||||
}
|
||||
if (e.install.runtime === "pypi" && e.install.packageId) {
|
||||
return `${safe}=uvx ${e.install.packageId}${trail}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hideBrokenIcon(ev: Event): void {
|
||||
(ev.target as HTMLImageElement).style.display = "none";
|
||||
}
|
||||
|
||||
interface RegistryListResponse {
|
||||
source: "official" | "smithery" | "local";
|
||||
fromCache: boolean;
|
||||
fetchedAt: number;
|
||||
loaded: number;
|
||||
hasMore: boolean;
|
||||
matched: number;
|
||||
entries: RegistryEntryDto[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
type McpFilter = "all" | "live" | "unbridged" | "marketplace";
|
||||
|
||||
interface McpFailure {
|
||||
spec: string;
|
||||
name: string;
|
||||
reason: string;
|
||||
at: number;
|
||||
}
|
||||
|
||||
export function McpPanel() {
|
||||
useLang();
|
||||
const [data, setData] = useState<McpData | null>(null);
|
||||
const [specs, setSpecs] = useState<string[] | null>(null);
|
||||
const [failures, setFailures] = useState<McpFailure[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
const [newSpec, setNewSpec] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [open, setOpen] = useState<McpServer | null>(null);
|
||||
const [openUnbridged, setOpenUnbridged] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState<McpFilter>("all");
|
||||
const [registry, setRegistry] = useState<RegistryListResponse | null>(null);
|
||||
const [registryQuery, setRegistryQuery] = useState("");
|
||||
const [registryLoading, setRegistryLoading] = useState(false);
|
||||
const [openRegistry, setOpenRegistry] = useState<RegistryEntryDto | null>(null);
|
||||
/** Display cap — grows by 50 each "load more" click. Server caps response size at this. */
|
||||
const [displayLimit, setDisplayLimit] = useState(50);
|
||||
|
||||
const loadRegistry = useCallback(async (q: string, pages: number, limit: number) => {
|
||||
setRegistryLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (q.trim()) params.set("q", q.trim());
|
||||
params.set("pages", String(pages));
|
||||
params.set("maxPages", String(Math.max(20, pages)));
|
||||
params.set("limit", String(limit));
|
||||
const r = await api<RegistryListResponse>(`/mcp/registry?${params.toString()}`);
|
||||
setRegistry(r);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setRegistryLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (filter !== "marketplace") return;
|
||||
if (registry) return;
|
||||
void loadRegistry("", 1, displayLimit);
|
||||
}, [filter, registry, loadRegistry, displayLimit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (filter !== "marketplace") return;
|
||||
// Reset the display cap whenever the user retypes; new query = fresh top-50.
|
||||
setDisplayLimit(50);
|
||||
const handle = setTimeout(() => void loadRegistry(registryQuery, 1, 50), 300);
|
||||
return () => clearTimeout(handle);
|
||||
}, [registryQuery, filter, loadRegistry]);
|
||||
|
||||
const installFromRegistry = useCallback(async (entry: RegistryEntryDto) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await api<{
|
||||
added: boolean;
|
||||
alreadyPresent?: boolean;
|
||||
bridged?: boolean;
|
||||
spec: string;
|
||||
}>("/mcp/registry/install", { method: "POST", body: { name: entry.name } });
|
||||
if (r.alreadyPresent) {
|
||||
setInfo(t("mcp.marketplaceAlready"));
|
||||
} else if (r.bridged) {
|
||||
setInfo(t("mcp.marketplaceInstalledBridged", { spec: r.spec }));
|
||||
} else {
|
||||
setInfo(t("mcp.marketplaceInstalled", { spec: r.spec }));
|
||||
}
|
||||
setTimeout(() => setInfo(null), 5000);
|
||||
// Reload BOTH live + spec lists since hot-reload should have
|
||||
// attached the new bridge.
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const mcpData = await api<McpData>("/mcp");
|
||||
setData({
|
||||
...mcpData,
|
||||
servers: (Array.isArray(mcpData.servers) ? mcpData.servers : []).map((server) => ({
|
||||
...server,
|
||||
spec: normalizeMcpSpec(server.spec) ?? "",
|
||||
})),
|
||||
});
|
||||
const specResponse = await api<{ specs?: unknown[]; failures?: unknown[] }>("/mcp/specs");
|
||||
const normalized = (Array.isArray(specResponse.specs) ? specResponse.specs : [])
|
||||
.map(normalizeMcpSpec)
|
||||
.filter((spec): spec is string => spec !== null && spec.length > 0);
|
||||
setSpecs(normalized);
|
||||
const rawFailures = Array.isArray(specResponse.failures) ? specResponse.failures : [];
|
||||
const validFailures: McpFailure[] = [];
|
||||
for (const f of rawFailures) {
|
||||
if (typeof f !== "object" || f === null) continue;
|
||||
const o = f as Record<string, unknown>;
|
||||
const rawSpec = typeof o.spec === "string" ? o.spec : "";
|
||||
const norm = normalizeMcpSpec(rawSpec);
|
||||
if (!norm) continue;
|
||||
validFailures.push({
|
||||
spec: norm,
|
||||
name: typeof o.name === "string" ? o.name : "",
|
||||
reason: typeof o.reason === "string" ? o.reason : "",
|
||||
at: typeof o.at === "number" ? o.at : 0,
|
||||
});
|
||||
}
|
||||
setFailures(validFailures);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const addSpec = useCallback(async () => {
|
||||
if (!newSpec.trim()) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await api<{ requiresRestart?: boolean }>("/mcp/specs", {
|
||||
method: "POST",
|
||||
body: { spec: newSpec.trim() },
|
||||
});
|
||||
setInfo(r.requiresRestart ? t("mcp.savedRestart") : t("mcp.saved"));
|
||||
setTimeout(() => setInfo(null), 4000);
|
||||
setNewSpec("");
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [newSpec, load]);
|
||||
|
||||
const removeSpec = useCallback(
|
||||
async (spec: string) => {
|
||||
if (!confirm(t("mcp.removeConfirm", { spec }))) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api("/mcp/specs", { method: "DELETE", body: { spec } });
|
||||
setInfo(t("mcp.removed"));
|
||||
setTimeout(() => setInfo(null), 4000);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[load],
|
||||
);
|
||||
|
||||
if (!data && !error)
|
||||
return html`<div class="card" style="color:var(--fg-3)">${t("mcp.loading")}</div>`;
|
||||
if (error && !data) return html`<div class="card accent-err">${error}</div>`;
|
||||
if (!data) return null;
|
||||
|
||||
const liveCount = data.servers.length;
|
||||
const unbridgedSpecs = (specs ?? []).filter((spec) => !data.servers.some((s) => s.spec === spec));
|
||||
const unbridgedCount = unbridgedSpecs.length;
|
||||
const showLive = filter === "all" || filter === "live";
|
||||
const showUnbridged = filter === "all" || filter === "unbridged";
|
||||
const showMarketplace = filter === "marketplace";
|
||||
|
||||
return html`
|
||||
<div class="sessions-grid">
|
||||
<div class="sessions-list">
|
||||
<div class="ssl-h" style="font-family:var(--font-mono);font-size:11px;color:var(--fg-3);text-transform:uppercase;letter-spacing:.1em">
|
||||
${t("mcp.servers", { count: liveCount })}
|
||||
</div>
|
||||
<div style="padding:8px 12px 4px">
|
||||
<div class="chips">
|
||||
<span class=${`chip-f ${filter === "all" ? "active" : ""}`} onClick=${() => setFilter("all")}>${t("mcp.all")} <span class="ct">${liveCount + unbridgedCount}</span></span>
|
||||
<span class=${`chip-f ${filter === "live" ? "active" : ""}`} onClick=${() => setFilter("live")}>${t("mcp.live")} <span class="ct">${liveCount}</span></span>
|
||||
<span class=${`chip-f ${filter === "unbridged" ? "active" : ""}`} onClick=${() => setFilter("unbridged")}>${t("mcp.unbridged")} <span class="ct">${unbridgedCount}</span></span>
|
||||
<span class=${`chip-f ${filter === "marketplace" ? "active" : ""}`} onClick=${() => setFilter("marketplace")}>${t("mcp.marketplace")}</span>
|
||||
</div>
|
||||
</div>
|
||||
${
|
||||
showMarketplace
|
||||
? html`
|
||||
<div style="padding:8px 12px;display:flex;gap:6px">
|
||||
<input
|
||||
type="text"
|
||||
placeholder=${t("mcp.marketplaceSearch")}
|
||||
value=${registryQuery}
|
||||
onInput=${(e: Event) => setRegistryQuery((e.target as HTMLInputElement).value)}
|
||||
style="flex:1;font-size:11px"
|
||||
/>
|
||||
</div>
|
||||
${
|
||||
registry
|
||||
? html`<div style="padding:0 12px 6px;font-size:11px;color:var(--fg-3)">
|
||||
${t("mcp.marketplaceCount", {
|
||||
loaded: registry.loaded,
|
||||
matched: registry.matched,
|
||||
source: registry.source,
|
||||
cached: registry.fromCache ? t("mcp.marketplaceCachedSuffix") : "",
|
||||
})}
|
||||
</div>`
|
||||
: null
|
||||
}
|
||||
`
|
||||
: html`
|
||||
<div style="padding:8px 12px;display:flex;gap:6px">
|
||||
<input
|
||||
type="text"
|
||||
placeholder=${t("mcp.specPlaceholder")}
|
||||
value=${newSpec}
|
||||
onInput=${(e: Event) => setNewSpec((e.target as HTMLInputElement).value)}
|
||||
style="flex:1;font-size:11px"
|
||||
/>
|
||||
<button class="btn primary" disabled=${busy || !newSpec.trim()} onClick=${addSpec}>+</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
${info ? html`<div style="padding:0 12px 8px"><span class="pill ok">${info}</span></div>` : null}
|
||||
${error ? html`<div class="card accent-err" style="margin:0 12px 8px">${error}</div>` : null}
|
||||
|
||||
<div class="ssl-rows">
|
||||
${
|
||||
!showMarketplace && liveCount === 0 && unbridgedCount === 0
|
||||
? html`<div style="color:var(--fg-3);padding:14px;font-size:12px">
|
||||
${t("mcp.noServers")}
|
||||
</div>`
|
||||
: null
|
||||
}
|
||||
${
|
||||
showMarketplace
|
||||
? renderMarketplaceRows({
|
||||
registry,
|
||||
registryLoading,
|
||||
openRegistry,
|
||||
setOpenRegistry: (e) => {
|
||||
setOpenRegistry(e);
|
||||
setOpen(null);
|
||||
setOpenUnbridged(null);
|
||||
},
|
||||
loadMore: () => {
|
||||
const nextLimit = displayLimit + 50;
|
||||
setDisplayLimit(nextLimit);
|
||||
// Pages: walk far enough to fill the new cap (each page ≈ 30
|
||||
// entries) plus a few-page lookahead so the next click also
|
||||
// has fresh data.
|
||||
const pagesNeeded = Math.ceil(nextLimit / 30) + 3;
|
||||
void loadRegistry(registryQuery, pagesNeeded, nextLimit);
|
||||
},
|
||||
installedSpecs: new Set(specs ?? []),
|
||||
})
|
||||
: null
|
||||
}
|
||||
${
|
||||
showLive
|
||||
? data.servers.map(
|
||||
(s) => html`
|
||||
<div
|
||||
class=${`ssl-row ${open?.label === s.label ? "sel" : ""}`}
|
||||
onClick=${() => {
|
||||
setOpen(s);
|
||||
setOpenUnbridged(null);
|
||||
}}
|
||||
>
|
||||
<span class="name">${s.label} <span class="pill ok">${t("mcp.live")}</span></span>
|
||||
<span class="preview">${specCommand(s.spec)}</span>
|
||||
<span class="meta"><span><span class="v">${fmtNum(s.toolCount)}</span> ${t("mcp.tools")}</span></span>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
: null
|
||||
}
|
||||
${
|
||||
showUnbridged
|
||||
? unbridgedSpecs.map((spec) => {
|
||||
const failure = failures.find((f) => f.spec === spec);
|
||||
return html`
|
||||
<div
|
||||
class=${`ssl-row ${openUnbridged === spec ? "sel" : ""}`}
|
||||
onClick=${() => {
|
||||
setOpenUnbridged(spec);
|
||||
setOpen(null);
|
||||
}}
|
||||
>
|
||||
<span class="name">${specLabel(spec)} <span class=${`pill ${failure ? "err" : ""}`}>${failure ? t("mcp.bridgeFailed") : t("mcp.unbridged")}</span></span>
|
||||
<span class="preview">${specCommand(spec)}</span>
|
||||
<span class="meta"><span class=${failure ? "" : "dim"} style=${failure ? "color:var(--c-err)" : ""}>${failure ? failure.reason : t("mcp.inConfig")}</span></span>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sessions-detail">
|
||||
${
|
||||
openRegistry != null
|
||||
? renderRegistryDetail({
|
||||
entry: openRegistry,
|
||||
busy,
|
||||
installedSpec: (() => {
|
||||
const spec = specForEntry(openRegistry);
|
||||
return spec && (specs ?? []).includes(spec) ? spec : null;
|
||||
})(),
|
||||
onInstall: () => installFromRegistry(openRegistry),
|
||||
onUninstall: (spec: string) => removeSpec(spec),
|
||||
onClose: () => setOpenRegistry(null),
|
||||
})
|
||||
: openUnbridged != null
|
||||
? (() => {
|
||||
const failure = failures.find((f) => f.spec === openUnbridged);
|
||||
return html`
|
||||
<div class="sessions-detail-h">
|
||||
<span class="name">${specLabel(openUnbridged)}</span>
|
||||
<span class="ws"><span class="pill">${failure ? t("mcp.bridgeFailedTitle") : t("mcp.unbridgedTitle")}</span></span>
|
||||
<span class="actions">
|
||||
<button class="btn" disabled=${busy} onClick=${() => removeSpec(openUnbridged)}
|
||||
style="border-color:var(--c-err);color:var(--c-err)">${t("mcp.removeBtn")}</button>
|
||||
<button class="btn ghost" onClick=${() => setOpenUnbridged(null)}>${t("common.back")}</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="card" style="margin-bottom:12px">
|
||||
<div class="card-h"><span class="title">${t("mcp.spec")}</span></div>
|
||||
<code class="mono" style="font-size:11.5px;color:var(--fg-2);word-break:break-all">${openUnbridged}</code>
|
||||
</div>
|
||||
${
|
||||
failure
|
||||
? html`<div class="card accent-err">
|
||||
<div class="card-h"><span class="title" style="color:var(--c-err)">${t("mcp.bridgeFailed")}</span></div>
|
||||
<div class="card-b" style="font-size:13px;line-height:1.6">
|
||||
<code class="mono" style="font-size:12px;color:var(--fg-1);word-break:break-word;white-space:pre-wrap">${failure.reason}</code>
|
||||
<div style="margin-top:10px;color:var(--fg-3);font-size:12px">
|
||||
${t("mcp.bridgeFailedHint")}
|
||||
</div>
|
||||
</div>
|
||||
</div>`
|
||||
: html`<div class="card accent-warn">
|
||||
<div class="card-h"><span class="title" style="color:var(--c-warn)">${t("mcp.whyUnbridged")}</span></div>
|
||||
<div class="card-b" style="font-size:13px;line-height:1.6">
|
||||
${t("mcp.whyUnbridgedDesc")}
|
||||
<div style="margin-top:10px;color:var(--fg-3);font-size:12px">
|
||||
${t("mcp.whyUnbridgedHint")}
|
||||
</div>
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
`;
|
||||
})()
|
||||
: open == null
|
||||
? html`<div style="color:var(--fg-3);font-size:13px;text-align:center;padding:60px 20px">
|
||||
${showMarketplace ? t("mcp.marketplacePickHint") : t("mcp.pickHint")}
|
||||
</div>`
|
||||
: html`
|
||||
<div class="sessions-detail-h">
|
||||
<span class="name">${open.label}</span>
|
||||
<span class="ws">${open.serverInfo?.name ?? "—"} ${open.serverInfo?.version ? `v${open.serverInfo.version}` : ""} · ${open.protocolVersion ?? "—"}</span>
|
||||
<span class="actions">
|
||||
<button class="btn ghost" onClick=${() => setOpen(null)}>${t("common.back")}</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom:12px">
|
||||
<div class="card-h"><span class="title">${t("mcp.spec")}</span></div>
|
||||
<code class="mono" style="font-size:11.5px;color:var(--fg-2)">${open.spec}</code>
|
||||
</div>
|
||||
|
||||
${
|
||||
open.instructions
|
||||
? html`<div class="card accent-brand" style="margin-bottom:12px">
|
||||
<div class="card-b">${open.instructions}</div>
|
||||
</div>`
|
||||
: null
|
||||
}
|
||||
|
||||
<h3 style="margin:18px 0 6px;font-family:var(--font-mono);font-size:11px;color:var(--fg-3);text-transform:uppercase;letter-spacing:.1em">
|
||||
${t("mcp.toolsTitle", { count: open.tools.length })}
|
||||
</h3>
|
||||
<div class="card" style="padding:0;overflow:hidden">
|
||||
<table class="tbl">
|
||||
<thead><tr><th>${t("mcp.colName")}</th><th>${t("mcp.colDesc")}</th></tr></thead>
|
||||
<tbody>
|
||||
${open.tools.map(
|
||||
(tool) =>
|
||||
html`<tr><td><code class="mono">${tool.name}</code></td><td class="dim">${tool.description ?? ""}</td></tr>`,
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
${
|
||||
open.resources.length > 0
|
||||
? html`
|
||||
<h3 style="margin:18px 0 6px;font-family:var(--font-mono);font-size:11px;color:var(--fg-3);text-transform:uppercase;letter-spacing:.1em">
|
||||
${t("mcp.resourcesTitle", { count: open.resources.length })}
|
||||
</h3>
|
||||
<div class="card" style="padding:0;overflow:hidden">
|
||||
<table class="tbl">
|
||||
<thead><tr><th>${t("mcp.colName")}</th><th>${t("mcp.colUri")}</th></tr></thead>
|
||||
<tbody>
|
||||
${open.resources.map(
|
||||
(r) =>
|
||||
html`<tr><td>${r.name}</td><td class="path">${r.uri}</td></tr>`,
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`
|
||||
: null
|
||||
}
|
||||
|
||||
${
|
||||
open.prompts.length > 0
|
||||
? html`
|
||||
<h3 style="margin:18px 0 6px;font-family:var(--font-mono);font-size:11px;color:var(--fg-3);text-transform:uppercase;letter-spacing:.1em">
|
||||
${t("mcp.promptsTitle", { count: open.prompts.length })}
|
||||
</h3>
|
||||
<div class="card" style="padding:0;overflow:hidden">
|
||||
<table class="tbl">
|
||||
<thead><tr><th>${t("mcp.colName")}</th><th>${t("mcp.colDesc")}</th></tr></thead>
|
||||
<tbody>
|
||||
${open.prompts.map(
|
||||
(p) =>
|
||||
html`<tr><td><code class="mono">${p.name}</code></td><td class="dim">${p.description ?? ""}</td></tr>`,
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`
|
||||
: null
|
||||
}
|
||||
`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
interface MarketplaceRowsArgs {
|
||||
registry: RegistryListResponse | null;
|
||||
registryLoading: boolean;
|
||||
openRegistry: RegistryEntryDto | null;
|
||||
setOpenRegistry: (entry: RegistryEntryDto) => void;
|
||||
loadMore: () => void;
|
||||
installedSpecs: Set<string>;
|
||||
}
|
||||
|
||||
function renderLoadMoreFooter({
|
||||
registry,
|
||||
registryLoading,
|
||||
loadMore,
|
||||
}: Pick<MarketplaceRowsArgs, "registry" | "registryLoading" | "loadMore">) {
|
||||
if (!registry) return null;
|
||||
const shown = registry.entries.length;
|
||||
const total = registry.matched;
|
||||
const moreCached = total > shown;
|
||||
const moreOnNetwork = registry.hasMore;
|
||||
const canDoSomething = moreCached || moreOnNetwork;
|
||||
|
||||
// Three states:
|
||||
// 1. Loading — disabled button + spinner-ish label
|
||||
// 2. More available — primary button + count of what's loaded
|
||||
// 3. Exhausted — distinct success-tinted card so the user
|
||||
// doesn't think the button stopped responding
|
||||
if (canDoSomething) {
|
||||
const label = registryLoading
|
||||
? t("mcp.marketplaceLoading")
|
||||
: t("mcp.marketplaceMoreLabel", {
|
||||
shown,
|
||||
total: moreOnNetwork ? `${total}+` : `${total}`,
|
||||
});
|
||||
return html`<div style="padding:10px 12px;display:flex;align-items:center;gap:10px">
|
||||
<button class="btn primary" disabled=${registryLoading} onClick=${loadMore}>${label}</button>
|
||||
<span style="font-size:11px;color:var(--fg-3)">
|
||||
${moreOnNetwork ? t("mcp.marketplaceMoreHint") : t("mcp.marketplaceMoreCachedHint")}
|
||||
</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return html`<div style="padding:12px;background:var(--bg-elev-2,rgba(36,143,242,0.07));border-top:1px solid var(--bd);display:flex;align-items:center;gap:8px;font-size:12px;color:var(--fg-2)">
|
||||
<span style="color:var(--c-ok)">✓</span>
|
||||
<span>${t("mcp.marketplaceExhaustedFull", { total })}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderMarketplaceRows({
|
||||
registry,
|
||||
registryLoading,
|
||||
openRegistry,
|
||||
setOpenRegistry,
|
||||
loadMore,
|
||||
installedSpecs,
|
||||
}: MarketplaceRowsArgs) {
|
||||
if (!registry && registryLoading) {
|
||||
return html`<div style="color:var(--fg-3);padding:14px;font-size:12px">${t("mcp.marketplaceLoading")}</div>`;
|
||||
}
|
||||
if (!registry || registry.entries.length === 0) {
|
||||
return html`<div style="color:var(--fg-3);padding:14px;font-size:12px">${t("mcp.marketplaceNoMatches")}</div>`;
|
||||
}
|
||||
return html`
|
||||
${registry.entries.map((e) => {
|
||||
const sel = openRegistry?.name === e.name;
|
||||
const tag = t("mcp.marketplaceSourceTag", { source: e.source });
|
||||
const spec = specForEntry(e);
|
||||
const installed = spec !== null && installedSpecs.has(spec);
|
||||
const pop =
|
||||
e.popularity !== undefined
|
||||
? html` <span class="dim">· ${fmtNum(e.popularity)}</span>`
|
||||
: null;
|
||||
const icon = e.iconUrl
|
||||
? html`<img src=${e.iconUrl} alt="" style="width:16px;height:16px;border-radius:3px;margin-right:6px;vertical-align:middle;object-fit:cover" loading="lazy" referrerpolicy="no-referrer" onError=${hideBrokenIcon} />`
|
||||
: null;
|
||||
return html`
|
||||
<div class=${`ssl-row ${sel ? "sel" : ""}`} onClick=${() => setOpenRegistry(e)}>
|
||||
<span class="name">${icon}${e.name} <span class="pill">${tag}</span>${installed ? html` <span class="pill ok">${t("mcp.marketplaceInstalledBadge")}</span>` : null}</span>
|
||||
<span class="preview">${e.description}</span>
|
||||
<span class="meta">${pop}</span>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
${renderLoadMoreFooter({ registry, registryLoading, loadMore })}
|
||||
`;
|
||||
}
|
||||
|
||||
interface RegistryDetailArgs {
|
||||
entry: RegistryEntryDto;
|
||||
busy: boolean;
|
||||
installedSpec: string | null;
|
||||
onInstall: () => void;
|
||||
onUninstall: (spec: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function renderRegistryDetail({
|
||||
entry,
|
||||
busy,
|
||||
installedSpec,
|
||||
onInstall,
|
||||
onUninstall,
|
||||
onClose,
|
||||
}: RegistryDetailArgs) {
|
||||
const installable = !!entry.install || entry.source === "smithery";
|
||||
const installed = installedSpec !== null;
|
||||
const specPreview = entry.install
|
||||
? `${entry.install.runtime} · ${entry.install.transport}${
|
||||
entry.install.packageId
|
||||
? ` · ${entry.install.packageId}`
|
||||
: entry.install.url
|
||||
? ` · ${entry.install.url}`
|
||||
: ""
|
||||
}${entry.install.version ? `@${entry.install.version}` : ""}`
|
||||
: "";
|
||||
const icon = entry.iconUrl
|
||||
? html`<img src=${entry.iconUrl} alt="" style="width:24px;height:24px;border-radius:4px;margin-right:8px;vertical-align:middle;object-fit:cover" loading="lazy" referrerpolicy="no-referrer" onError=${hideBrokenIcon} />`
|
||||
: null;
|
||||
return html`
|
||||
<div class="sessions-detail-h">
|
||||
<span class="name">${icon}${entry.name}${installed ? html` <span class="pill ok">${t("mcp.marketplaceInstalledBadge")}</span>` : null}</span>
|
||||
<span class="ws">${t("mcp.marketplaceSourceTag", { source: entry.source })}${
|
||||
entry.popularity !== undefined ? ` · ${fmtNum(entry.popularity)} uses` : ""
|
||||
}${entry.homepage ? html` · <a href=${entry.homepage} target="_blank" rel="noopener noreferrer">homepage</a>` : ""}</span>
|
||||
<span class="actions">
|
||||
${
|
||||
installed
|
||||
? html`<button
|
||||
class="btn"
|
||||
disabled=${busy}
|
||||
onClick=${() => onUninstall(installedSpec)}
|
||||
style="border-color:var(--c-err);color:var(--c-err)"
|
||||
>${t("mcp.marketplaceUninstall")}</button>`
|
||||
: html`<button class="btn primary" disabled=${busy || !installable} onClick=${onInstall}>${t("mcp.marketplaceInstall")}</button>`
|
||||
}
|
||||
<button class="btn ghost" onClick=${onClose}>${t("common.back")}</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom:12px">
|
||||
<div class="card-b" style="font-size:13px;line-height:1.6">${entry.description || "—"}</div>
|
||||
</div>
|
||||
|
||||
${
|
||||
entry.install
|
||||
? html`<div class="card" style="margin-bottom:12px">
|
||||
<div class="card-h"><span class="title">${t("mcp.spec")}</span></div>
|
||||
<div class="card-b">
|
||||
<code class="mono" style="font-size:11.5px;color:var(--fg-2);word-break:break-all;display:block">${specPreview}</code>
|
||||
${
|
||||
installedSpec
|
||||
? html`<div style="margin-top:8px;font-size:11px;color:var(--fg-3)">
|
||||
<span class="dim">on disk:</span> <code class="mono">${installedSpec}</code>
|
||||
</div>`
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
</div>`
|
||||
: entry.source === "smithery"
|
||||
? html`<div class="card" style="margin-bottom:12px">
|
||||
<div class="card-b" style="font-size:13px;line-height:1.6;color:var(--fg-3)">
|
||||
${t("mcp.marketplaceFetchOnInstall")}
|
||||
</div>
|
||||
</div>`
|
||||
: null
|
||||
}
|
||||
|
||||
${
|
||||
entry.install?.requiredEnv?.length
|
||||
? html`<div class="card accent-brand" style="margin-bottom:12px">
|
||||
<div class="card-h"><span class="title">${t("mcp.marketplaceEnvTitle")}</span></div>
|
||||
<div class="card-b" style="font-size:13px">
|
||||
${entry.install.requiredEnv.map(
|
||||
(name) =>
|
||||
html`<div><code class="mono" style="color:var(--c-warn)">${name}</code></div>`,
|
||||
)}
|
||||
<div style="margin-top:6px;color:var(--fg-3);font-size:12px">
|
||||
${t("mcp.marketplaceEnvHint")}
|
||||
</div>
|
||||
</div>
|
||||
</div>`
|
||||
: null
|
||||
}
|
||||
|
||||
${
|
||||
installed
|
||||
? html`<div class="card accent-warn">
|
||||
<div class="card-b" style="font-size:12.5px;line-height:1.6">
|
||||
${t("mcp.marketplaceRestartHint")}
|
||||
</div>
|
||||
</div>`
|
||||
: null
|
||||
}
|
||||
`;
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from "preact/hooks";
|
||||
import { api } from "../lib/api.js";
|
||||
import { fmtBytes, fmtRelativeTime } from "../lib/format.js";
|
||||
import { html } from "../lib/html.js";
|
||||
import { t, useLang } from "../i18n/index.js";
|
||||
|
||||
interface MemoryFile {
|
||||
name: string;
|
||||
size: number;
|
||||
mtime: string | number;
|
||||
}
|
||||
|
||||
interface MemoryTree {
|
||||
project: { path?: string | null; exists?: boolean };
|
||||
global: { files: MemoryFile[] };
|
||||
projectMem: { path?: string | null; files: MemoryFile[] };
|
||||
}
|
||||
|
||||
type Scope = "project" | "global" | "project-mem";
|
||||
|
||||
export function MemoryPanel() {
|
||||
useLang();
|
||||
const [tree, setTree] = useState<MemoryTree | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState<{ scope: Scope; name?: string } | null>(null);
|
||||
const [body, setBody] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setTree(await api<MemoryTree>("/memory"));
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const openFile = useCallback(async (scope: Scope, name?: string) => {
|
||||
setOpen({ scope, name });
|
||||
setBusy(true);
|
||||
try {
|
||||
const path =
|
||||
scope === "project"
|
||||
? "/memory/project"
|
||||
: `/memory/${scope}/${encodeURIComponent(name ?? "")}`;
|
||||
const r = await api<{ body: string }>(path);
|
||||
setBody(r.body);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
if (!open) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const path =
|
||||
open.scope === "project"
|
||||
? "/memory/project"
|
||||
: `/memory/${open.scope}/${encodeURIComponent(open.name ?? "")}`;
|
||||
await api(path, { method: "POST", body: { body } });
|
||||
setInfo(t("memory.saved", { scope: open.scope + (open.name ? `/${open.name}` : "") }));
|
||||
setTimeout(() => setInfo(null), 3000);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [open, body, load]);
|
||||
|
||||
if (!tree && !error)
|
||||
return html`<div class="card" style="color:var(--fg-3)">${t("memory.loading")}</div>`;
|
||||
if (error && !tree) return html`<div class="card accent-err">${error}</div>`;
|
||||
if (!tree) return null;
|
||||
|
||||
const fileRow = (scope: Scope, f: MemoryFile) => {
|
||||
const sel = open && open.scope === scope && open.name === f.name;
|
||||
return html`
|
||||
<div
|
||||
class=${`ssl-row ${sel ? "sel" : ""}`}
|
||||
onClick=${() => openFile(scope, f.name)}
|
||||
>
|
||||
<span class="name">${f.name}</span>
|
||||
<span class="meta">
|
||||
<span class="dim">${scope}</span>
|
||||
<span><span class="v">${fmtBytes(f.size)}</span></span>
|
||||
<span>${fmtRelativeTime(f.mtime)}</span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
const totalFiles =
|
||||
(tree.project.path ? 1 : 0) +
|
||||
tree.global.files.length +
|
||||
tree.projectMem.files.length;
|
||||
|
||||
return html`
|
||||
<div class="sessions-grid">
|
||||
<div class="sessions-list">
|
||||
<div class="ssl-h" style="font-family:var(--font-mono);font-size:11px;color:var(--fg-3);text-transform:uppercase;letter-spacing:.1em">
|
||||
${t("memory.files", { count: totalFiles })}
|
||||
</div>
|
||||
<div class="ssl-rows">
|
||||
${
|
||||
tree.project.path
|
||||
? html`
|
||||
<div
|
||||
class=${`ssl-row ${open?.scope === "project" ? "sel" : ""}`}
|
||||
onClick=${() => openFile("project")}
|
||||
>
|
||||
<span class="name">
|
||||
REASONIX.md
|
||||
${
|
||||
tree.project.exists
|
||||
? html`<span class="pill ok">${t("memory.exists")}</span>`
|
||||
: html`<span class="pill">${t("memory.create")}</span>`
|
||||
}
|
||||
</span>
|
||||
<span class="preview">${tree.project.path}</span>
|
||||
<span class="meta"><span class="dim">project</span></span>
|
||||
</div>
|
||||
`
|
||||
: null
|
||||
}
|
||||
${tree.global.files.map((f) => fileRow("global", f))}
|
||||
${tree.projectMem.files.map((f) => fileRow("project-mem", f))}
|
||||
${
|
||||
(tree.global.files.length === 0 &&
|
||||
tree.projectMem.files.length === 0 &&
|
||||
!tree.project.path)
|
||||
? html`<div style="color:var(--fg-3);padding:14px;font-size:12px">
|
||||
${t("memory.noFiles")}
|
||||
</div>`
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sessions-detail">
|
||||
${
|
||||
open == null
|
||||
? html`<div style="color:var(--fg-3);font-size:13px;text-align:center;padding:60px 20px">
|
||||
${t("memory.pickHint")}
|
||||
<div style="margin-top:12px;font-size:11.5px">
|
||||
${t("memory.pickDesc")}
|
||||
</div>
|
||||
</div>`
|
||||
: html`
|
||||
<div class="sessions-detail-h">
|
||||
<span class="name">
|
||||
${open.scope}${open.name ? `/${open.name}` : ""}
|
||||
</span>
|
||||
<span class="ws">${t("memory.chars", { count: body.length.toLocaleString() })}</span>
|
||||
<span class="actions">
|
||||
<button class="btn primary" disabled=${busy} onClick=${save}>${t("common.save")}</button>
|
||||
<button class="btn ghost" onClick=${() => setOpen(null)}>${t("common.back")}</button>
|
||||
</span>
|
||||
</div>
|
||||
${info ? html`<div style="margin-bottom:8px"><span class="pill ok">${info}</span></div>` : null}
|
||||
${error ? html`<div class="card accent-err" style="margin-bottom:8px">${error}</div>` : null}
|
||||
<textarea
|
||||
style="width:100%;min-height:480px;background:var(--bg-input);color:var(--fg-0);border:1px solid var(--bd);border-radius:var(--r);padding:12px;font-family:var(--font-mono);font-size:13px;line-height:1.55;resize:vertical"
|
||||
value=${body}
|
||||
onInput=${(e: Event) => setBody((e.target as HTMLTextAreaElement).value)}
|
||||
disabled=${busy}
|
||||
></textarea>
|
||||
<div style="margin-top:8px;color:var(--fg-3);font-size:11.5px">
|
||||
${t("memory.reloadHint")}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,336 +0,0 @@
|
||||
import { budgetTone, deriveBudgetState } from "../lib/budget.js";
|
||||
import { fmtCompactNum, fmtCost, fmtNum, fmtRelativeTime, fmtUsd } from "../lib/format.js";
|
||||
import { html } from "../lib/html.js";
|
||||
import { usePoll } from "../lib/use-poll.js";
|
||||
import { compareVersions } from "../lib/version.js";
|
||||
import { t, useLang } from "../i18n/index.js";
|
||||
|
||||
interface CockpitKpi {
|
||||
total: number;
|
||||
deltaPct: number | null;
|
||||
}
|
||||
interface CockpitCacheKpi {
|
||||
ratio: number;
|
||||
deltaPp: number | null;
|
||||
}
|
||||
interface CockpitDailyCost {
|
||||
date: string;
|
||||
usd: number;
|
||||
}
|
||||
interface CockpitCurrentSession {
|
||||
id: string;
|
||||
turns: number;
|
||||
totalCostUsd: number;
|
||||
lastPromptTokens: number;
|
||||
completionTokens: number;
|
||||
}
|
||||
interface CockpitToolCallsKpi {
|
||||
total: number;
|
||||
delta: number | null;
|
||||
}
|
||||
interface CockpitRecentPlan {
|
||||
id: string;
|
||||
title: string;
|
||||
totalSteps: number;
|
||||
completedSteps: number;
|
||||
status: "active" | "done";
|
||||
whenMs: number;
|
||||
}
|
||||
interface CockpitToolFeedRow {
|
||||
name: string;
|
||||
args: string;
|
||||
level: "ok" | "warn" | "err";
|
||||
whenMs: number;
|
||||
}
|
||||
|
||||
interface CockpitData {
|
||||
balance: { currency: string; total: string } | null;
|
||||
tokens7d: CockpitKpi | null;
|
||||
cacheHit7d: CockpitCacheKpi | null;
|
||||
costTrend14d: ReadonlyArray<CockpitDailyCost> | null;
|
||||
currentSession: CockpitCurrentSession | null;
|
||||
toolCalls24h: CockpitToolCallsKpi | null;
|
||||
recentPlans: ReadonlyArray<CockpitRecentPlan> | null;
|
||||
toolActivity: ReadonlyArray<CockpitToolFeedRow> | null;
|
||||
}
|
||||
|
||||
interface OverviewData {
|
||||
mode: "standalone" | "attached";
|
||||
version?: string;
|
||||
latestVersion?: string;
|
||||
session?: string | null;
|
||||
model?: string;
|
||||
editMode?: string;
|
||||
planMode?: boolean | null;
|
||||
pendingEdits?: number;
|
||||
mcpServerCount?: number;
|
||||
toolCount?: number;
|
||||
cwd?: string;
|
||||
cockpit?: CockpitData;
|
||||
budgetUsd?: number | null;
|
||||
/** Cumulative session spend in USD — set when a session is attached. */
|
||||
sessionSpendUsd?: number | null;
|
||||
}
|
||||
|
||||
function kpi(label: string, value: unknown, delta?: unknown, deltaTone?: "up" | "down" | "flat") {
|
||||
const muted = value === "—" || value === null || value === undefined;
|
||||
return html`
|
||||
<div class="kpi cock-w-1">
|
||||
<div class="label">${label}</div>
|
||||
<div class="value" style=${muted ? "color:var(--fg-4)" : ""}>${value ?? "—"}</div>
|
||||
${delta != null ? html`<div class=${`delta ${deltaTone ?? ""}`}>${delta}</div>` : null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function deltaPctText(deltaPct: number | null): { text: string; tone: "up" | "down" | "flat" } {
|
||||
if (deltaPct === null) return { text: t("overview.noPriorData"), tone: "flat" };
|
||||
if (Math.abs(deltaPct) < 1) return { text: t("overview.stable"), tone: "flat" };
|
||||
const arrow = deltaPct > 0 ? "▲" : "▼";
|
||||
return {
|
||||
text: t("overview.vsPrior", { arrow, pct: Math.abs(deltaPct).toFixed(0) }),
|
||||
tone: deltaPct > 0 ? "up" : "down",
|
||||
};
|
||||
}
|
||||
|
||||
function deltaPpText(deltaPp: number | null): { text: string; tone: "up" | "down" | "flat" } {
|
||||
if (deltaPp === null) return { text: t("overview.noPriorData"), tone: "flat" };
|
||||
if (Math.abs(deltaPp) < 0.5) return { text: t("overview.stable"), tone: "flat" };
|
||||
const arrow = deltaPp > 0 ? "▲" : "▼";
|
||||
return { text: `${arrow} ${Math.abs(deltaPp).toFixed(1)}pp`, tone: deltaPp > 0 ? "up" : "down" };
|
||||
}
|
||||
|
||||
function deltaCountText(delta: number | null): { text: string; tone: "up" | "down" | "flat" } {
|
||||
if (delta === null || delta === 0) return { text: t("overview.stable"), tone: "flat" };
|
||||
const arrow = delta > 0 ? "▲" : "▼";
|
||||
return { text: `${arrow} ${Math.abs(delta)}`, tone: delta > 0 ? "up" : "down" };
|
||||
}
|
||||
|
||||
function balanceKpi(c: CockpitData) {
|
||||
if (!c.balance) return kpi(t("overview.balance"), "—", "open in TUI", "flat");
|
||||
const symbol = c.balance.currency === "CNY" ? "¥" : c.balance.currency === "USD" ? "$" : "";
|
||||
return kpi(t("overview.balance"), `${symbol}${c.balance.total}`, c.balance.currency, "flat");
|
||||
}
|
||||
|
||||
function budgetKpi(o: OverviewData) {
|
||||
const state = deriveBudgetState(o.budgetUsd, o.cockpit?.currentSession?.totalCostUsd ?? null);
|
||||
if (state.kind === "off") return null;
|
||||
const tone = budgetTone(state);
|
||||
const valueColor =
|
||||
tone === "err"
|
||||
? "color:var(--c-err)"
|
||||
: tone === "warn"
|
||||
? "color:var(--c-warn)"
|
||||
: "";
|
||||
return html`
|
||||
<div class="kpi cock-w-1">
|
||||
<div class="label">${t("overview.budget")}</div>
|
||||
<div class="value" style=${valueColor}>${fmtUsd(state.spent)} / ${fmtUsd(state.cap)}</div>
|
||||
<div class=${`progress ${tone}`} style="margin-top:4px"><div class="progress-fill" style=${`width:${Math.min(100, state.pct)}%`}></div></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function tokens7dKpi(c: CockpitData) {
|
||||
if (!c.tokens7d) return kpi(t("overview.tokens7d"), "—", t("overview.noUsageYet"), "flat");
|
||||
const d = deltaPctText(c.tokens7d.deltaPct);
|
||||
return kpi(t("overview.tokens7d"), fmtCompactNum(c.tokens7d.total), d.text, d.tone);
|
||||
}
|
||||
|
||||
function cacheHitKpi(c: CockpitData) {
|
||||
if (!c.cacheHit7d) return kpi(t("overview.cacheHit"), "—", t("overview.noUsageYet"), "flat");
|
||||
const pct = (c.cacheHit7d.ratio * 100).toFixed(0);
|
||||
const d = deltaPpText(c.cacheHit7d.deltaPp);
|
||||
return html`
|
||||
<div class="kpi cock-w-1">
|
||||
<div class="label">${t("overview.cacheHit")}</div>
|
||||
<div class="value">${pct}<span class="unit">%</span></div>
|
||||
<div class=${`delta ${d.tone}`}>${d.text}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function toolCallsKpi(c: CockpitData) {
|
||||
if (!c.toolCalls24h) return kpi(t("overview.toolCalls24h"), "—", t("overview.noToolCalls"), "flat");
|
||||
const d = deltaCountText(c.toolCalls24h.delta);
|
||||
return kpi(t("overview.toolCalls24h"), fmtNum(c.toolCalls24h.total), d.text, d.tone);
|
||||
}
|
||||
|
||||
function currentSessionBlock(c: CockpitData) {
|
||||
if (!c.currentSession) {
|
||||
return html`
|
||||
<div class="cock-list cock-w-2">
|
||||
<div class="ch"><span class="ttl">${t("overview.currentSession")}</span></div>
|
||||
<div style="color:var(--fg-3);font-size:12.5px;padding:8px 0">
|
||||
${t("overview.noSession")}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
const s = c.currentSession;
|
||||
const currency = c.balance?.currency;
|
||||
return html`
|
||||
<div class="cock-list cock-w-2">
|
||||
<div class="ch"><span class="ttl">${t("overview.currentSession")}</span></div>
|
||||
<div class="card accent-brand" style="margin:0 0 8px;background:transparent;border:none;padding:0">
|
||||
<div class="card-h"><span class="glyph">◆</span><span class="title">${s.id}</span><span class="meta">${s.turns} turn${s.turns === 1 ? "" : "s"}</span></div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(3, 1fr);gap:8px;font-family:var(--font-mono);font-size:11px">
|
||||
<div><span style="color:var(--fg-3)">${t("overview.promptTok")}</span><div style="color:var(--fg-0);font-size:13px;font-weight:600">${fmtNum(s.lastPromptTokens)}</div></div>
|
||||
<div><span style="color:var(--fg-3)">${t("overview.completionTok")}</span><div style="color:var(--fg-0);font-size:13px;font-weight:600">${fmtNum(s.completionTokens)}</div></div>
|
||||
<div><span style="color:var(--fg-3)">${t("overview.cost")}</span><div style="color:var(--fg-0);font-size:13px;font-weight:600">${fmtCost(s.totalCostUsd, currency)}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function costTrendSpark(c: CockpitData) {
|
||||
if (!c.costTrend14d || c.costTrend14d.length === 0) {
|
||||
return html`
|
||||
<div class="chart cock-w-2">
|
||||
<div class="chart-h"><span class="title">${t("overview.costTrend")}</span></div>
|
||||
<div class="chart-v" style="color:var(--fg-4)">—<span class="unit">${t("overview.noUsageYet")}</span></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
const days = c.costTrend14d;
|
||||
const total = days.reduce((s, d) => s + d.usd, 0);
|
||||
const max = Math.max(...days.map((d) => d.usd), 0.0001);
|
||||
const w = 400;
|
||||
const h = 60;
|
||||
const points = days
|
||||
.map((d, i) => {
|
||||
const x = days.length === 1 ? 0 : (i * w) / (days.length - 1);
|
||||
const y = h - (d.usd / max) * (h - 6) - 3;
|
||||
return `${x.toFixed(0)},${y.toFixed(0)}`;
|
||||
})
|
||||
.join(" ");
|
||||
const area = `${points} ${w},${h} 0,${h}`;
|
||||
const avg = total / days.length;
|
||||
return html`
|
||||
<div class="chart cock-w-2">
|
||||
<div class="chart-h"><span class="title">${t("overview.costTrend")}</span></div>
|
||||
<div class="chart-v">${fmtCost(avg, c.balance?.currency)}<span class="unit">${t("overview.dayAvg")}</span></div>
|
||||
<div class="chart-spark">
|
||||
<svg viewBox=${`0 0 ${w} ${h}`} preserveAspectRatio="none">
|
||||
<polyline fill="none" stroke="var(--c-brand)" stroke-width="1.5" points=${points} />
|
||||
<polyline fill="rgba(121,192,255,.10)" stroke="none" points=${area} />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function recentPlansRail(c: CockpitData) {
|
||||
return html`
|
||||
<div class="cock-list cock-w-2">
|
||||
<div class="ch"><span class="ttl">${t("overview.recentPlans")}</span></div>
|
||||
${
|
||||
!c.recentPlans || c.recentPlans.length === 0
|
||||
? html`<div style="color:var(--fg-3);font-size:12.5px;padding:8px 0">${t("overview.noPlans")}</div>`
|
||||
: c.recentPlans.map(
|
||||
(p) => html`
|
||||
<div class=${`rail-step ${p.status === "done" ? "done" : "active"}`}>
|
||||
<span class="g">${p.status === "done" ? "✓" : "⏵"}</span>
|
||||
<span>${p.title} · ${p.completedSteps}/${p.totalSteps} step${p.totalSteps === 1 ? "" : "s"}</span>
|
||||
<span style="margin-left:auto;color:var(--fg-4);font-family:var(--font-mono);font-size:10.5px">${fmtRelativeTime(p.whenMs)}</span>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function toolActivityFeed(c: CockpitData) {
|
||||
return html`
|
||||
<div class="cock-list cock-w-2">
|
||||
<div class="ch"><span class="ttl">${t("overview.toolActivity")}</span></div>
|
||||
${
|
||||
!c.toolActivity || c.toolActivity.length === 0
|
||||
? html`<div style="color:var(--fg-3);font-size:12.5px;padding:8px 0">${t("overview.noToolCalls")}</div>`
|
||||
: c.toolActivity.map(
|
||||
(r) => html`
|
||||
<div class=${`feed-row ${r.level}`}>
|
||||
<span class="g">${r.level === "ok" ? "●" : r.level === "warn" ? "▲" : "✕"}</span>
|
||||
<span class="name">${r.name}${r.args ? html` <span class="args">${r.args}</span>` : null}</span>
|
||||
<span class="when" style="margin-left:auto">${fmtRelativeTime(r.whenMs)}</span>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function OverviewPanel() {
|
||||
useLang();
|
||||
const { data, error, loading } = usePoll<OverviewData>("/overview", 2500);
|
||||
if (loading && !data)
|
||||
return html`<div class="card" style="color:var(--fg-3)">${t("overview.loading")}</div>`;
|
||||
if (error) return html`<div class="card accent-err">${t("overview.failed", { error: error.message })}</div>`;
|
||||
if (!data) return null;
|
||||
const o = data;
|
||||
const c: CockpitData = o.cockpit ?? {
|
||||
balance: null,
|
||||
tokens7d: null,
|
||||
cacheHit7d: null,
|
||||
costTrend14d: null,
|
||||
currentSession: null,
|
||||
toolCalls24h: null,
|
||||
recentPlans: null,
|
||||
toolActivity: null,
|
||||
};
|
||||
const upToDate =
|
||||
o.latestVersion && o.version ? compareVersions(o.version, o.latestVersion) >= 0 : null;
|
||||
const versionDelta =
|
||||
upToDate === null ? t("overview.checking") : upToDate ? t("overview.latest") : `latest: ${o.latestVersion}`;
|
||||
const versionTone: "up" | "down" | "flat" = upToDate === false ? "down" : "flat";
|
||||
|
||||
return html`
|
||||
<div style="display:flex;flex-direction:column;gap:14px">
|
||||
${
|
||||
o.mode === "standalone"
|
||||
? html`<div class="card accent-warn">
|
||||
<div class="card-h">
|
||||
<span class="title" style="color:var(--c-warn)">${t("overview.standaloneTitle")}</span>
|
||||
</div>
|
||||
<div class="card-b">
|
||||
${t("overview.standaloneDesc")}
|
||||
</div>
|
||||
</div>`
|
||||
: null
|
||||
}
|
||||
|
||||
<h3 style="margin:0;font-family:var(--font-mono);font-size:11px;color:var(--fg-3);text-transform:uppercase;letter-spacing:.1em">
|
||||
${t("overview.cockpit")}
|
||||
</h3>
|
||||
<div class="cockpit">
|
||||
${balanceKpi(c)}
|
||||
${tokens7dKpi(c)}
|
||||
${cacheHitKpi(c)}
|
||||
${toolCallsKpi(c)}
|
||||
${budgetKpi(o)}
|
||||
|
||||
${currentSessionBlock(c)}
|
||||
${costTrendSpark(c)}
|
||||
|
||||
${recentPlansRail(c)}
|
||||
${toolActivityFeed(c)}
|
||||
|
||||
${kpi(t("overview.toolsLoaded"), fmtNum(o.toolCount), o.toolCount ? t("overview.active") : "—", "flat")}
|
||||
${kpi(t("overview.mcpServers"), fmtNum(o.mcpServerCount), o.mcpServerCount ? t("overview.allUp") : "—", o.mcpServerCount ? "up" : "flat")}
|
||||
${kpi(t("overview.editMode"), o.editMode ?? "—", o.editMode === "yolo" ? t("overview.yoloWarning") : null, o.editMode === "yolo" ? "down" : "flat")}
|
||||
${kpi(t("overview.version"), o.version ?? "—", versionDelta, versionTone)}
|
||||
</div>
|
||||
|
||||
<h3 style="margin:0;font-family:var(--font-mono);font-size:11px;color:var(--fg-3);text-transform:uppercase;letter-spacing:.1em">
|
||||
${t("overview.workingDir")}
|
||||
</h3>
|
||||
<div class="card">
|
||||
<div class="card-h"><span class="title">${t("overview.projectRoot")}</span></div>
|
||||
<code class="mono" style="color:var(--fg-2);font-size:12px">${o.cwd ?? "—"}</code>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
import { useCallback, useState } from "preact/hooks";
|
||||
import { api } from "../lib/api.js";
|
||||
import { html } from "../lib/html.js";
|
||||
import { usePoll } from "../lib/use-poll.js";
|
||||
import { t, useLang } from "../i18n/index.js";
|
||||
|
||||
interface PermissionsData {
|
||||
editMode?: string;
|
||||
currentCwd?: string | null;
|
||||
project: string[];
|
||||
builtin: string[];
|
||||
}
|
||||
|
||||
interface Feedback {
|
||||
kind: "ok" | "err" | "info";
|
||||
text: string;
|
||||
}
|
||||
|
||||
function groupByVerb(list: string[]): [string, string[]][] {
|
||||
const groups = new Map<string, string[]>();
|
||||
for (const entry of list) {
|
||||
const sp = entry.indexOf(" ");
|
||||
const verb = sp > 0 ? entry.slice(0, sp) : entry;
|
||||
const tail = sp > 0 ? entry.slice(sp + 1) : "";
|
||||
const arr = groups.get(verb) ?? [];
|
||||
arr.push(tail);
|
||||
groups.set(verb, arr);
|
||||
}
|
||||
return [...groups.entries()];
|
||||
}
|
||||
|
||||
export function PermissionsPanel() {
|
||||
useLang();
|
||||
const { data, error, loading, refresh } = usePoll<PermissionsData>("/permissions", 5000);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [feedback, setFeedback] = useState<Feedback | null>(null);
|
||||
|
||||
const add = useCallback(async () => {
|
||||
const prefix = draft.trim();
|
||||
if (!prefix) return;
|
||||
setBusy(true);
|
||||
setFeedback(null);
|
||||
try {
|
||||
const res = await api<{ alreadyPresent?: boolean }>("/permissions", {
|
||||
method: "POST",
|
||||
body: { prefix },
|
||||
});
|
||||
if (res.alreadyPresent) setFeedback({ kind: "info", text: t("permissions.alreadyIn", { prefix }) });
|
||||
else setFeedback({ kind: "ok", text: t("permissions.added", { prefix }) });
|
||||
setDraft("");
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setFeedback({ kind: "err", text: (err as Error).message });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [draft, refresh]);
|
||||
|
||||
const remove = useCallback(
|
||||
async (prefix: string) => {
|
||||
if (!confirm(t("permissions.removeConfirm", { prefix }))) return;
|
||||
setBusy(true);
|
||||
setFeedback(null);
|
||||
try {
|
||||
await api("/permissions", { method: "DELETE", body: { prefix } });
|
||||
setFeedback({ kind: "ok", text: t("permissions.removed", { prefix }) });
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setFeedback({ kind: "err", text: (err as Error).message });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
const clearAll = useCallback(async () => {
|
||||
if (!confirm(t("permissions.clearConfirm"))) return;
|
||||
setBusy(true);
|
||||
setFeedback(null);
|
||||
try {
|
||||
const res = await api<{ dropped: number }>("/permissions/clear", {
|
||||
method: "POST",
|
||||
body: { confirm: true },
|
||||
});
|
||||
setFeedback({
|
||||
kind: "ok",
|
||||
text: t("permissions.cleared", { count: res.dropped, y: res.dropped === 1 ? "y" : "ies" }),
|
||||
});
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setFeedback({ kind: "err", text: (err as Error).message });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
if (loading && !data)
|
||||
return html`<div class="card" style="color:var(--fg-3)">${t("permissions.loading")}</div>`;
|
||||
if (error) return html`<div class="card accent-err">${t("common.loadingFailed", { name: "permissions", error: error.message })}</div>`;
|
||||
if (!data) return null;
|
||||
const p = data;
|
||||
|
||||
const feedbackPill = feedback
|
||||
? html`<span
|
||||
class=${`pill ${feedback.kind === "err" ? "err" : feedback.kind === "ok" ? "ok" : "warn"}`}
|
||||
>${feedback.text}</span>`
|
||||
: null;
|
||||
|
||||
return html`
|
||||
<div style="display:flex;flex-direction:column;gap:14px">
|
||||
${
|
||||
p.editMode === "yolo"
|
||||
? html`<div class="card accent-warn">
|
||||
<div class="card-h"><span class="title" style="color:var(--c-warn)">${t("permissions.yoloTitle")}</span></div>
|
||||
<div class="card-b">
|
||||
${t("permissions.yoloDesc")}
|
||||
</div>
|
||||
</div>`
|
||||
: null
|
||||
}
|
||||
|
||||
<div class="chips">
|
||||
<span class="chip-f static active">${t("permissions.project")} <span class="ct">${p.project.length}</span></span>
|
||||
<span class="chip-f static">${t("permissions.builtin")} <span class="ct">${p.builtin.length}</span></span>
|
||||
</div>
|
||||
|
||||
${
|
||||
p.currentCwd
|
||||
? html`
|
||||
<div class="card">
|
||||
<div class="card-h">
|
||||
<span class="title">${t("permissions.addPrefix")}</span>
|
||||
<span class="meta">${p.currentCwd}</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<input
|
||||
type="text"
|
||||
placeholder=${t("permissions.addPlaceholder")}
|
||||
value=${draft}
|
||||
onInput=${(e: Event) => setDraft((e.target as HTMLInputElement).value)}
|
||||
onKeyDown=${(e: KeyboardEvent) => {
|
||||
if (e.key === "Enter") add();
|
||||
}}
|
||||
disabled=${busy}
|
||||
style="flex:1"
|
||||
/>
|
||||
<button class="primary" onClick=${add} disabled=${busy || !draft.trim()}>${t("common.add")}</button>
|
||||
<button
|
||||
class="danger"
|
||||
onClick=${clearAll}
|
||||
disabled=${busy || p.project.length === 0}
|
||||
>${t("permissions.clearAll")}</button>
|
||||
</div>
|
||||
${feedbackPill ? html`<div style="margin-top:8px">${feedbackPill}</div>` : null}
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="card accent-warn">
|
||||
<div class="card-b">
|
||||
${t("permissions.standaloneWarning")}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
<h3 style="margin:6px 0 0;font-family:var(--font-mono);font-size:11px;color:var(--fg-3);text-transform:uppercase;letter-spacing:.1em">
|
||||
${t("permissions.projectAllowlist", { count: p.project.length })}
|
||||
</h3>
|
||||
${
|
||||
p.project.length === 0
|
||||
? html`<div class="card" style="color:var(--fg-3)">${t("permissions.nothingStored")}</div>`
|
||||
: html`
|
||||
<div class="card" style="padding:0;overflow:hidden">
|
||||
<table class="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:48px">${t("permissions.colNum")}</th>
|
||||
<th>${t("permissions.colPrefix")}</th>
|
||||
<th style="width:120px"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${p.project.map(
|
||||
(prefix, i) => html`
|
||||
<tr>
|
||||
<td class="dim">${i + 1}</td>
|
||||
<td><code class="mono">${prefix}</code></td>
|
||||
<td>
|
||||
${
|
||||
p.currentCwd
|
||||
? html`<button
|
||||
class="danger"
|
||||
onClick=${() => remove(prefix)}
|
||||
disabled=${busy}
|
||||
>${t("common.remove")}</button>`
|
||||
: null
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
`,
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
<h3 style="margin:6px 0 0;font-family:var(--font-mono);font-size:11px;color:var(--fg-3);text-transform:uppercase;letter-spacing:.1em">
|
||||
${t("permissions.builtinTitle", { count: p.builtin.length })}
|
||||
</h3>
|
||||
<div class="card" style="font-family:var(--font-mono);font-size:11.5px;line-height:1.8">
|
||||
${groupByVerb(p.builtin).map(
|
||||
([verb, list]) => html`
|
||||
<div style="margin-bottom:4px">
|
||||
<span class="pill" style="margin-right:6px">${verb}</span>
|
||||
<span style="color:var(--fg-2)">${list.join(" · ")}</span>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
import { useState } from "preact/hooks";
|
||||
import { fmtPct, fmtRelativeTime } from "../lib/format.js";
|
||||
import { html } from "../lib/html.js";
|
||||
import { usePoll } from "../lib/use-poll.js";
|
||||
import { t, useLang } from "../i18n/index.js";
|
||||
|
||||
interface PlanStep {
|
||||
id: string;
|
||||
title: string;
|
||||
action?: string;
|
||||
risk?: "low" | "medium" | "high";
|
||||
}
|
||||
|
||||
interface ArchivedPlan {
|
||||
session: string;
|
||||
summary?: string;
|
||||
steps: PlanStep[];
|
||||
completedStepIds: string[];
|
||||
completedSteps: number;
|
||||
totalSteps: number;
|
||||
completionRatio: number;
|
||||
completedAt: string | number;
|
||||
}
|
||||
|
||||
interface PlansData {
|
||||
plans?: ArchivedPlan[];
|
||||
}
|
||||
|
||||
function statusPill(p: ArchivedPlan) {
|
||||
if (p.completionRatio >= 1) return html`<span class="pill ok">${t("plans.done")}</span>`;
|
||||
if (p.completionRatio > 0) return html`<span class="pill info">${t("plans.active")}</span>`;
|
||||
return html`<span class="pill">${t("plans.idle")}</span>`;
|
||||
}
|
||||
|
||||
export function PlansPanel() {
|
||||
useLang();
|
||||
const { data, error, loading } = usePoll<PlansData>("/plans", 8000);
|
||||
const [openIdx, setOpenIdx] = useState<number | null>(null);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "done">("all");
|
||||
|
||||
if (loading && !data)
|
||||
return html`<div class="card" style="color:var(--fg-3)">${t("plans.loading")}</div>`;
|
||||
if (error) return html`<div class="card accent-err">${t("common.loadingFailed", { name: "plans", error: error.message })}</div>`;
|
||||
const plans = data?.plans ?? [];
|
||||
|
||||
if (plans.length === 0)
|
||||
return html`<div class="card" style="color:var(--fg-3)">
|
||||
${t("plans.noPlans")}
|
||||
</div>`;
|
||||
|
||||
const statusFiltered =
|
||||
statusFilter === "all"
|
||||
? plans
|
||||
: statusFilter === "active"
|
||||
? plans.filter((p) => p.completionRatio > 0 && p.completionRatio < 1)
|
||||
: plans.filter((p) => p.completionRatio >= 1);
|
||||
const filtered = filter.trim()
|
||||
? statusFiltered.filter(
|
||||
(p) =>
|
||||
p.session.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
(p.summary ?? "").toLowerCase().includes(filter.toLowerCase()),
|
||||
)
|
||||
: statusFiltered;
|
||||
|
||||
const open = openIdx !== null ? plans[openIdx] : null;
|
||||
|
||||
return html`
|
||||
<div class="sessions-grid">
|
||||
<div class="sessions-list">
|
||||
<div class="ssl-h">
|
||||
<input
|
||||
type="text"
|
||||
placeholder=${t("plans.filterPlaceholder")}
|
||||
value=${filter}
|
||||
onInput=${(e: Event) => setFilter((e.target as HTMLInputElement).value)}
|
||||
style="flex:1"
|
||||
/>
|
||||
</div>
|
||||
<div class="chips" style="padding:0 12px 8px">
|
||||
<span
|
||||
class=${`chip-f ${statusFilter === "all" ? "active" : ""}`}
|
||||
onClick=${() => setStatusFilter("all")}
|
||||
>${t("common.all")} <span class="ct">${plans.length}</span></span>
|
||||
<span
|
||||
class=${`chip-f ${statusFilter === "active" ? "active" : ""}`}
|
||||
onClick=${() => setStatusFilter("active")}
|
||||
>
|
||||
${t("plans.active")}
|
||||
<span class="ct">${plans.filter((p) => p.completionRatio > 0 && p.completionRatio < 1).length}</span>
|
||||
</span>
|
||||
<span
|
||||
class=${`chip-f ${statusFilter === "done" ? "active" : ""}`}
|
||||
onClick=${() => setStatusFilter("done")}
|
||||
>
|
||||
${t("plans.done")} <span class="ct">${plans.filter((p) => p.completionRatio >= 1).length}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="ssl-rows">
|
||||
${filtered.map((p) => {
|
||||
const idx = plans.indexOf(p);
|
||||
const sel = idx === openIdx;
|
||||
return html`
|
||||
<div class=${`ssl-row ${sel ? "sel" : ""}`} onClick=${() => setOpenIdx(idx)}>
|
||||
<span class="name">${p.summary ?? p.session} ${statusPill(p)}</span>
|
||||
${
|
||||
p.summary && p.session !== p.summary
|
||||
? html`<span class="preview">${p.session}</span>`
|
||||
: null
|
||||
}
|
||||
<span class="meta">
|
||||
<span><span class="v">${p.totalSteps}</span> ${t("plans.steps")}</span>
|
||||
<span><span class="v">${p.completedSteps} / ${p.totalSteps}</span> · ${fmtPct(p.completionRatio)}</span>
|
||||
<span>${fmtRelativeTime(p.completedAt)}</span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sessions-detail">
|
||||
${
|
||||
open == null
|
||||
? html`<div style="color:var(--fg-3);font-size:13px;text-align:center;padding:60px 20px">
|
||||
${t("plans.pickHint")}
|
||||
</div>`
|
||||
: html`
|
||||
<div class="sessions-detail-h">
|
||||
<span class="name">${open.summary ?? t("plans.noTitle")}</span>
|
||||
<span class="ws">${open.session} · ${fmtRelativeTime(open.completedAt)}</span>
|
||||
<span class="actions">
|
||||
<button class="btn ghost" onClick=${() => setOpenIdx(null)}>${t("common.back")}</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 style="margin:0 0 6px;font-family:var(--font-mono);font-size:11px;color:var(--fg-3);text-transform:uppercase;letter-spacing:.1em">
|
||||
${t("plans.stepTimeline", { done: open.completedSteps, total: open.totalSteps })}
|
||||
</h3>
|
||||
<div class="plan-timeline" style="margin-bottom:14px">
|
||||
${open.steps.map((step, i) => {
|
||||
const done = open.completedStepIds.includes(step.id);
|
||||
const cls = done ? "done" : i === open.completedSteps ? "active" : "";
|
||||
return html`
|
||||
<div class=${`plan-step ${cls}`}>
|
||||
<span class="lbl">${t("plans.step", { n: i + 1 })}</span>
|
||||
<span class="name">${step.title}</span>
|
||||
${step.action ? html`<span class="meta">${step.action}</span>` : null}
|
||||
${
|
||||
step.risk
|
||||
? html`<span
|
||||
class=${`pill ${step.risk === "high" ? "err" : step.risk === "medium" ? "warn" : ""}`}
|
||||
style="align-self:flex-start;margin-top:4px"
|
||||
>${step.risk}</span>`
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,257 +0,0 @@
|
||||
import { useCallback, useState } from "preact/hooks";
|
||||
import { ChatMessage } from "../components/chat-internals.js";
|
||||
import { api } from "../lib/api.js";
|
||||
import { fmtBytes, fmtNum, fmtRelativeTime } from "../lib/format.js";
|
||||
import { html } from "../lib/html.js";
|
||||
import { usePoll } from "../lib/use-poll.js";
|
||||
import { t, useLang } from "../i18n/index.js";
|
||||
|
||||
interface SessionEntry {
|
||||
name: string;
|
||||
messageCount: number;
|
||||
size: number;
|
||||
mtime: string | number;
|
||||
}
|
||||
|
||||
interface SessionsData {
|
||||
sessions?: SessionEntry[];
|
||||
currentSession?: string | null;
|
||||
canSwitch?: boolean;
|
||||
}
|
||||
|
||||
interface OpenSession {
|
||||
name: string;
|
||||
messages: unknown[] | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function SessionsPanel() {
|
||||
useLang();
|
||||
const { data, error, loading, refresh } = usePoll<SessionsData>("/sessions", 5000);
|
||||
const [open, setOpen] = useState<OpenSession | null>(null);
|
||||
const [openLoading, setOpenLoading] = useState(false);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
const view = useCallback(async (name: string) => {
|
||||
setOpen({ name, messages: null });
|
||||
setOpenLoading(true);
|
||||
try {
|
||||
const detail = await api<{ messages: unknown[] }>(`/sessions/${encodeURIComponent(name)}`);
|
||||
setOpen({ name, messages: detail.messages });
|
||||
} catch (err) {
|
||||
setOpen({ name, messages: null, error: (err as Error).message });
|
||||
} finally {
|
||||
setOpenLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const newSession = useCallback(async () => {
|
||||
setBusy("new");
|
||||
setActionError(null);
|
||||
try {
|
||||
await api("/sessions/new", { method: "POST" });
|
||||
setOpen(null);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setActionError((err as Error).message);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
const switchTo = useCallback(
|
||||
async (name: string) => {
|
||||
setBusy(`switch:${name}`);
|
||||
setActionError(null);
|
||||
try {
|
||||
await api(`/sessions/${encodeURIComponent(name)}/switch`, { method: "POST" });
|
||||
setOpen(null);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setActionError((err as Error).message);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
const remove = useCallback(
|
||||
async (name: string) => {
|
||||
if (!confirm(t("sessions.deleteConfirm", { name }))) return;
|
||||
setBusy(`delete:${name}`);
|
||||
setActionError(null);
|
||||
try {
|
||||
await api(`/sessions/${encodeURIComponent(name)}`, { method: "DELETE" });
|
||||
if (open?.name === name) setOpen(null);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setActionError((err as Error).message);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[open, refresh],
|
||||
);
|
||||
|
||||
if (loading && !data)
|
||||
return html`<div class="card" style="color:var(--fg-3)">${t("sessions.loading")}</div>`;
|
||||
if (error)
|
||||
return html`<div class="card accent-err">${t("common.loadingFailed", { name: "sessions", error: error.message })}</div>`;
|
||||
const sessions = data?.sessions ?? [];
|
||||
const currentSession = data?.currentSession ?? null;
|
||||
const canSwitch = data?.canSwitch ?? false;
|
||||
|
||||
const filtered = filter.trim()
|
||||
? sessions.filter((s) => s.name.toLowerCase().includes(filter.toLowerCase()))
|
||||
: sessions;
|
||||
|
||||
return html`
|
||||
<div class="sessions-grid">
|
||||
<div class="sessions-list">
|
||||
<div class="ssl-h" style="display:flex;gap:6px">
|
||||
<input
|
||||
type="text"
|
||||
placeholder=${t("sessions.filterPlaceholder")}
|
||||
value=${filter}
|
||||
onInput=${(e: Event) => setFilter((e.target as HTMLInputElement).value)}
|
||||
style="flex:1"
|
||||
/>
|
||||
<button
|
||||
class="btn primary"
|
||||
disabled=${!canSwitch || busy === "new"}
|
||||
title=${canSwitch ? t("sessions.newHint") : t("sessions.attachRequired")}
|
||||
onClick=${newSession}
|
||||
>
|
||||
${busy === "new" ? t("common.loading") : `+ ${t("sessions.newBtn")}`}
|
||||
</button>
|
||||
</div>
|
||||
${
|
||||
!canSwitch
|
||||
? html`<div style="padding:0 12px 6px;font-size:11.5px;color:var(--fg-3)">${t("sessions.attachRequired")}</div>`
|
||||
: null
|
||||
}
|
||||
${
|
||||
actionError
|
||||
? html`<div class="card accent-err" style="margin:0 12px 8px;padding:6px 10px;font-size:12px">${actionError}</div>`
|
||||
: null
|
||||
}
|
||||
<div class="chips" style="padding:0 12px 8px">
|
||||
<span class="chip-f static active">${t("common.all")} <span class="ct">${sessions.length}</span></span>
|
||||
${currentSession ? html`<span class="chip-f static">${t("sessions.activeChip")} <span class="ct">${currentSession}</span></span>` : null}
|
||||
</div>
|
||||
${
|
||||
sessions.length === 0
|
||||
? html`<div class="ctx-empty" style="padding:24px 12px;color:var(--fg-3)">${t("sessions.noSessions")}</div>`
|
||||
: html`<div class="ssl-rows">
|
||||
${filtered.map((s) => {
|
||||
const isCurrent = currentSession === s.name;
|
||||
return html`
|
||||
<div
|
||||
class=${`ssl-row ${open?.name === s.name ? "sel" : ""}`}
|
||||
onClick=${() => view(s.name)}
|
||||
>
|
||||
<span class="name">
|
||||
${isCurrent ? html`<span class="pill ok" style="margin-right:6px">${t("sessions.activePill")}</span>` : null}
|
||||
${s.name}
|
||||
</span>
|
||||
<span class="meta">
|
||||
<span><span class="v">${fmtNum(s.messageCount)}</span> ${t("sessions.msgs")}</span>
|
||||
<span><span class="v">${fmtBytes(s.size)}</span></span>
|
||||
<span>${fmtRelativeTime(s.mtime)}</span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>`
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="sessions-detail">
|
||||
${
|
||||
open == null
|
||||
? html`<div style="color:var(--fg-3);font-size:13px;text-align:center;padding:60px 20px">
|
||||
${t("sessions.pickHint")}
|
||||
</div>`
|
||||
: (() => {
|
||||
const isCurrent = currentSession === open.name;
|
||||
return html`
|
||||
<div class="sessions-detail-h">
|
||||
<span class="name">
|
||||
${isCurrent ? html`<span class="pill ok" style="margin-right:6px">${t("sessions.activePill")}</span>` : null}
|
||||
${open.name}
|
||||
</span>
|
||||
<span class="ws">
|
||||
${
|
||||
open.messages
|
||||
? t("sessions.messages", { count: open.messages.length, s: open.messages.length === 1 ? "" : "s" })
|
||||
: t("common.loading")
|
||||
}
|
||||
</span>
|
||||
<span class="actions">
|
||||
${
|
||||
canSwitch && !isCurrent
|
||||
? html`<button class="btn primary" disabled=${busy === `switch:${open.name}`} onClick=${() => switchTo(open.name)}>${busy === `switch:${open.name}` ? t("common.loading") : t("sessions.switchBtn")}</button>`
|
||||
: null
|
||||
}
|
||||
<button
|
||||
class="btn"
|
||||
disabled=${isCurrent || busy === `delete:${open.name}`}
|
||||
title=${isCurrent ? t("sessions.cantDeleteActive") : t("sessions.deleteBtn")}
|
||||
style="border-color:var(--c-err);color:var(--c-err)"
|
||||
onClick=${() => remove(open.name)}
|
||||
>${busy === `delete:${open.name}` ? t("common.loading") : t("sessions.deleteBtn")}</button>
|
||||
<button class="btn ghost" onClick=${() => setOpen(null)}>${t("common.back")}</button>
|
||||
</span>
|
||||
</div>
|
||||
${
|
||||
!canSwitch
|
||||
? html`<div class="card accent-brand" style="margin-bottom:10px">
|
||||
<div class="card-h"><span class="title">${t("sessions.resumeTitle")}</span></div>
|
||||
<div class="card-b" style="font-size:12.5px;color:var(--fg-2)">
|
||||
${t("sessions.resumeDesc")}
|
||||
<code class="mono" style="display:block;margin-top:8px;padding:8px 10px;background:var(--bg-input);border-radius:var(--r);color:var(--fg-0);font-size:12px;user-select:all">reasonix chat --session ${open.name}</code>
|
||||
</div>
|
||||
</div>`
|
||||
: null
|
||||
}
|
||||
${
|
||||
openLoading
|
||||
? html`<div style="color:var(--fg-3)">${t("sessions.loadingTranscript")}</div>`
|
||||
: open.error
|
||||
? html`<div class="card accent-err">${open.error}</div>`
|
||||
: open.messages && open.messages.length > 0
|
||||
? html`<div class="chat-feed" style="max-height:calc(100vh - 220px);overflow-y:auto">
|
||||
${open.messages.map(
|
||||
(m: any, i: number) => html`
|
||||
<${ChatMessage}
|
||||
key=${i}
|
||||
msg=${{
|
||||
id: `r-${i}`,
|
||||
role:
|
||||
m.role === "tool"
|
||||
? "tool"
|
||||
: m.role === "assistant"
|
||||
? "assistant"
|
||||
: m.role === "user"
|
||||
? "user"
|
||||
: "info",
|
||||
text: m.content ?? "",
|
||||
toolName: m.toolName,
|
||||
}}
|
||||
streaming=${false}
|
||||
/>
|
||||
`,
|
||||
)}
|
||||
</div>`
|
||||
: html`<div style="color:var(--fg-3)">${t("sessions.emptyTranscript")}</div>`
|
||||
}
|
||||
`;
|
||||
})()
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,718 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
||||
import { type DashboardLang, getLang, setLang, t, useLang } from "../i18n/index.js";
|
||||
import { api } from "../lib/api.js";
|
||||
import {
|
||||
type BudgetState,
|
||||
QUICK_CAPS_USD,
|
||||
budgetTone,
|
||||
bumpSuggestions,
|
||||
deriveBudgetState,
|
||||
} from "../lib/budget.js";
|
||||
import { html } from "../lib/html.js";
|
||||
import {
|
||||
INTERVAL_PRESETS_MS,
|
||||
type IntervalUnit,
|
||||
type LoopRunStatus,
|
||||
formatRemaining,
|
||||
parseCustomInterval,
|
||||
} from "../lib/loop-control.js";
|
||||
|
||||
interface SettingsData {
|
||||
apiKey?: string | null;
|
||||
baseUrl?: string;
|
||||
preset?: string;
|
||||
reasoningEffort?: string;
|
||||
search?: boolean;
|
||||
model?: string;
|
||||
editMode?: string;
|
||||
proNext?: boolean;
|
||||
budgetUsd?: number | null;
|
||||
/** Cumulative session spend (USD); null when no session is attached. */
|
||||
sessionSpendUsd?: number | null;
|
||||
skillPaths?: string[];
|
||||
}
|
||||
|
||||
function fmtUsd2(n: number): string {
|
||||
return `$${n.toFixed(n < 1 ? 4 : 2)}`;
|
||||
}
|
||||
|
||||
interface ModelPriceEntry {
|
||||
inputCacheHit: number;
|
||||
inputCacheMiss: number;
|
||||
output: number;
|
||||
}
|
||||
|
||||
interface ModelCatalog {
|
||||
models: string[] | null;
|
||||
current: string | null;
|
||||
pricing: Record<string, ModelPriceEntry>;
|
||||
}
|
||||
|
||||
function formatPricing(p: ModelPriceEntry | undefined): string | null {
|
||||
if (!p) return null;
|
||||
return t("settings.modelPricingLine", {
|
||||
hit: p.inputCacheHit.toFixed(3),
|
||||
miss: p.inputCacheMiss.toFixed(3),
|
||||
out: p.output.toFixed(3),
|
||||
});
|
||||
}
|
||||
|
||||
function ModelRow({
|
||||
current,
|
||||
catalog,
|
||||
saving,
|
||||
onPick,
|
||||
}: {
|
||||
current: string;
|
||||
catalog: ModelCatalog | null;
|
||||
saving: boolean;
|
||||
onPick: (model: string) => void;
|
||||
}) {
|
||||
const list = catalog?.models ?? null;
|
||||
const ready = list && list.length > 0;
|
||||
if (!ready) {
|
||||
// Fallback: catalog hasn't loaded (or API failed). Read-only — same as before D-4.
|
||||
return html`<code class="mono">${current ?? "—"}</code>`;
|
||||
}
|
||||
// Ensure the live model is selectable even if the catalog hasn't reported it
|
||||
// yet (preset overrides, custom IDs).
|
||||
const options = list.includes(current) ? list : [current, ...list];
|
||||
const price = catalog?.pricing[current];
|
||||
return html`
|
||||
<span style="display:inline-flex;flex-direction:column;gap:4px">
|
||||
<select
|
||||
value=${current}
|
||||
onChange=${(e: Event) => {
|
||||
const next = (e.target as HTMLSelectElement).value;
|
||||
if (next && next !== current) onPick(next);
|
||||
}}
|
||||
disabled=${saving}
|
||||
style="font-family:var(--font-mono);min-width:200px"
|
||||
>
|
||||
${options.map((m) => html`<option key=${m} value=${m}>${m}</option>`)}
|
||||
</select>
|
||||
${
|
||||
price
|
||||
? html`<span style="color:var(--fg-3);font-size:11px;font-family:var(--font-mono)">${formatPricing(price)}</span>`
|
||||
: null
|
||||
}
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
function BudgetGauge({ state }: { state: BudgetState }) {
|
||||
if (state.kind === "off") return null;
|
||||
const tone = budgetTone(state);
|
||||
const fill = Math.min(100, state.pct);
|
||||
const valueColor =
|
||||
tone === "err"
|
||||
? "color:var(--c-err)"
|
||||
: tone === "warn"
|
||||
? "color:var(--c-warn)"
|
||||
: "color:var(--fg-1)";
|
||||
return html`
|
||||
<div style="display:flex;flex-direction:column;gap:6px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:baseline;font-size:13px">
|
||||
<span style=${valueColor}>
|
||||
<strong style="font-family:var(--font-mono)">${fmtUsd2(state.spent)}</strong>
|
||||
<span style="color:var(--fg-3)"> ${t("settings.budgetOf")} </span>
|
||||
<strong style="font-family:var(--font-mono)">${fmtUsd2(state.cap)}</strong>
|
||||
</span>
|
||||
<span style=${`font-family:var(--font-mono);font-size:11px;${valueColor}`}>${state.pct.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div class=${`progress ${tone}`}><div class="progress-fill" style=${`width:${fill}%`}></div></div>
|
||||
<span style="color:var(--fg-3);font-size:11px">
|
||||
${
|
||||
state.kind === "exhausted"
|
||||
? t("settings.budgetRefusing")
|
||||
: state.kind === "warn"
|
||||
? t("settings.budgetWarnLine")
|
||||
: t("settings.budgetIdleLine")
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
interface BudgetSectionProps {
|
||||
state: BudgetState;
|
||||
saving: boolean;
|
||||
onSetCap: (usd: number) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
function BudgetSection({ state, saving, onSetCap, onClear }: BudgetSectionProps) {
|
||||
const [custom, setCustom] = useState("");
|
||||
const submitCustom = () => {
|
||||
const n = Number.parseFloat(custom);
|
||||
if (Number.isFinite(n) && n > 0) {
|
||||
onSetCap(n);
|
||||
setCustom("");
|
||||
}
|
||||
};
|
||||
|
||||
const quickButtons = (caps: ReadonlyArray<number>) =>
|
||||
caps.map(
|
||||
(c) => html`
|
||||
<button
|
||||
key=${c}
|
||||
class="btn"
|
||||
style="font-family:var(--font-mono)"
|
||||
disabled=${saving}
|
||||
onClick=${() => onSetCap(c)}
|
||||
>$${c}</button>
|
||||
`,
|
||||
);
|
||||
|
||||
const customField = html`
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;margin-left:auto">
|
||||
<span style="color:var(--fg-3);font-size:11px">${t("settings.budgetCustom")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
value=${custom}
|
||||
placeholder="0.00"
|
||||
onInput=${(e: Event) => setCustom((e.target as HTMLInputElement).value)}
|
||||
onKeyDown=${(e: KeyboardEvent) => {
|
||||
if (e.key === "Enter") submitCustom();
|
||||
}}
|
||||
style="width:72px;font-family:var(--font-mono)"
|
||||
disabled=${saving}
|
||||
/>
|
||||
<button
|
||||
class="btn primary"
|
||||
disabled=${saving || !(Number.parseFloat(custom) > 0)}
|
||||
onClick=${submitCustom}
|
||||
>→</button>
|
||||
</span>
|
||||
`;
|
||||
|
||||
return html`
|
||||
<div class="card" style="display:flex;flex-direction:column;gap:12px">
|
||||
<${BudgetGauge} state=${state} />
|
||||
|
||||
${
|
||||
state.kind === "off"
|
||||
? html`
|
||||
<div>
|
||||
<div style="color:var(--fg-3);font-size:11px;margin-bottom:6px">${t("settings.budgetSetCap")}</div>
|
||||
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
|
||||
${quickButtons(QUICK_CAPS_USD)}
|
||||
${customField}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: state.kind === "warn" || state.kind === "exhausted"
|
||||
? html`
|
||||
<div>
|
||||
<div style="color:var(--fg-3);font-size:11px;margin-bottom:6px">${t("settings.budgetBumpHint")}</div>
|
||||
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
|
||||
${bumpSuggestions(state.cap).map(
|
||||
(next) => html`
|
||||
<button
|
||||
key=${next}
|
||||
class="btn primary"
|
||||
style="font-family:var(--font-mono)"
|
||||
disabled=${saving}
|
||||
onClick=${() => onSetCap(next)}
|
||||
>→ $${next % 1 === 0 ? next : next.toFixed(2)}</button>
|
||||
`,
|
||||
)}
|
||||
${customField}
|
||||
</div>
|
||||
<div style="margin-top:8px">
|
||||
<button class="btn" disabled=${saving} onClick=${onClear}>${t("settings.budgetClear")}</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
|
||||
${bumpSuggestions(state.cap).map(
|
||||
(next) => html`
|
||||
<button
|
||||
key=${next}
|
||||
class="btn"
|
||||
style="font-family:var(--font-mono)"
|
||||
disabled=${saving}
|
||||
onClick=${() => onSetCap(next)}
|
||||
>→ $${next % 1 === 0 ? next : next.toFixed(2)}</button>
|
||||
`,
|
||||
)}
|
||||
${customField}
|
||||
<button
|
||||
class="btn"
|
||||
style="margin-left:8px"
|
||||
disabled=${saving}
|
||||
onClick=${onClear}
|
||||
>${t("settings.budgetClear")}</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
interface LoopSectionProps {
|
||||
status: LoopRunStatus | null;
|
||||
/** ms remaining until next fire — ticks down client-side between status polls. */
|
||||
remainingMs: number;
|
||||
/** Last-turn cost in USD; used as a hint for "each iteration costs ~". */
|
||||
avgIterCostUsd: number | null;
|
||||
busy: boolean;
|
||||
onStart: (intervalMs: number, prompt: string) => void;
|
||||
onStop: () => void;
|
||||
}
|
||||
|
||||
function LoopSection({
|
||||
status,
|
||||
remainingMs,
|
||||
avgIterCostUsd,
|
||||
busy,
|
||||
onStart,
|
||||
onStop,
|
||||
}: LoopSectionProps) {
|
||||
const [intervalMs, setIntervalMs] = useState<number>(INTERVAL_PRESETS_MS[1]!.ms);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [customValue, setCustomValue] = useState("");
|
||||
const [customUnit, setCustomUnit] = useState<IntervalUnit>("m");
|
||||
|
||||
if (status) {
|
||||
return html`
|
||||
<div class="card" style="display:flex;flex-direction:column;gap:10px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:baseline">
|
||||
<span style="color:var(--c-warn);font-family:var(--font-mono);font-size:11px">⟳ ${t("settings.loopRunning")}</span>
|
||||
<span style="color:var(--fg-3);font-size:11px">
|
||||
${t("settings.loopIter", { iter: status.iter })} · ${t("settings.loopFiresIn", { remaining: formatRemaining(remainingMs) })}
|
||||
</span>
|
||||
</div>
|
||||
<div style="background:var(--bg-elev-2);border:1px solid var(--bd);border-radius:var(--r);padding:8px 10px;font-family:var(--font-mono);font-size:12px;color:var(--fg-1);white-space:pre-wrap;max-height:120px;overflow-y:auto">${status.prompt}</div>
|
||||
<div>
|
||||
<button class="btn danger" disabled=${busy} onClick=${onStop}>${t("settings.loopStop")}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const customMs = parseCustomInterval(customValue, customUnit);
|
||||
const canStart = !busy && intervalMs > 0 && prompt.trim().length > 0;
|
||||
|
||||
return html`
|
||||
<div class="card" style="display:flex;flex-direction:column;gap:10px">
|
||||
<div style="color:var(--fg-3);font-size:11px">
|
||||
${t("settings.loopIdleHint")}
|
||||
${
|
||||
typeof avgIterCostUsd === "number" && avgIterCostUsd > 0
|
||||
? html` ${t("settings.loopCostHint", { cost: `$${avgIterCostUsd.toFixed(4)}` })}`
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:6px">
|
||||
<span style="color:var(--fg-3);font-size:11px">${t("settings.loopInterval")}</span>
|
||||
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
|
||||
${INTERVAL_PRESETS_MS.map(
|
||||
(p) => html`
|
||||
<button
|
||||
key=${p.ms}
|
||||
class=${`btn ${intervalMs === p.ms && customValue === "" ? "primary" : ""}`}
|
||||
style="font-family:var(--font-mono)"
|
||||
disabled=${busy}
|
||||
onClick=${() => {
|
||||
setIntervalMs(p.ms);
|
||||
setCustomValue("");
|
||||
}}
|
||||
>${p.label}</button>
|
||||
`,
|
||||
)}
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;margin-left:auto">
|
||||
<span style="color:var(--fg-3);font-size:11px">${t("settings.loopCustom")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
value=${customValue}
|
||||
onInput=${(e: Event) => {
|
||||
const raw = (e.target as HTMLInputElement).value;
|
||||
setCustomValue(raw);
|
||||
const ms = parseCustomInterval(raw, customUnit);
|
||||
if (ms !== null) setIntervalMs(ms);
|
||||
}}
|
||||
style="width:64px;font-family:var(--font-mono)"
|
||||
disabled=${busy}
|
||||
/>
|
||||
<select
|
||||
value=${customUnit}
|
||||
onChange=${(e: Event) => {
|
||||
const next = (e.target as HTMLSelectElement).value as IntervalUnit;
|
||||
setCustomUnit(next);
|
||||
if (customValue) {
|
||||
const ms = parseCustomInterval(customValue, next);
|
||||
if (ms !== null) setIntervalMs(ms);
|
||||
}
|
||||
}}
|
||||
disabled=${busy}
|
||||
>
|
||||
<option value="s">s</option>
|
||||
<option value="m">m</option>
|
||||
<option value="h">h</option>
|
||||
</select>
|
||||
</span>
|
||||
</div>
|
||||
${
|
||||
customValue && customMs === null
|
||||
? html`<span style="color:var(--c-err);font-size:11px">${t("settings.loopRangeError")}</span>`
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:6px">
|
||||
<span style="color:var(--fg-3);font-size:11px">${t("settings.loopPrompt")}</span>
|
||||
<textarea
|
||||
rows="3"
|
||||
placeholder=${t("settings.loopPromptPlaceholder")}
|
||||
value=${prompt}
|
||||
onInput=${(e: Event) => setPrompt((e.target as HTMLTextAreaElement).value)}
|
||||
style="width:100%;font-family:var(--font-mono);resize:vertical"
|
||||
disabled=${busy}
|
||||
></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
class="btn primary"
|
||||
disabled=${!canStart}
|
||||
onClick=${() => onStart(intervalMs, prompt.trim())}
|
||||
>${t("settings.loopStart")}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function SettingsPanel() {
|
||||
useLang();
|
||||
const [data, setData] = useState<SettingsData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState<Partial<SettingsData>>({});
|
||||
const [catalog, setCatalog] = useState<ModelCatalog | null>(null);
|
||||
const [loopStatus, setLoopStatus] = useState<LoopRunStatus | null>(null);
|
||||
const [loopAvgCost, setLoopAvgCost] = useState<number | null>(null);
|
||||
const [loopBusy, setLoopBusy] = useState(false);
|
||||
/** Wall-clock time of the last status sync — used to interpolate the countdown. */
|
||||
const lastStatusSyncRef = useRef<number>(0);
|
||||
const [now, setNow] = useState<number>(() => Date.now());
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const r = await api<SettingsData>("/settings");
|
||||
setData(r);
|
||||
setDraft({});
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
useEffect(() => {
|
||||
api<ModelCatalog>("/models")
|
||||
.then(setCatalog)
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
const refreshLoop = useCallback(async () => {
|
||||
try {
|
||||
const r = await api<{ status: LoopRunStatus | null }>("/loop/status");
|
||||
setLoopStatus(r.status);
|
||||
lastStatusSyncRef.current = Date.now();
|
||||
} catch {
|
||||
/* ignore — status is best-effort */
|
||||
}
|
||||
try {
|
||||
const r = await api<{ stats?: { lastTurnCostUsd?: number } }>("/overview");
|
||||
setLoopAvgCost(r.stats?.lastTurnCostUsd ?? null);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
refreshLoop();
|
||||
const id = setInterval(() => {
|
||||
if (!cancelled) refreshLoop();
|
||||
}, 5_000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, [refreshLoop]);
|
||||
useEffect(() => {
|
||||
if (!loopStatus) return;
|
||||
const id = setInterval(() => setNow(Date.now()), 1_000);
|
||||
return () => clearInterval(id);
|
||||
}, [loopStatus]);
|
||||
|
||||
const remainingMs = loopStatus
|
||||
? Math.max(0, loopStatus.nextFireMs - (now - lastStatusSyncRef.current))
|
||||
: 0;
|
||||
|
||||
const startLoop = useCallback(
|
||||
async (intervalMs: number, prompt: string) => {
|
||||
setLoopBusy(true);
|
||||
try {
|
||||
await api("/loop/start", { method: "POST", body: { intervalMs, prompt } });
|
||||
await refreshLoop();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setLoopBusy(false);
|
||||
}
|
||||
},
|
||||
[refreshLoop],
|
||||
);
|
||||
const stopLoop = useCallback(async () => {
|
||||
setLoopBusy(true);
|
||||
try {
|
||||
await api("/loop/stop", { method: "POST" });
|
||||
await refreshLoop();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setLoopBusy(false);
|
||||
}
|
||||
}, [refreshLoop]);
|
||||
|
||||
const save = useCallback(
|
||||
async (fields: Partial<SettingsData>) => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api("/settings", { method: "POST", body: fields });
|
||||
await load();
|
||||
setSaved(t("settings.saved", { fields: Object.keys(fields).join(", ") }));
|
||||
setTimeout(() => setSaved(null), 3000);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[load],
|
||||
);
|
||||
|
||||
const skillPathsDraft = draft.skillPaths ?? data?.skillPaths ?? [];
|
||||
const skillPathsText = Array.isArray(skillPathsDraft)
|
||||
? skillPathsDraft.join(", ")
|
||||
: String(skillPathsDraft ?? "");
|
||||
|
||||
if (!data && !error)
|
||||
return html`<div class="card" style="color:var(--fg-3)">${t("settings.loading")}</div>`;
|
||||
if (error && !data) return html`<div class="card accent-err">${error}</div>`;
|
||||
if (!data) return null;
|
||||
const v = data;
|
||||
|
||||
const sectionH3 = (text: string) => html`
|
||||
<h3 style="margin:18px 0 8px;font-family:var(--font-mono);font-size:11px;color:var(--fg-3);text-transform:uppercase;letter-spacing:.1em">${text}</h3>
|
||||
`;
|
||||
const fieldRow = (label: string, control: unknown, note?: string) => html`
|
||||
<div style="display:flex;align-items:center;gap:10px;padding:6px 0">
|
||||
<span style="flex:0 0 110px;font-family:var(--font-mono);font-size:11.5px;color:var(--fg-3)">${label}</span>
|
||||
<div style="flex:1;display:flex;align-items:center;gap:8px">${control}</div>
|
||||
${note ? html`<span style="color:var(--fg-3);font-size:11px">${note}</span>` : null}
|
||||
</div>
|
||||
`;
|
||||
|
||||
const currentLang = getLang();
|
||||
|
||||
return html`
|
||||
<div style="max-width:760px;display:flex;flex-direction:column;gap:6px">
|
||||
${saved ? html`<div><span class="pill ok">${saved}</span></div>` : null}
|
||||
${error ? html`<div class="card accent-err">${error}</div>` : null}
|
||||
|
||||
${sectionH3(t("settings.sectionLanguage"))}
|
||||
<div class="card">
|
||||
${fieldRow(
|
||||
t("settings.language"),
|
||||
html`
|
||||
<select
|
||||
value=${currentLang}
|
||||
onChange=${(e: Event) => {
|
||||
const lang = (e.target as HTMLSelectElement).value as DashboardLang;
|
||||
setLang(lang);
|
||||
}}
|
||||
>
|
||||
<option value="en">${t("settings.langEn")}</option>
|
||||
<option value="zh-CN">${t("settings.langZhCn")}</option>
|
||||
</select>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
|
||||
${sectionH3(t("settings.sectionApi"))}
|
||||
<div class="card">
|
||||
${fieldRow(
|
||||
t("settings.apiKey"),
|
||||
html`<code class="mono" style="color:var(--fg-2);font-size:11.5px">${v.apiKey ?? t("settings.notSet")}</code>`,
|
||||
)}
|
||||
${fieldRow(
|
||||
t("settings.replace"),
|
||||
html`
|
||||
<input
|
||||
type="password"
|
||||
placeholder=${t("settings.pasteKey")}
|
||||
value=${draft.apiKey ?? ""}
|
||||
onInput=${(e: Event) => setDraft({ ...draft, apiKey: (e.target as HTMLInputElement).value })}
|
||||
style="flex:1"
|
||||
/>
|
||||
<button
|
||||
class="btn primary"
|
||||
disabled=${saving || !(draft.apiKey ?? "").trim()}
|
||||
onClick=${() => save({ apiKey: draft.apiKey })}
|
||||
>${t("settings.saveKey")}</button>
|
||||
`,
|
||||
)}
|
||||
${fieldRow(
|
||||
t("settings.baseUrl"),
|
||||
html`
|
||||
<input
|
||||
type="text"
|
||||
value=${draft.baseUrl ?? v.baseUrl ?? ""}
|
||||
placeholder=${t("settings.baseUrlPlaceholder")}
|
||||
onInput=${(e: Event) => setDraft({ ...draft, baseUrl: (e.target as HTMLInputElement).value })}
|
||||
style="flex:1"
|
||||
/>
|
||||
<button
|
||||
class="btn"
|
||||
disabled=${saving || (draft.baseUrl ?? v.baseUrl ?? "") === (v.baseUrl ?? "")}
|
||||
onClick=${() => save({ baseUrl: draft.baseUrl })}
|
||||
>${t("common.save")}</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
|
||||
${sectionH3(t("settings.sectionDefaults"))}
|
||||
<div class="card">
|
||||
${fieldRow(
|
||||
t("settings.preset"),
|
||||
html`
|
||||
<select
|
||||
value=${["auto", "flash", "pro"].includes(v.preset ?? "") ? v.preset : "auto"}
|
||||
onChange=${(e: Event) => save({ preset: (e.target as HTMLSelectElement).value })}
|
||||
disabled=${saving}
|
||||
>
|
||||
<option value="auto">${t("settings.presetAuto")}</option>
|
||||
<option value="flash">${t("settings.presetFlash")}</option>
|
||||
<option value="pro">${t("settings.presetPro")}</option>
|
||||
</select>
|
||||
`,
|
||||
t("settings.appliesNextTurn"),
|
||||
)}
|
||||
${fieldRow(
|
||||
t("settings.effort"),
|
||||
html`
|
||||
<select
|
||||
value=${v.reasoningEffort}
|
||||
onChange=${(e: Event) => save({ reasoningEffort: (e.target as HTMLSelectElement).value })}
|
||||
disabled=${saving}
|
||||
>
|
||||
<option value="max">${t("settings.effortMax")}</option>
|
||||
<option value="high">${t("settings.effortHigh")}</option>
|
||||
</select>
|
||||
`,
|
||||
t("settings.appliesNextTurn"),
|
||||
)}
|
||||
${fieldRow(
|
||||
t("settings.webSearch"),
|
||||
html`
|
||||
<button
|
||||
class=${`btn ${v.search ? "primary" : ""}`}
|
||||
onClick=${() => save({ search: !v.search })}
|
||||
disabled=${saving}
|
||||
>${v.search ? t("common.on") : t("common.off")}</button>
|
||||
`,
|
||||
t("settings.webSearchNote"),
|
||||
)}
|
||||
</div>
|
||||
|
||||
${sectionH3(t("settings.sectionSkills"))}
|
||||
<div class="card">
|
||||
${fieldRow(
|
||||
t("settings.skillPaths"),
|
||||
html`
|
||||
<input
|
||||
type="text"
|
||||
value=${skillPathsText}
|
||||
placeholder=${t("settings.skillPathsPlaceholder")}
|
||||
onInput=${(e: Event) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
skillPaths: (e.target as HTMLInputElement).value
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
})}
|
||||
style="flex:1;font-family:var(--font-mono)"
|
||||
/>
|
||||
<button
|
||||
class="btn"
|
||||
disabled=${saving || skillPathsText === (v.skillPaths ?? []).join(", ")}
|
||||
onClick=${() => save({ skillPaths: draft.skillPaths ?? [] })}
|
||||
>${t("common.save")}</button>
|
||||
`,
|
||||
t("settings.skillPathsNote"),
|
||||
)}
|
||||
</div>
|
||||
|
||||
${sectionH3(t("settings.sectionCompute"))}
|
||||
<div class="card">
|
||||
${fieldRow(
|
||||
t("settings.proNext"),
|
||||
html`
|
||||
<button
|
||||
class=${`btn ${v.proNext ? "primary" : ""}`}
|
||||
onClick=${() => save({ proNext: !v.proNext })}
|
||||
disabled=${saving}
|
||||
>${v.proNext ? t("settings.proArmed") : t("settings.proArm")}</button>
|
||||
`,
|
||||
t("settings.proNextNote"),
|
||||
)}
|
||||
</div>
|
||||
|
||||
${sectionH3(t("settings.sectionBudget"))}
|
||||
<${BudgetSection}
|
||||
state=${deriveBudgetState(v.budgetUsd, v.sessionSpendUsd)}
|
||||
saving=${saving}
|
||||
onSetCap=${(usd: number) => save({ budgetUsd: usd })}
|
||||
onClear=${() => save({ budgetUsd: null })}
|
||||
/>
|
||||
|
||||
${sectionH3(t("settings.sectionLoop"))}
|
||||
<${LoopSection}
|
||||
status=${loopStatus}
|
||||
remainingMs=${remainingMs}
|
||||
avgIterCostUsd=${loopAvgCost}
|
||||
busy=${loopBusy}
|
||||
onStart=${startLoop}
|
||||
onStop=${stopLoop}
|
||||
/>
|
||||
|
||||
${sectionH3(t("settings.sectionRuntime"))}
|
||||
<div class="card">
|
||||
${fieldRow(
|
||||
t("settings.activeModel"),
|
||||
html`<${ModelRow}
|
||||
current=${v.model ?? "—"}
|
||||
catalog=${catalog}
|
||||
saving=${saving}
|
||||
onPick=${(m: string) => save({ model: m })}
|
||||
/>`,
|
||||
t("settings.appliesNextTurn"),
|
||||
)}
|
||||
${fieldRow(
|
||||
t("settings.editMode"),
|
||||
html`<code class="mono">${v.editMode}</code>`,
|
||||
t("settings.editModeNote"),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,296 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from "preact/hooks";
|
||||
import { t, useLang } from "../i18n/index.js";
|
||||
import { api } from "../lib/api.js";
|
||||
import { html } from "../lib/html.js";
|
||||
|
||||
interface SkillEntry {
|
||||
name: string;
|
||||
description?: string;
|
||||
runs7d?: number;
|
||||
}
|
||||
|
||||
interface SkillPathInfo {
|
||||
dir: string;
|
||||
scope: "custom";
|
||||
status: string;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
interface SkillsData {
|
||||
paths: { project?: string; custom?: SkillPathInfo[] };
|
||||
project: SkillEntry[];
|
||||
custom: SkillEntry[];
|
||||
global: SkillEntry[];
|
||||
builtin: SkillEntry[];
|
||||
}
|
||||
|
||||
type Scope = "project" | "custom" | "global" | "builtin";
|
||||
|
||||
export function SkillsPanel() {
|
||||
useLang();
|
||||
const [data, setData] = useState<SkillsData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState<{ scope: Scope; name: string } | null>(null);
|
||||
const [body, setBody] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newScope, setNewScope] = useState<"global" | "project">("global");
|
||||
const [filter, setFilter] = useState("");
|
||||
const [scopeFilter, setScopeFilter] = useState<"all" | Scope>("all");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setData(await api<SkillsData>("/skills"));
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const openSkill = useCallback(async (scope: Scope, name: string) => {
|
||||
setOpen({ scope, name });
|
||||
if (scope === "builtin" || scope === "custom") {
|
||||
setBody("");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await api<{ body: string }>(`/skills/${scope}/${encodeURIComponent(name)}`);
|
||||
setBody(r.body);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
if (!open) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api(`/skills/${open.scope}/${encodeURIComponent(open.name)}`, {
|
||||
method: "POST",
|
||||
body: { body },
|
||||
});
|
||||
setInfo(t("skills.saved", { scope: open.scope, name: open.name }));
|
||||
setTimeout(() => setInfo(null), 3000);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [open, body, load]);
|
||||
|
||||
const remove = useCallback(async () => {
|
||||
if (!open) return;
|
||||
if (!confirm(t("skills.deleteConfirm", { scope: open.scope, name: open.name }))) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api(`/skills/${open.scope}/${encodeURIComponent(open.name)}`, { method: "DELETE" });
|
||||
setOpen(null);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [open, load]);
|
||||
|
||||
const create = useCallback(async () => {
|
||||
if (!newName.trim()) return;
|
||||
setBusy(true);
|
||||
const stub = `---\nname: ${newName.trim()}\ndescription: TODO — one-line description that helps the model match this skill\n---\n\n# ${newName.trim()}\n\n`;
|
||||
try {
|
||||
await api(`/skills/${newScope}/${encodeURIComponent(newName.trim())}`, {
|
||||
method: "POST",
|
||||
body: { body: stub },
|
||||
});
|
||||
setNewName("");
|
||||
await load();
|
||||
openSkill(newScope, newName.trim());
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [newName, newScope, load, openSkill]);
|
||||
|
||||
if (!data && !error)
|
||||
return html`<div class="card" style="color:var(--fg-3)">${t("skills.loading")}</div>`;
|
||||
if (error && !data) return html`<div class="card accent-err">${error}</div>`;
|
||||
if (!data) return null;
|
||||
|
||||
const allWith = [
|
||||
...data.project.map((s) => ({ scope: "project" as Scope, ...s })),
|
||||
...data.custom.map((s) => ({ scope: "custom" as Scope, ...s })),
|
||||
...data.global.map((s) => ({ scope: "global" as Scope, ...s })),
|
||||
...data.builtin.map((s) => ({ scope: "builtin" as Scope, ...s })),
|
||||
];
|
||||
const scopeFiltered =
|
||||
scopeFilter === "all" ? allWith : allWith.filter((s) => s.scope === scopeFilter);
|
||||
const filtered = filter.trim()
|
||||
? scopeFiltered.filter(
|
||||
(s) =>
|
||||
s.name.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
(s.description ?? "").toLowerCase().includes(filter.toLowerCase()),
|
||||
)
|
||||
: scopeFiltered;
|
||||
|
||||
return html`
|
||||
<div class="sessions-grid">
|
||||
<div class="sessions-list">
|
||||
<div class="ssl-h">
|
||||
<input
|
||||
type="text"
|
||||
placeholder=${t("skills.filterPlaceholder")}
|
||||
value=${filter}
|
||||
onInput=${(e: Event) => setFilter((e.target as HTMLInputElement).value)}
|
||||
style="flex:1"
|
||||
/>
|
||||
</div>
|
||||
<div class="chips" style="padding:0 12px 8px">
|
||||
<span
|
||||
class=${`chip-f ${scopeFilter === "all" ? "active" : ""}`}
|
||||
onClick=${() => setScopeFilter("all")}
|
||||
>${t("common.all")} <span class="ct">${allWith.length}</span></span>
|
||||
<span
|
||||
class=${`chip-f ${scopeFilter === "project" ? "active" : ""}`}
|
||||
onClick=${() => setScopeFilter("project")}
|
||||
>${t("skills.project")} <span class="ct">${data.project.length}</span></span>
|
||||
<span
|
||||
class=${`chip-f ${scopeFilter === "custom" ? "active" : ""}`}
|
||||
onClick=${() => setScopeFilter("custom")}
|
||||
>${t("skills.custom")} <span class="ct">${data.custom.length}</span></span>
|
||||
<span
|
||||
class=${`chip-f ${scopeFilter === "global" ? "active" : ""}`}
|
||||
onClick=${() => setScopeFilter("global")}
|
||||
>${t("skills.global")} <span class="ct">${data.global.length}</span></span>
|
||||
<span
|
||||
class=${`chip-f ${scopeFilter === "builtin" ? "active" : ""}`}
|
||||
onClick=${() => setScopeFilter("builtin")}
|
||||
>${t("skills.builtin")} <span class="ct">${data.builtin.length}</span></span>
|
||||
</div>
|
||||
|
||||
<div style="padding:0 12px 8px;display:flex;gap:6px;flex-wrap:wrap">
|
||||
<select
|
||||
value=${newScope}
|
||||
onChange=${(e: Event) => setNewScope((e.target as HTMLSelectElement).value as "global" | "project")}
|
||||
style="flex:0 0 auto;font-size:11.5px;padding:5px 6px"
|
||||
>
|
||||
<option value="global">${t("skills.global")}</option>
|
||||
${data.paths.project ? html`<option value="project">${t("skills.project")}</option>` : null}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
placeholder=${t("skills.newSkill")}
|
||||
value=${newName}
|
||||
onInput=${(e: Event) => setNewName((e.target as HTMLInputElement).value)}
|
||||
style="flex:1;min-width:0"
|
||||
/>
|
||||
<button class="btn primary" disabled=${busy || !newName.trim()} onClick=${create} style="flex:0 0 auto">+</button>
|
||||
</div>
|
||||
|
||||
<div class="ssl-rows">
|
||||
${filtered.map((s) => {
|
||||
const sel = open?.scope === s.scope && open?.name === s.name;
|
||||
return html`
|
||||
<div
|
||||
class=${`ssl-row ${sel ? "sel" : ""}`}
|
||||
onClick=${() => openSkill(s.scope, s.name)}
|
||||
>
|
||||
<span class="name">
|
||||
${s.name}
|
||||
${s.scope === "builtin" ? html`<span class="pill">${t("skills.builtin")}</span>` : null}
|
||||
${s.scope === "custom" ? html`<span class="pill">${t("skills.custom")}</span>` : null}
|
||||
</span>
|
||||
<span class="preview">${s.description ?? t("skills.noDescription")}</span>
|
||||
<span class="meta">
|
||||
${
|
||||
typeof s.runs7d === "number" && s.runs7d > 0
|
||||
? html`<span><span class="v">${s.runs7d}</span> ${t("skills.runs7d")}</span>`
|
||||
: null
|
||||
}
|
||||
<span class="dim">${s.scope}</span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sessions-detail">
|
||||
${
|
||||
open == null
|
||||
? html`<div style="color:var(--fg-3);font-size:13px;text-align:center;padding:60px 20px">
|
||||
${t("skills.pickHint")}
|
||||
</div>`
|
||||
: open.scope === "builtin"
|
||||
? (() => {
|
||||
const builtin = data.builtin.find((b) => b.name === open.name);
|
||||
return html`
|
||||
<div class="sessions-detail-h">
|
||||
<span class="name">${open.scope}/${open.name}</span>
|
||||
<span class="ws"><span class="pill">${t("skills.readOnlyBuiltin")}</span></span>
|
||||
<span class="actions">
|
||||
<button class="btn ghost" onClick=${() => setOpen(null)}>${t("common.back")}</button>
|
||||
</span>
|
||||
</div>
|
||||
<div style="color:var(--fg-2);font-size:13px;line-height:1.6">
|
||||
${builtin?.description ?? t("skills.noDescription")}
|
||||
</div>
|
||||
<div style="margin-top:14px;color:var(--fg-3);font-size:11.5px">
|
||||
${t("skills.builtinDesc")}
|
||||
</div>
|
||||
`;
|
||||
})()
|
||||
: open.scope === "custom"
|
||||
? (() => {
|
||||
const custom = data.custom.find((b) => b.name === open.name);
|
||||
return html`
|
||||
<div class="sessions-detail-h">
|
||||
<span class="name">${open.scope}/${open.name}</span>
|
||||
<span class="ws"><span class="pill">${t("skills.readOnlyCustom")}</span></span>
|
||||
<span class="actions">
|
||||
<button class="btn ghost" onClick=${() => setOpen(null)}>${t("common.back")}</button>
|
||||
</span>
|
||||
</div>
|
||||
<div style="color:var(--fg-2);font-size:13px;line-height:1.6">
|
||||
${custom?.description ?? t("skills.noDescription")}
|
||||
</div>
|
||||
<div style="margin-top:14px;color:var(--fg-3);font-size:11.5px;font-family:var(--font-mono)">
|
||||
${data.paths.custom?.map((p) => html`<div>${p.status} · ${p.dir}</div>`)}
|
||||
</div>
|
||||
`;
|
||||
})()
|
||||
: html`
|
||||
<div class="sessions-detail-h">
|
||||
<span class="name">${open.scope}/${open.name}</span>
|
||||
<span class="ws">${body.length.toLocaleString()} chars</span>
|
||||
<span class="actions">
|
||||
<button class="btn primary" disabled=${busy} onClick=${save}>${t("common.save")}</button>
|
||||
<button class="btn" disabled=${busy} onClick=${remove}
|
||||
style="border-color:var(--c-err);color:var(--c-err)">${t("common.delete")}</button>
|
||||
<button class="btn ghost" onClick=${() => setOpen(null)}>${t("common.back")}</button>
|
||||
</span>
|
||||
</div>
|
||||
${info ? html`<div style="margin-bottom:8px"><span class="pill ok">${info}</span></div>` : null}
|
||||
${error ? html`<div class="card accent-err" style="margin-bottom:8px">${error}</div>` : null}
|
||||
<textarea
|
||||
style="width:100%;min-height:520px;background:var(--bg-input);color:var(--fg-0);border:1px solid var(--bd);border-radius:var(--r);padding:12px;font-family:var(--font-mono);font-size:13px;line-height:1.55;resize:vertical"
|
||||
value=${body}
|
||||
onInput=${(e: Event) => setBody((e.target as HTMLTextAreaElement).value)}
|
||||
disabled=${busy}
|
||||
></textarea>
|
||||
<div style="margin-top:8px;color:var(--fg-3);font-size:11.5px">
|
||||
${t("skills.reloadHint")}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import { fmtBytes, fmtNum } from "../lib/format.js";
|
||||
import { html } from "../lib/html.js";
|
||||
import { usePoll } from "../lib/use-poll.js";
|
||||
import { compareVersions } from "../lib/version.js";
|
||||
import { t, useLang } from "../i18n/index.js";
|
||||
|
||||
interface HealthData {
|
||||
version: string;
|
||||
latestVersion: string | null;
|
||||
sessions: { count: number; totalBytes: number; path: string };
|
||||
memory: { fileCount: number; totalBytes: number; path: string };
|
||||
semantic: { exists: boolean; fileCount?: number; totalBytes?: number; path: string };
|
||||
usageLog: { bytes: number; path: string };
|
||||
jobs: number | null;
|
||||
reasonixHome: string;
|
||||
}
|
||||
|
||||
export function SystemPanel() {
|
||||
useLang();
|
||||
const { data, error, loading } = usePoll<HealthData>("/health", 5000);
|
||||
if (loading && !data)
|
||||
return html`<div class="card" style="color:var(--fg-3)">${t("system.loading")}</div>`;
|
||||
if (error) return html`<div class="card accent-err">${t("common.loadingFailed", { name: "health", error: error.message })}</div>`;
|
||||
if (!data) return null;
|
||||
const h = data;
|
||||
const upToDate = h.latestVersion ? compareVersions(h.version, h.latestVersion) >= 0 : null;
|
||||
|
||||
return html`
|
||||
<div style="display:flex;flex-direction:column;gap:14px">
|
||||
<h3 style="margin:0;font-family:var(--font-mono);font-size:11px;color:var(--fg-3);text-transform:uppercase;letter-spacing:.1em">${t("system.healthChecks")}</h3>
|
||||
<div class="health-grid">
|
||||
<div class=${`health-item ${upToDate === false ? "warn" : ""}`}>
|
||||
<div class="lbl">
|
||||
${t("system.version")}
|
||||
${
|
||||
upToDate === null
|
||||
? html`<span class="pill">${t("system.checking")}</span>`
|
||||
: upToDate
|
||||
? html`<span class="pill ok">${t("system.latest")}</span>`
|
||||
: html`<span class="pill warn">${t("system.outOfDate")}</span>`
|
||||
}
|
||||
</div>
|
||||
<div class="v">${h.version}</div>
|
||||
<div class="meta">${
|
||||
upToDate === null
|
||||
? t("system.versionPending")
|
||||
: upToDate
|
||||
? t("system.upToDate")
|
||||
: t("system.latestVer", { version: h.latestVersion ?? "" })
|
||||
}</div>
|
||||
</div>
|
||||
|
||||
<div class="health-item">
|
||||
<div class="lbl">${t("system.sessions")} <span class="pill ok">${t("system.ok")}</span></div>
|
||||
<div class="v">${fmtBytes(h.sessions.totalBytes)}</div>
|
||||
<div class="meta">${fmtNum(h.sessions.count)} ${t("system.files")}</div>
|
||||
</div>
|
||||
|
||||
<div class="health-item">
|
||||
<div class="lbl">${t("system.memory")} <span class="pill ok">${t("system.ok")}</span></div>
|
||||
<div class="v">${fmtBytes(h.memory.totalBytes)}</div>
|
||||
<div class="meta">${fmtNum(h.memory.fileCount)} ${t("system.files")}</div>
|
||||
</div>
|
||||
|
||||
<div class="health-item">
|
||||
<div class="lbl">
|
||||
${t("system.semanticIndex")}
|
||||
${
|
||||
h.semantic.exists
|
||||
? html`<span class="pill ok">${t("system.built")}</span>`
|
||||
: html`<span class="pill">${t("system.none")}</span>`
|
||||
}
|
||||
</div>
|
||||
<div class="v">${h.semantic.exists ? fmtBytes(h.semantic.totalBytes) : "—"}</div>
|
||||
<div class="meta">
|
||||
${h.semantic.exists ? `${fmtNum(h.semantic.fileCount)} ${t("system.files")}` : t("system.runIndex")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="health-item">
|
||||
<div class="lbl">${t("system.usageLog")} <span class="pill ok">${t("system.ok")}</span></div>
|
||||
<div class="v">${fmtBytes(h.usageLog.bytes)}</div>
|
||||
<div class="meta">~/.reasonix/usage.jsonl</div>
|
||||
</div>
|
||||
|
||||
<div class="health-item">
|
||||
<div class="lbl">
|
||||
${t("system.backgroundJobs")}
|
||||
${
|
||||
h.jobs === null
|
||||
? html`<span class="pill">${t("system.noSession")}</span>`
|
||||
: html`<span class="pill ok">● ${fmtNum(h.jobs)}</span>`
|
||||
}
|
||||
</div>
|
||||
<div class="v">${h.jobs === null ? "—" : t("system.running", { count: fmtNum(h.jobs) })}</div>
|
||||
<div class="meta">${h.jobs === null ? t("system.attachHint") : t("system.shellSpawn")}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding:0">
|
||||
<div class="card-h" style="padding:12px 14px 6px">
|
||||
<span class="title">${t("system.paths")}</span>
|
||||
</div>
|
||||
<table class="tbl">
|
||||
<tbody style="font-size:11.5px">
|
||||
<tr><td class="dim" style="padding:5px 14px">${t("system.home")}</td><td class="path">${h.reasonixHome}</td></tr>
|
||||
<tr><td class="dim" style="padding:5px 14px">${t("system.sessionsPath")}</td><td class="path">${h.sessions.path}</td></tr>
|
||||
<tr><td class="dim" style="padding:5px 14px">${t("system.memoryPath")}</td><td class="path">${h.memory.path}</td></tr>
|
||||
<tr><td class="dim" style="padding:5px 14px">${t("system.semanticPath")}</td><td class="path">${h.semantic.path}</td></tr>
|
||||
<tr><td class="dim" style="padding:5px 14px">${t("system.usagePath")}</td><td class="path">${h.usageLog.path}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { html } from "../lib/html.js";
|
||||
import { usePoll } from "../lib/use-poll.js";
|
||||
import { t, useLang } from "../i18n/index.js";
|
||||
|
||||
interface ToolEntry {
|
||||
name: string;
|
||||
description?: string;
|
||||
readOnly?: boolean;
|
||||
flattened?: boolean;
|
||||
}
|
||||
|
||||
interface ToolsData {
|
||||
total: number;
|
||||
planMode?: boolean;
|
||||
tools: ToolEntry[];
|
||||
}
|
||||
|
||||
interface ToolsError {
|
||||
status?: number;
|
||||
message: string;
|
||||
body?: { error?: string };
|
||||
}
|
||||
|
||||
function toolDesc(name: string, fallback: string): string {
|
||||
const key = `tools.desc.${name}`;
|
||||
const translated = t(key);
|
||||
return translated === key ? fallback : translated;
|
||||
}
|
||||
|
||||
export function ToolsPanel() {
|
||||
useLang();
|
||||
const { data, error, loading } = usePoll<ToolsData>("/tools", 4000);
|
||||
if (loading && !data)
|
||||
return html`<div class="card" style="color:var(--fg-3)">${t("tools.loading")}</div>`;
|
||||
const e = error as ToolsError | null;
|
||||
if (e?.status === 503) {
|
||||
return html`<div class="card accent-warn">${e.body?.error ?? t("common.loadingFailed", { name: "tools", error: "" })}</div>`;
|
||||
}
|
||||
if (e) return html`<div class="card accent-err">${t("common.loadingFailed", { name: "tools", error: e.message })}</div>`;
|
||||
if (!data) return null;
|
||||
const d = data;
|
||||
|
||||
return html`
|
||||
<div style="display:flex;flex-direction:column;gap:14px">
|
||||
<div class="chips">
|
||||
<span class="chip-f static active">${t("common.all")} <span class="ct">${d.total}</span></span>
|
||||
${d.planMode ? html`<span class="chip-f static" style="border-color:var(--c-warn);color:var(--c-warn)">${t("tools.planMode")}</span>` : null}
|
||||
</div>
|
||||
|
||||
${
|
||||
d.tools.length === 0
|
||||
? html`<div class="card" style="color:var(--fg-3)">${t("tools.noTools")}</div>`
|
||||
: html`
|
||||
<div class="card" style="padding:0;overflow:hidden">
|
||||
<table class="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>${t("tools.colTool")}</th>
|
||||
<th>${t("tools.colFlags")}</th>
|
||||
<th>${t("tools.colDesc")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${d.tools.map(
|
||||
(tool) => html`
|
||||
<tr>
|
||||
<td><code class="mono">${tool.name}</code></td>
|
||||
<td>
|
||||
${tool.readOnly
|
||||
? html`<span class="pill ok">${t("tools.readOnly")}</span>`
|
||||
: html`<span class="pill acc">${t("tools.write")}</span>`}
|
||||
${tool.flattened ? html` <span class="pill">${t("tools.flat")}</span>` : null}
|
||||
</td>
|
||||
<td class="dim">${toolDesc(tool.name, tool.description ?? "")}</td>
|
||||
</tr>
|
||||
`,
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import { api } from "../lib/api.js";
|
||||
import { fmtNum, fmtPct, fmtUsd } from "../lib/format.js";
|
||||
import { html } from "../lib/html.js";
|
||||
import { usePoll } from "../lib/use-poll.js";
|
||||
import { t, useLang } from "../i18n/index.js";
|
||||
|
||||
type UPlotInstance = {
|
||||
destroy(): void;
|
||||
setSize(opts: { width: number; height: number }): void;
|
||||
};
|
||||
|
||||
type UPlotConstructor = new (
|
||||
opts: unknown,
|
||||
data: unknown,
|
||||
el: HTMLElement,
|
||||
) => UPlotInstance;
|
||||
|
||||
let uPlotPromise: Promise<UPlotConstructor> | null = null;
|
||||
function loadUPlot(): Promise<UPlotConstructor> {
|
||||
if (!uPlotPromise) {
|
||||
uPlotPromise = import("uplot").then(
|
||||
(m) => (m.default ?? m) as UPlotConstructor,
|
||||
);
|
||||
}
|
||||
return uPlotPromise;
|
||||
}
|
||||
|
||||
interface UsageDay {
|
||||
day: string;
|
||||
costUsd: number;
|
||||
cacheSavingsUsd: number;
|
||||
turns: number;
|
||||
}
|
||||
|
||||
function UsageChart({ days }: { days: UsageDay[] }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const plotRef = useRef<UPlotInstance | null>(null);
|
||||
useLang();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
loadUPlot().then((uPlot) => {
|
||||
if (cancelled || !containerRef.current) return;
|
||||
if (plotRef.current) {
|
||||
plotRef.current.destroy();
|
||||
plotRef.current = null;
|
||||
}
|
||||
if (!days || days.length === 0) return;
|
||||
const xs = days.map((d) => Math.floor(Date.parse(d.day) / 1000));
|
||||
const cost = days.map((d) => d.costUsd);
|
||||
const saved = days.map((d) => d.cacheSavingsUsd);
|
||||
const turns = days.map((d) => d.turns);
|
||||
const data = [xs, cost, saved, turns];
|
||||
const opts = {
|
||||
width: containerRef.current.clientWidth,
|
||||
height: 280,
|
||||
cursor: { drag: { x: true, y: false } },
|
||||
scales: {
|
||||
x: { time: true },
|
||||
y: { auto: true },
|
||||
turns: { auto: true },
|
||||
},
|
||||
axes: [
|
||||
{ stroke: "#94a3b8", grid: { stroke: "rgba(148, 163, 184, 0.08)" } },
|
||||
{
|
||||
scale: "y",
|
||||
label: t("usage.axisUsd"),
|
||||
stroke: "#94a3b8",
|
||||
grid: { stroke: "rgba(148, 163, 184, 0.08)" },
|
||||
values: (_u: unknown, v: number[]) => v.map((n) => `$${n.toFixed(4)}`),
|
||||
},
|
||||
{
|
||||
scale: "turns",
|
||||
side: 1,
|
||||
label: t("usage.axisTurns"),
|
||||
stroke: "#94a3b8",
|
||||
grid: { show: false },
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{ label: t("usage.axisTime") },
|
||||
{
|
||||
label: t("usage.seriesCost"),
|
||||
stroke: "#67e8f9",
|
||||
width: 2,
|
||||
fill: "rgba(103, 232, 249, 0.10)",
|
||||
},
|
||||
{ label: t("usage.seriesCacheSaved"), stroke: "#5eead4", width: 2, dash: [4, 4] },
|
||||
{
|
||||
label: t("usage.seriesTurns"),
|
||||
stroke: "#c4b5fd",
|
||||
scale: "turns",
|
||||
width: 1.5,
|
||||
points: { show: true, size: 4 },
|
||||
},
|
||||
],
|
||||
legend: { live: true },
|
||||
};
|
||||
plotRef.current = new uPlot(opts, data, containerRef.current);
|
||||
});
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (plotRef.current && containerRef.current) {
|
||||
plotRef.current.setSize({ width: containerRef.current.clientWidth, height: 280 });
|
||||
}
|
||||
});
|
||||
if (containerRef.current) ro.observe(containerRef.current);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
ro.disconnect();
|
||||
if (plotRef.current) {
|
||||
plotRef.current.destroy();
|
||||
plotRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [days]);
|
||||
|
||||
return html`<div ref=${containerRef} style="width: 100%; min-height: 280px;"></div>`;
|
||||
}
|
||||
|
||||
interface Bucket {
|
||||
label: string;
|
||||
turns: number;
|
||||
cacheHitTokens: number;
|
||||
cacheMissTokens: number;
|
||||
costUsd: number;
|
||||
cacheSavingsUsd: number;
|
||||
claudeEquivUsd: number;
|
||||
}
|
||||
|
||||
interface UsageSummary {
|
||||
recordCount: number;
|
||||
logSize: string;
|
||||
buckets: Bucket[];
|
||||
byModel: { model: string; turns: number }[];
|
||||
subagents?: { total: number; costUsd: number; totalDurationMs: number };
|
||||
}
|
||||
|
||||
export function UsagePanel() {
|
||||
useLang();
|
||||
const { data: summary, error, loading } = usePoll<UsageSummary>("/usage", 5000);
|
||||
const [series, setSeries] = useState<UsageDay[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const s = await api<{ days?: UsageDay[] }>("/usage/series");
|
||||
if (!cancelled) setSeries(s.days ?? []);
|
||||
} catch {
|
||||
/* keep null; chart hides */
|
||||
}
|
||||
})();
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const s = await api<{ days?: UsageDay[] }>("/usage/series");
|
||||
if (!cancelled) setSeries(s.days ?? []);
|
||||
} catch {
|
||||
/* swallow */
|
||||
}
|
||||
}, 30_000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (loading && !summary)
|
||||
return html`<div class="card" style="color:var(--fg-3)">${t("usage.loading")}</div>`;
|
||||
if (error) return html`<div class="card accent-err">${t("common.loadingFailed", { name: "usage", error: error.message })}</div>`;
|
||||
if (!summary) return null;
|
||||
const u = summary;
|
||||
|
||||
const sectionH3 = (text: string) => html`
|
||||
<h3 style="margin:18px 0 8px;font-family:var(--font-mono);font-size:11px;color:var(--fg-3);text-transform:uppercase;letter-spacing:.1em">${text}</h3>
|
||||
`;
|
||||
|
||||
return html`
|
||||
<div style="display:flex;flex-direction:column;gap:6px">
|
||||
<div class="chips">
|
||||
<span class="chip-f static active">${t("usage.records", { count: u.recordCount.toLocaleString() })}</span>
|
||||
<span class="chip-f static">${u.logSize}</span>
|
||||
</div>
|
||||
|
||||
${
|
||||
series && series.length > 0
|
||||
? html`
|
||||
<div class="card" style="padding:18px">
|
||||
<div class="card-h">
|
||||
<span class="title">${t("usage.dailyUsage")}</span>
|
||||
<span class="meta">${t("usage.dailyMeta")}</span>
|
||||
</div>
|
||||
<${UsageChart} days=${series} />
|
||||
</div>
|
||||
`
|
||||
: null
|
||||
}
|
||||
|
||||
${
|
||||
u.recordCount === 0
|
||||
? html`<div class="card" style="color:var(--fg-3);margin-top:8px">
|
||||
${t("usage.noData")}
|
||||
</div>`
|
||||
: html`
|
||||
${sectionH3(t("usage.windows"))}
|
||||
<div class="card" style="padding:0;overflow:hidden">
|
||||
<table class="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>${t("usage.colWindow")}</th>
|
||||
<th class="num">${t("usage.colTurns")}</th>
|
||||
<th class="num">${t("usage.colCacheHit")}</th>
|
||||
<th class="num">${t("usage.colCost")}</th>
|
||||
<th class="num">${t("usage.colCacheSaved")}</th>
|
||||
<th class="num">${t("usage.colVsClaude")}</th>
|
||||
<th class="num">${t("usage.colSaved")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${u.buckets.map((b) => {
|
||||
const hitRatio =
|
||||
b.cacheHitTokens + b.cacheMissTokens > 0
|
||||
? b.cacheHitTokens / (b.cacheHitTokens + b.cacheMissTokens)
|
||||
: 0;
|
||||
const claudeSavings =
|
||||
b.claudeEquivUsd > 0 ? 1 - b.costUsd / b.claudeEquivUsd : 0;
|
||||
return html`
|
||||
<tr>
|
||||
<td class="dim">${b.label}</td>
|
||||
<td class="num">${fmtNum(b.turns)}</td>
|
||||
<td class="num">${b.turns > 0 ? fmtPct(hitRatio) : "—"}</td>
|
||||
<td class="num">${b.turns > 0 ? fmtUsd(b.costUsd) : "—"}</td>
|
||||
<td class="num">${b.turns > 0 && b.cacheSavingsUsd > 0 ? fmtUsd(b.cacheSavingsUsd) : "—"}</td>
|
||||
<td class="num">${b.turns > 0 ? fmtUsd(b.claudeEquivUsd) : "—"}</td>
|
||||
<td class="num">${b.turns > 0 && claudeSavings > 0 ? fmtPct(claudeSavings) : "—"}</td>
|
||||
</tr>
|
||||
`;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
${
|
||||
u.byModel.length > 0
|
||||
? html`
|
||||
${sectionH3(t("usage.mostUsed"))}
|
||||
<div class="card" style="padding:0;overflow:hidden">
|
||||
<table class="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>${t("usage.colModel")}</th>
|
||||
<th>${t("usage.colTurns")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${u.byModel.slice(0, 5).map(
|
||||
(m) => html`
|
||||
<tr>
|
||||
<td><code class="mono">${m.model}</code></td>
|
||||
<td class="num">${fmtNum(m.turns)}</td>
|
||||
</tr>
|
||||
`,
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
export type ReadyEvent = { type: "$ready" };
|
||||
export type ProtocolErrorEvent = { type: "$error"; message: string };
|
||||
export type TurnCompleteEvent = { type: "$turn_complete" };
|
||||
export type PathAccessRequiredEvent = {
|
||||
type: "$path_access_required";
|
||||
id: number;
|
||||
path: string;
|
||||
intent: "read" | "write";
|
||||
toolName: string;
|
||||
sandboxRoot: string;
|
||||
allowPrefix: string;
|
||||
};
|
||||
|
||||
export type ConfirmRequiredEvent = {
|
||||
type: "$confirm_required";
|
||||
id: number;
|
||||
kind: "run_command" | "run_background";
|
||||
command: string;
|
||||
};
|
||||
|
||||
export type ConfirmationChoice =
|
||||
| { type: "deny"; denyContext?: string }
|
||||
| { type: "run_once" }
|
||||
| { type: "always_allow"; prefix: string };
|
||||
|
||||
export type ChoiceOption = {
|
||||
id: string;
|
||||
title: string;
|
||||
summary?: string;
|
||||
};
|
||||
|
||||
export type ChoiceRequiredEvent = {
|
||||
type: "$choice_required";
|
||||
id: number;
|
||||
question: string;
|
||||
options: ChoiceOption[];
|
||||
allowCustom: boolean;
|
||||
};
|
||||
|
||||
export type ChoiceVerdict =
|
||||
| { type: "pick"; optionId: string }
|
||||
| { type: "text"; text: string }
|
||||
| { type: "cancel" };
|
||||
|
||||
export type PlanRequiredEvent = {
|
||||
type: "$plan_required";
|
||||
id: number;
|
||||
plan: string;
|
||||
steps?: unknown[];
|
||||
summary?: string;
|
||||
};
|
||||
|
||||
export type PlanVerdict =
|
||||
| { type: "approve"; feedback?: string }
|
||||
| { type: "refine"; feedback?: string }
|
||||
| { type: "cancel"; feedback?: string };
|
||||
|
||||
export type PlanStep = {
|
||||
id: string;
|
||||
title: string;
|
||||
action: string;
|
||||
risk?: "low" | "med" | "high";
|
||||
};
|
||||
|
||||
export type CheckpointRequiredEvent = {
|
||||
type: "$checkpoint_required";
|
||||
id: number;
|
||||
stepId: string;
|
||||
title?: string;
|
||||
result: string;
|
||||
notes?: string;
|
||||
completed: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type CheckpointVerdict =
|
||||
| { type: "continue" }
|
||||
| { type: "revise"; feedback?: string }
|
||||
| { type: "stop" };
|
||||
|
||||
export type RevisionRequiredEvent = {
|
||||
type: "$revision_required";
|
||||
id: number;
|
||||
reason: string;
|
||||
remainingSteps: PlanStep[];
|
||||
summary?: string;
|
||||
};
|
||||
|
||||
export type RevisionVerdict = { type: "accepted" } | { type: "rejected" } | { type: "cancelled" };
|
||||
|
||||
export type ModalKind =
|
||||
| "shell"
|
||||
| "path"
|
||||
| "choice"
|
||||
| "plan"
|
||||
| "checkpoint"
|
||||
| "revision"
|
||||
| "edit-review";
|
||||
|
||||
export type ModalDismissedEvent = { type: "$modal_dismissed"; kind: ModalKind };
|
||||
|
||||
export type StepCompletedEvent = {
|
||||
type: "$step_completed";
|
||||
stepId: string;
|
||||
title?: string;
|
||||
result: string;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
export type PlanClearedEvent = { type: "$plan_cleared" };
|
||||
|
||||
export type SessionsEvent = {
|
||||
type: "$sessions";
|
||||
currentSession?: string | null;
|
||||
items: {
|
||||
name: string;
|
||||
messageCount: number;
|
||||
mtime: string;
|
||||
summary?: string;
|
||||
workspaceStatus?: "matched" | "legacy_missing_meta";
|
||||
}[];
|
||||
};
|
||||
|
||||
export type SessionUsageEvent = {
|
||||
type: "$session_usage";
|
||||
totalCostUsd: number;
|
||||
totalPromptTokens: number;
|
||||
totalCompletionTokens: number;
|
||||
cacheHitTokens: number;
|
||||
cacheMissTokens: number;
|
||||
};
|
||||
|
||||
export type MentionResultsEvent = {
|
||||
type: "$mention_results";
|
||||
nonce: number;
|
||||
query: string;
|
||||
results: string[];
|
||||
};
|
||||
|
||||
export type MentionPreviewEvent = {
|
||||
type: "$mention_preview";
|
||||
nonce: number;
|
||||
path: string;
|
||||
head: string;
|
||||
totalLines: number;
|
||||
};
|
||||
|
||||
export type TabOpenedEvent = {
|
||||
type: "$tab_opened";
|
||||
workspaceDir: string;
|
||||
/** True when the frontend should focus this tab (user-opened, or the restored focused tab). */
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
export type TabClosedEvent = {
|
||||
type: "$tab_closed";
|
||||
};
|
||||
|
||||
export type McpSpecStatus = "configured" | "handshake" | "connected" | "failed" | "disabled";
|
||||
|
||||
export type McpSpecInfo = {
|
||||
raw: string;
|
||||
name: string | null;
|
||||
transport: "stdio" | "sse" | "streamable-http";
|
||||
summary: string;
|
||||
parseError?: string;
|
||||
status: McpSpecStatus;
|
||||
statusReason?: string;
|
||||
toolCount?: number;
|
||||
};
|
||||
|
||||
export type McpSpecsEvent = {
|
||||
type: "$mcp_specs";
|
||||
specs: McpSpecInfo[];
|
||||
bridged: boolean;
|
||||
};
|
||||
|
||||
export type SkillScope = "project" | "global" | "builtin";
|
||||
|
||||
export type SkillInfo = {
|
||||
name: string;
|
||||
description: string;
|
||||
scope: SkillScope;
|
||||
path: string;
|
||||
runAs: "inline" | "subagent";
|
||||
model?: string;
|
||||
};
|
||||
|
||||
export type SkillsEvent = {
|
||||
type: "$skills";
|
||||
items: SkillInfo[];
|
||||
};
|
||||
|
||||
export type CtxBreakdownEvent = {
|
||||
type: "$ctx_breakdown";
|
||||
reservedTokens: number;
|
||||
};
|
||||
|
||||
export type MemoryEntryInfo = {
|
||||
kind: "project_file" | "global_file" | "structured";
|
||||
name: string;
|
||||
scope: "project" | "global";
|
||||
path: string;
|
||||
description: string;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
export type MemoryEvent = {
|
||||
type: "$memory";
|
||||
entries: MemoryEntryInfo[];
|
||||
};
|
||||
|
||||
export type MemoryDetail = MemoryEntryInfo & {
|
||||
body: string;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
export type MemoryDetailEvent = {
|
||||
type: "$memory_detail";
|
||||
detail: MemoryDetail;
|
||||
};
|
||||
|
||||
export type RetryResultEvent = { type: "$retry_result"; text: string };
|
||||
|
||||
export type BtwResultEvent = { type: "$btw_result"; question: string; answer: string };
|
||||
|
||||
export type JobInfo = {
|
||||
id: number;
|
||||
tabId: string;
|
||||
sessionLabel: string;
|
||||
command: string;
|
||||
pid: number | null;
|
||||
running: boolean;
|
||||
exitCode: number | null;
|
||||
startedAt: number;
|
||||
outputTail: string;
|
||||
spawnError?: string;
|
||||
};
|
||||
|
||||
export type JobsEvent = {
|
||||
type: "$jobs";
|
||||
items: JobInfo[];
|
||||
};
|
||||
|
||||
export type LoadedSegment =
|
||||
| { kind: "text"; text: string }
|
||||
| { kind: "reasoning"; text: string }
|
||||
| {
|
||||
kind: "tool";
|
||||
callId: string;
|
||||
name: string;
|
||||
args: string;
|
||||
result?: string;
|
||||
ok?: boolean;
|
||||
};
|
||||
|
||||
export type LoadedMessage =
|
||||
| { kind: "user"; text: string }
|
||||
| {
|
||||
kind: "assistant";
|
||||
turn: number;
|
||||
segments: LoadedSegment[];
|
||||
pending: false;
|
||||
};
|
||||
|
||||
export type SessionLoadedEvent = {
|
||||
type: "$session_loaded";
|
||||
name: string;
|
||||
messages: LoadedMessage[];
|
||||
carryover: {
|
||||
totalCostUsd: number;
|
||||
cacheHitTokens: number;
|
||||
cacheMissTokens: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type SessionEmptyEvent = {
|
||||
type: "$session_empty";
|
||||
name: string;
|
||||
sizeBytes: number;
|
||||
};
|
||||
|
||||
export type NeedsSetupEvent = {
|
||||
type: "$needs_setup";
|
||||
reason: "no_api_key";
|
||||
};
|
||||
|
||||
export type EditMode = "review" | "auto" | "yolo" | "plan";
|
||||
|
||||
export type ReasoningEffort = "low" | "medium" | "high" | "max";
|
||||
|
||||
export type WebSearchEngineName =
|
||||
| "bing"
|
||||
| "bing-intl"
|
||||
| "searxng"
|
||||
| "metaso"
|
||||
| "baidu"
|
||||
| "tavily"
|
||||
| "perplexity"
|
||||
| "exa"
|
||||
| "brave"
|
||||
| "ollama";
|
||||
|
||||
export type SettingsEvent = {
|
||||
type: "$settings";
|
||||
reasoningEffort: ReasoningEffort;
|
||||
editMode: EditMode;
|
||||
budgetUsd: number | null;
|
||||
baseUrl?: string;
|
||||
apiKeyPrefix?: string;
|
||||
workspaceDir: string;
|
||||
recentWorkspaces: string[];
|
||||
model: string;
|
||||
editor?: string;
|
||||
webSearchEngine?: WebSearchEngineName;
|
||||
webSearchApiKeys?: {
|
||||
baidu?: string;
|
||||
};
|
||||
subagentModels?: Record<string, "flash" | "pro">;
|
||||
showSystemEvents?: boolean;
|
||||
version: string;
|
||||
};
|
||||
|
||||
export type QQSettingsEvent = {
|
||||
type: "$qq_settings";
|
||||
appId?: string;
|
||||
appSecret?: string;
|
||||
sandbox: boolean;
|
||||
enabled: boolean;
|
||||
configured: boolean;
|
||||
runtimeState: "disconnected" | "connecting" | "connected" | "failed";
|
||||
lastError?: string;
|
||||
appIdPreview?: string;
|
||||
access: string;
|
||||
};
|
||||
|
||||
export type BalanceEvent = {
|
||||
type: "$balance";
|
||||
currency: string;
|
||||
total: number;
|
||||
isAvailable: boolean;
|
||||
};
|
||||
|
||||
export type SettingsPatch = {
|
||||
reasoningEffort?: ReasoningEffort;
|
||||
editMode?: EditMode;
|
||||
budgetUsd?: number | null;
|
||||
baseUrl?: string;
|
||||
workspaceDir?: string;
|
||||
recentWorkspaces?: string[];
|
||||
model?: string;
|
||||
editor?: string;
|
||||
webSearchEngine?: WebSearchEngineName;
|
||||
baiduApiKey?: string | null;
|
||||
subagentModels?: Record<string, "flash" | "pro">;
|
||||
showSystemEvents?: boolean;
|
||||
};
|
||||
|
||||
export type QQConfigPatch = {
|
||||
appId?: string;
|
||||
appSecret?: string;
|
||||
sandbox: boolean;
|
||||
};
|
||||
|
||||
export type UserMessageEvent = {
|
||||
type: "user.message";
|
||||
id: number;
|
||||
ts: string;
|
||||
turn: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type ModelTurnStartedEvent = {
|
||||
type: "model.turn.started";
|
||||
id: number;
|
||||
ts: string;
|
||||
turn: number;
|
||||
model: string;
|
||||
reasoningEffort: ReasoningEffort;
|
||||
prefixHash: string;
|
||||
};
|
||||
|
||||
export type ModelDeltaEvent = {
|
||||
type: "model.delta";
|
||||
id: number;
|
||||
ts: string;
|
||||
turn: number;
|
||||
channel: "content" | "reasoning" | "tool_args";
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type Usage = {
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
total_tokens?: number;
|
||||
prompt_cache_hit_tokens?: number;
|
||||
prompt_cache_miss_tokens?: number;
|
||||
};
|
||||
|
||||
export type ModelFinalEvent = {
|
||||
type: "model.final";
|
||||
id: number;
|
||||
ts: string;
|
||||
turn: number;
|
||||
content: string;
|
||||
reasoningContent?: string;
|
||||
usage?: Usage;
|
||||
costUsd?: number;
|
||||
};
|
||||
|
||||
export type ToolPreparingEvent = {
|
||||
type: "tool.preparing";
|
||||
id: number;
|
||||
ts: string;
|
||||
turn: number;
|
||||
callId: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type ToolIntentEvent = {
|
||||
type: "tool.intent";
|
||||
id: number;
|
||||
ts: string;
|
||||
turn: number;
|
||||
callId: string;
|
||||
name: string;
|
||||
args: string;
|
||||
};
|
||||
|
||||
export type ToolResultEvent = {
|
||||
type: "tool.result";
|
||||
id: number;
|
||||
ts: string;
|
||||
turn: number;
|
||||
callId: string;
|
||||
ok: boolean;
|
||||
output: string;
|
||||
};
|
||||
|
||||
export type StatusEvent = {
|
||||
type: "status";
|
||||
id: number;
|
||||
ts: string;
|
||||
turn: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type WarningEvent = {
|
||||
type: "warning";
|
||||
id: number;
|
||||
ts: string;
|
||||
turn: number;
|
||||
text: string;
|
||||
severity: "low" | "high";
|
||||
};
|
||||
|
||||
export type KernelErrorEvent = {
|
||||
type: "error";
|
||||
id: number;
|
||||
ts: string;
|
||||
turn: number;
|
||||
message: string;
|
||||
recoverable: boolean;
|
||||
};
|
||||
|
||||
export type IncomingEvent = { tabId?: string } & (
|
||||
| ReadyEvent
|
||||
| ProtocolErrorEvent
|
||||
| TurnCompleteEvent
|
||||
| ConfirmRequiredEvent
|
||||
| PathAccessRequiredEvent
|
||||
| ChoiceRequiredEvent
|
||||
| PlanRequiredEvent
|
||||
| SessionsEvent
|
||||
| SessionUsageEvent
|
||||
| SessionLoadedEvent
|
||||
| SessionEmptyEvent
|
||||
| NeedsSetupEvent
|
||||
| SettingsEvent
|
||||
| QQSettingsEvent
|
||||
| BalanceEvent
|
||||
| CheckpointRequiredEvent
|
||||
| RevisionRequiredEvent
|
||||
| ModalDismissedEvent
|
||||
| StepCompletedEvent
|
||||
| PlanClearedEvent
|
||||
| MentionResultsEvent
|
||||
| MentionPreviewEvent
|
||||
| TabOpenedEvent
|
||||
| TabClosedEvent
|
||||
| McpSpecsEvent
|
||||
| SkillsEvent
|
||||
| CtxBreakdownEvent
|
||||
| MemoryEvent
|
||||
| MemoryDetailEvent
|
||||
| JobsEvent
|
||||
| UserMessageEvent
|
||||
| ModelTurnStartedEvent
|
||||
| ModelDeltaEvent
|
||||
| ModelFinalEvent
|
||||
| ToolPreparingEvent
|
||||
| ToolIntentEvent
|
||||
| ToolResultEvent
|
||||
| StatusEvent
|
||||
| WarningEvent
|
||||
| KernelErrorEvent
|
||||
| RetryResultEvent
|
||||
| BtwResultEvent
|
||||
);
|
||||
|
||||
export type OutgoingCommand = { tabId?: string } & (
|
||||
| { cmd: "user_input"; text: string }
|
||||
| { cmd: "abort" }
|
||||
| { cmd: "confirm_response"; id: number; response: ConfirmationChoice; kind: "shell" | "path" }
|
||||
| { cmd: "choice_response"; id: number; response: ChoiceVerdict }
|
||||
| { cmd: "plan_response"; id: number; response: PlanVerdict }
|
||||
| { cmd: "checkpoint_response"; id: number; response: CheckpointVerdict }
|
||||
| { cmd: "revision_response"; id: number; response: RevisionVerdict }
|
||||
| { cmd: "session_list" }
|
||||
| { cmd: "session_delete"; name: string }
|
||||
| { cmd: "session_load"; name: string }
|
||||
| { cmd: "memory_read"; path: string }
|
||||
| { cmd: "new_chat" }
|
||||
| { cmd: "setup_save_key"; key: string }
|
||||
| { cmd: "settings_get" }
|
||||
| ({ cmd: "settings_save" } & SettingsPatch)
|
||||
| { cmd: "qq_status_get" }
|
||||
| { cmd: "qq_connect" }
|
||||
| { cmd: "qq_disconnect" }
|
||||
| ({ cmd: "qq_config_save" } & QQConfigPatch)
|
||||
| { cmd: "mention_query"; query: string; nonce: number }
|
||||
| { cmd: "mention_preview"; path: string; nonce: number }
|
||||
| { cmd: "mention_picked"; path: string }
|
||||
| { cmd: "tab_open"; workspaceDir?: string }
|
||||
| { cmd: "tab_close" }
|
||||
| { cmd: "tab_activate"; tabId: string }
|
||||
| { cmd: "mcp_specs_get" }
|
||||
| { cmd: "mcp_specs_add"; spec: string }
|
||||
| { cmd: "mcp_specs_remove"; spec: string }
|
||||
| { cmd: "skills_get" }
|
||||
| { cmd: "skill_run"; name: string; args?: string }
|
||||
| { cmd: "jobs_list" }
|
||||
| { cmd: "jobs_stop"; jobId: number }
|
||||
| { cmd: "jobs_stop_all" }
|
||||
| { cmd: "compact_history" }
|
||||
| { cmd: "retry" }
|
||||
| { cmd: "btw"; text: string }
|
||||
);
|
||||
@@ -0,0 +1,63 @@
|
||||
import { t } from "./i18n";
|
||||
|
||||
export interface QQDesktopSettingsState {
|
||||
appId?: string;
|
||||
appSecret?: string;
|
||||
sandbox: boolean;
|
||||
enabled: boolean;
|
||||
configured: boolean;
|
||||
runtimeState: "disconnected" | "connecting" | "connected" | "failed";
|
||||
lastError?: string;
|
||||
appIdPreview?: string;
|
||||
access: string;
|
||||
}
|
||||
|
||||
export function maskQQAppId(appId: string | undefined): string | undefined {
|
||||
if (!appId) return undefined;
|
||||
return appId.length > 6 ? `${appId.slice(0, 6)}...` : appId;
|
||||
}
|
||||
|
||||
export function getQQConnectIntent(qq: QQDesktopSettingsState): "configure" | "connect" {
|
||||
return qq.configured ? "connect" : "configure";
|
||||
}
|
||||
|
||||
export function getQQStatusLabel(qq: QQDesktopSettingsState): string {
|
||||
switch (qq.runtimeState) {
|
||||
case "connected":
|
||||
return t("settings.qqConnected");
|
||||
case "connecting":
|
||||
return t("settings.qqConnecting");
|
||||
case "failed":
|
||||
return t("settings.qqFailed");
|
||||
default:
|
||||
return t("settings.qqDisconnected");
|
||||
}
|
||||
}
|
||||
|
||||
export function describeQQAccessLabel(access: string): string {
|
||||
const owner = /^owner (.+?)(?:, allowlist (\d+))?$/.exec(access);
|
||||
if (owner) {
|
||||
const openId = owner[1] ?? "unknown";
|
||||
const count = owner[2];
|
||||
if (count) return t("settings.qqAccessOwnerWithAllowlist", { openId, count });
|
||||
return t("settings.qqAccessOwner", { openId });
|
||||
}
|
||||
|
||||
const allowlist = /^allowlist (\d+)$/.exec(access);
|
||||
if (allowlist) return t("settings.qqAccessAllowlist", { count: allowlist[1] ?? "0" });
|
||||
|
||||
const runtime = /^first-sender \(runtime only, (.+)\)$/.exec(access);
|
||||
if (runtime) return t("settings.qqAccessRuntime", { openId: runtime[1] ?? "unknown" });
|
||||
|
||||
return t("settings.qqAccessOpen");
|
||||
}
|
||||
|
||||
export function describeQQRowSummary(qq: QQDesktopSettingsState): string {
|
||||
if (!qq.configured) return t("settings.qqSummaryMissing");
|
||||
const appId = qq.appIdPreview ?? maskQQAppId(qq.appId) ?? "unknown";
|
||||
return t("settings.qqSummaryDetail", {
|
||||
appId: t("settings.qqSummaryAppId", { appId }),
|
||||
environment: qq.sandbox ? t("settings.qqSandbox") : t("settings.qqProduction"),
|
||||
access: describeQQAccessLabel(qq.access),
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,107 @@
|
||||
export const THEME = {
|
||||
DARK: "dark",
|
||||
LIGHT: "light",
|
||||
} as const;
|
||||
|
||||
export type Theme = (typeof THEME)[keyof typeof THEME];
|
||||
|
||||
export function isTheme(value: unknown): value is Theme {
|
||||
return value === THEME.DARK || value === THEME.LIGHT;
|
||||
}
|
||||
|
||||
export const THEME_STYLE = {
|
||||
GRAPHITE: "graphite",
|
||||
EMBER: "ember",
|
||||
AURORA: "aurora",
|
||||
SANDSTONE: "sandstone",
|
||||
PORCELAIN: "porcelain",
|
||||
LINEN: "linen",
|
||||
GLACIER: "glacier",
|
||||
MIDNIGHT: "midnight",
|
||||
} as const;
|
||||
|
||||
export type ThemeStyle = (typeof THEME_STYLE)[keyof typeof THEME_STYLE];
|
||||
|
||||
export const DEFAULT_THEME_STYLE: Record<Theme, ThemeStyle> = {
|
||||
dark: THEME_STYLE.GRAPHITE,
|
||||
light: THEME_STYLE.SANDSTONE,
|
||||
};
|
||||
|
||||
export const THEME_STYLE_THEME: Record<ThemeStyle, Theme> = {
|
||||
graphite: THEME.DARK,
|
||||
ember: THEME.DARK,
|
||||
aurora: THEME.DARK,
|
||||
sandstone: THEME.LIGHT,
|
||||
porcelain: THEME.LIGHT,
|
||||
linen: THEME.LIGHT,
|
||||
glacier: THEME.LIGHT,
|
||||
midnight: THEME.DARK,
|
||||
};
|
||||
|
||||
export const THEME_STYLES = [
|
||||
THEME_STYLE.GRAPHITE,
|
||||
THEME_STYLE.EMBER,
|
||||
THEME_STYLE.AURORA,
|
||||
THEME_STYLE.SANDSTONE,
|
||||
THEME_STYLE.PORCELAIN,
|
||||
THEME_STYLE.LINEN,
|
||||
THEME_STYLE.GLACIER,
|
||||
THEME_STYLE.MIDNIGHT,
|
||||
] as const;
|
||||
|
||||
export function isThemeStyle(value: unknown): value is ThemeStyle {
|
||||
return (
|
||||
value === THEME_STYLE.GRAPHITE ||
|
||||
value === THEME_STYLE.EMBER ||
|
||||
value === THEME_STYLE.AURORA ||
|
||||
value === THEME_STYLE.SANDSTONE ||
|
||||
value === THEME_STYLE.PORCELAIN ||
|
||||
value === THEME_STYLE.LINEN ||
|
||||
value === THEME_STYLE.GLACIER ||
|
||||
value === THEME_STYLE.MIDNIGHT
|
||||
);
|
||||
}
|
||||
|
||||
export function themeForStyle(style: ThemeStyle): Theme {
|
||||
return THEME_STYLE_THEME[style];
|
||||
}
|
||||
|
||||
export function defaultStyleForTheme(theme: Theme): ThemeStyle {
|
||||
return DEFAULT_THEME_STYLE[theme];
|
||||
}
|
||||
|
||||
export const FONT_SCALE = {
|
||||
SMALL: "small",
|
||||
MEDIUM: "medium",
|
||||
LARGE: "large",
|
||||
} as const;
|
||||
|
||||
export type FontScale = (typeof FONT_SCALE)[keyof typeof FONT_SCALE];
|
||||
|
||||
export function isFontScale(value: unknown): value is FontScale {
|
||||
return value === FONT_SCALE.SMALL || value === FONT_SCALE.MEDIUM || value === FONT_SCALE.LARGE;
|
||||
}
|
||||
|
||||
export const FONT_SCALE_ZOOM: Record<FontScale, number> = {
|
||||
small: 0.875,
|
||||
medium: 1.0,
|
||||
large: 1.125,
|
||||
};
|
||||
|
||||
export const FONT_FAMILY = {
|
||||
SANS: "sans",
|
||||
SYSTEM: "system",
|
||||
SERIF: "serif",
|
||||
} as const;
|
||||
|
||||
export type FontFamily = (typeof FONT_FAMILY)[keyof typeof FONT_FAMILY];
|
||||
|
||||
export function isFontFamily(value: unknown): value is FontFamily {
|
||||
return value === FONT_FAMILY.SANS || value === FONT_FAMILY.SYSTEM || value === FONT_FAMILY.SERIF;
|
||||
}
|
||||
|
||||
export const FONT_FAMILY_STACK: Record<FontFamily, string> = {
|
||||
sans: '"Geist", system-ui, sans-serif',
|
||||
system: '-apple-system, system-ui, "Segoe UI", Roboto, sans-serif',
|
||||
serif: 'Georgia, "Times New Roman", serif',
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { t } from "../i18n";
|
||||
import { I } from "../icons";
|
||||
|
||||
const REPO_URL = "https://github.com/esengine/DeepSeek-Reasonix";
|
||||
|
||||
export function AboutModal({ onClose }: { onClose: () => void }) {
|
||||
const openGitHub = () => {
|
||||
void openUrl(REPO_URL).catch(() => undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="about-mask" onClick={onClose}>
|
||||
<div className="about-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button type="button" className="about-close" onClick={onClose} aria-label={t("about.close")}>
|
||||
<I.x size={14} />
|
||||
</button>
|
||||
<div className="about-brand">
|
||||
<div className="about-name">Reasonix</div>
|
||||
<div className="about-tagline">{t("about.tagline")}</div>
|
||||
</div>
|
||||
<div className="about-meta">
|
||||
<div className="about-row">
|
||||
<span className="about-label">{t("about.version")}</span>
|
||||
<code className="about-value">{__APP_VERSION__}</code>
|
||||
</div>
|
||||
<div className="about-row">
|
||||
<span className="about-label">{t("about.repo")}</span>
|
||||
<button type="button" className="about-link" onClick={openGitHub}>
|
||||
<I.link size={12} />
|
||||
<span>esengine/DeepSeek-Reasonix</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,751 @@
|
||||
import { memo, useState, type ReactNode } from "react";
|
||||
import { I } from "../icons";
|
||||
import { Markdown } from "../Markdown";
|
||||
import { t, useLang } from "../i18n";
|
||||
import { Shortcut } from "./shortcut";
|
||||
|
||||
type Tone = "default" | "success" | "warning" | "danger" | "accent" | "violet";
|
||||
|
||||
export function Card({
|
||||
tone = "default",
|
||||
icon,
|
||||
kind,
|
||||
name,
|
||||
meta,
|
||||
defaultOpen = true,
|
||||
compact = false,
|
||||
children,
|
||||
headRight,
|
||||
}: {
|
||||
tone?: Tone;
|
||||
icon: ReactNode;
|
||||
kind: string;
|
||||
name?: ReactNode;
|
||||
meta?: ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
/** Slimmer header — used for thinking / tool-call process cards so they
|
||||
* read as background detail rather than primary content. */
|
||||
compact?: boolean;
|
||||
children: ReactNode;
|
||||
headRight?: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<div className={compact ? "card is-compact" : "card"} data-tone={tone} data-open={open}>
|
||||
<button
|
||||
type="button"
|
||||
className="card-head"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
style={{
|
||||
width: "100%",
|
||||
background: "none",
|
||||
border: "none",
|
||||
textAlign: "left",
|
||||
font: "inherit",
|
||||
color: "inherit",
|
||||
}}
|
||||
>
|
||||
<span className="ico">{icon}</span>
|
||||
<span className="kind">{kind}</span>
|
||||
{name ? <span className="name">{name}</span> : null}
|
||||
<span className="grow" />
|
||||
{meta ? <span className="meta">{meta}</span> : null}
|
||||
{headRight}
|
||||
<span className="chev">
|
||||
<I.chev size={12} />
|
||||
</span>
|
||||
</button>
|
||||
{open ? <div className="card-body">{children}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Plan ----
|
||||
|
||||
export type PlanItem = {
|
||||
id: string | number;
|
||||
status: "todo" | "active" | "done" | "failed" | "blocked" | "skipped";
|
||||
text: string;
|
||||
tool?: string;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
function derivePlanBadge(items: PlanItem[]): { state: "running" | "done" | "failed" | "waiting" | "blocked"; label: string } {
|
||||
if (items.some((x) => x.status === "failed")) return { state: "failed", label: t("planBadge.failed") };
|
||||
if (items.some((x) => x.status === "blocked")) return { state: "blocked", label: t("planBadge.blocked") };
|
||||
if (items.some((x) => x.status === "active")) return { state: "running", label: t("planBadge.running") };
|
||||
if (items.length > 0 && items.every((x) => x.status === "done")) return { state: "done", label: t("planBadge.done") };
|
||||
return { state: "waiting", label: t("planBadge.pending") };
|
||||
}
|
||||
|
||||
function StatusIcon({ state, label }: { state: "running" | "done" | "failed" | "waiting" | "blocked"; label: string }) {
|
||||
switch (state) {
|
||||
case "running":
|
||||
return <span className="spin-meta" role="img" aria-label={label} title={label} />;
|
||||
case "done":
|
||||
return <I.check size={10} style={{ color: "var(--success)" }} aria-label={label} />;
|
||||
case "failed":
|
||||
return <I.x size={10} style={{ color: "var(--danger)" }} aria-label={label} />;
|
||||
case "waiting":
|
||||
return <span className="status-dot warn" role="img" aria-label={label} title={label} />;
|
||||
case "blocked":
|
||||
return <I.slash size={10} style={{ color: "var(--warning)" }} aria-label={label} />;
|
||||
}
|
||||
}
|
||||
|
||||
export function PlanCardView({ items, title }: { items: PlanItem[]; title?: string }) {
|
||||
useLang();
|
||||
const resolvedTitle = title ?? t("cards.planDefaultTitle");
|
||||
const done = items.filter((x) => x.status === "done").length;
|
||||
const badge = derivePlanBadge(items);
|
||||
return (
|
||||
<Card
|
||||
tone="accent"
|
||||
icon={<I.list size={12} />}
|
||||
kind="plan"
|
||||
name={resolvedTitle}
|
||||
meta={
|
||||
<>
|
||||
<span>
|
||||
{done}/{items.length}
|
||||
</span>
|
||||
<StatusIcon state={badge.state} label={badge.label} />
|
||||
<span className="meta-label">{badge.label}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ul className="plan-list" style={{ listStyle: "none", margin: 0, padding: "8px 12px 12px" }}>
|
||||
{items.map((it) => (
|
||||
<li key={it.id} className="plan-item" data-status={it.status}>
|
||||
<span className="ck">{it.status === "done" ? <I.check size={12} /> : null}</span>
|
||||
<div>
|
||||
<div className="text">{it.text}</div>
|
||||
{it.tool || it.note ? (
|
||||
<div className="sub">
|
||||
{it.tool ? <span className="tool">{it.tool}</span> : null}
|
||||
{it.note ? <span>{it.note}</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="stat">{it.status === "active" ? <span className="spin" /> : null}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Reasoning ----
|
||||
|
||||
export function ReasoningCard({
|
||||
text,
|
||||
streaming,
|
||||
tokens,
|
||||
elapsed,
|
||||
model,
|
||||
}: {
|
||||
text: string;
|
||||
streaming: boolean;
|
||||
tokens?: number;
|
||||
elapsed?: string;
|
||||
model?: string;
|
||||
}) {
|
||||
useLang();
|
||||
return (
|
||||
<Card
|
||||
tone="violet"
|
||||
icon={<I.brain size={12} />}
|
||||
kind="reasoning"
|
||||
name={t("cards.reasoningName")}
|
||||
meta={
|
||||
<>
|
||||
{elapsed || tokens ? (
|
||||
<span>
|
||||
{elapsed ?? ""}
|
||||
{elapsed && tokens ? " · " : ""}
|
||||
{tokens ? `${tokens.toLocaleString()} t` : ""}
|
||||
</span>
|
||||
) : null}
|
||||
{streaming ? (
|
||||
<StatusIcon state="running" label={t("cards.streaming")} />
|
||||
) : (
|
||||
<StatusIcon state="done" label={t("cards.reasoningComplete")} />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
defaultOpen={streaming}
|
||||
compact
|
||||
>
|
||||
<div className="reason">
|
||||
<div className="stream">
|
||||
{text.split(/\n\n+/).map((para, i) => (
|
||||
<p
|
||||
key={i}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: para
|
||||
.replace(/`([^`]+)`/g, '<span class="hl">$1</span>')
|
||||
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>"),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{model || tokens !== undefined ? (
|
||||
<div className="meta">
|
||||
{model ? (
|
||||
<span>
|
||||
<span className="k">{t("settings.model")}</span> {model}
|
||||
</span>
|
||||
) : null}
|
||||
{tokens !== undefined ? (
|
||||
<span>
|
||||
<span className="k">{t("statusbar.tokens")}</span> {tokens.toLocaleString()}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Shell ----
|
||||
|
||||
export function ShellCard({
|
||||
command,
|
||||
output,
|
||||
state,
|
||||
durationMs,
|
||||
onApprove,
|
||||
onReject,
|
||||
onAlwaysAllow,
|
||||
}: {
|
||||
command: string;
|
||||
output?: string;
|
||||
state: "await" | "running" | "done" | "failed";
|
||||
durationMs?: number;
|
||||
onApprove?: () => void;
|
||||
onReject?: () => void;
|
||||
onAlwaysAllow?: () => void;
|
||||
}) {
|
||||
useLang();
|
||||
const tone: Tone = state === "failed" ? "danger" : state === "done" ? "success" : "warning";
|
||||
return (
|
||||
<Card
|
||||
tone={tone}
|
||||
icon={<I.terminal size={12} />}
|
||||
kind="shell"
|
||||
name="shell"
|
||||
compact
|
||||
meta={
|
||||
<>
|
||||
{state === "await" ? (
|
||||
<StatusIcon state="waiting" label={t("cards.shellAwaiting")} />
|
||||
) : state === "running" ? (
|
||||
<StatusIcon state="running" label={t("cards.shellRunning")} />
|
||||
) : state === "failed" ? (
|
||||
<StatusIcon state="failed" label={t("cards.failed")} />
|
||||
) : (
|
||||
<StatusIcon state="done" label={t("cards.done")} />
|
||||
)}
|
||||
{(state === "done" || state === "failed") && durationMs ? (
|
||||
<span className="meta-dur">{(durationMs / 1000).toFixed(2)}s</span>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="shell">
|
||||
<div className="cmd">
|
||||
<span className="prompt">$</span>
|
||||
<span className="text">{command}</span>
|
||||
</div>
|
||||
{output ? (
|
||||
<pre className="out">
|
||||
{output.split("\n").map((ln, i) => {
|
||||
if (ln.startsWith(" ✓") || ln.startsWith("✓"))
|
||||
return (
|
||||
<div key={i}>
|
||||
<span className="ok">{ln}</span>
|
||||
</div>
|
||||
);
|
||||
if (ln.startsWith(" ✗") || ln.startsWith("✗") || /error/i.test(ln))
|
||||
return (
|
||||
<div key={i}>
|
||||
<span className="err">{ln}</span>
|
||||
</div>
|
||||
);
|
||||
return <div key={i}>{ln}</div>;
|
||||
})}
|
||||
</pre>
|
||||
) : null}
|
||||
{state === "await" && onApprove ? (
|
||||
<div className="approve-row">
|
||||
<div className="why">
|
||||
<b>{t("cards.shellAwaiting")}</b> — {t("cards.shellExecuteHint")}
|
||||
</div>
|
||||
<div className="actions">
|
||||
{onAlwaysAllow ? (
|
||||
<button type="button" className="btn ghost" onClick={onAlwaysAllow}>
|
||||
{t("cards.shellAlwaysAllow")}
|
||||
</button>
|
||||
) : null}
|
||||
{onReject ? (
|
||||
<button type="button" className="btn" onClick={onReject}>
|
||||
{t("cards.shellReject")} <Shortcut keys={["mod", "."]} />
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="btn primary" onClick={onApprove}>
|
||||
{t("cards.shellRun")} <Shortcut keys={["mod", "enter"]} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Compaction ----
|
||||
|
||||
export function CompactionCard({ summary }: { summary: string }) {
|
||||
useLang();
|
||||
const charCount = summary.length;
|
||||
return (
|
||||
<Card
|
||||
tone="default"
|
||||
icon={<I.archive size={12} />}
|
||||
kind="compaction"
|
||||
name={t("cards.compactionName")}
|
||||
meta={<span>{t("cards.compactionMeta", { chars: charCount.toLocaleString() })}</span>}
|
||||
defaultOpen={false}
|
||||
compact
|
||||
>
|
||||
<div className="compaction-body">
|
||||
<Markdown source={summary} />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Generic Tool ----
|
||||
|
||||
export function ToolCard({
|
||||
name,
|
||||
args,
|
||||
result,
|
||||
ok,
|
||||
durationMs,
|
||||
}: {
|
||||
name: string;
|
||||
args?: string;
|
||||
result?: string;
|
||||
ok?: boolean;
|
||||
durationMs?: number;
|
||||
}) {
|
||||
useLang();
|
||||
const running = result === undefined;
|
||||
const tone: Tone = running ? "default" : ok === false ? "danger" : "success";
|
||||
return (
|
||||
<Card
|
||||
tone={tone}
|
||||
icon={<I.wrench size={12} />}
|
||||
kind="tool"
|
||||
name={name}
|
||||
defaultOpen={false}
|
||||
compact
|
||||
meta={
|
||||
<>
|
||||
{running ? (
|
||||
<StatusIcon state="running" label={t("cards.running")} />
|
||||
) : ok === false ? (
|
||||
<StatusIcon state="failed" label={t("cards.error")} />
|
||||
) : (
|
||||
<StatusIcon state="done" label={t("cards.done")} />
|
||||
)}
|
||||
{!running && durationMs !== undefined ? (
|
||||
<span className="meta-dur">{durationMs} ms</span>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="tool-call">
|
||||
{args ? (
|
||||
<div className="row">
|
||||
<span className="k">args</span>
|
||||
<span className="v">
|
||||
<span className="str">{args.length > 600 ? `${args.slice(0, 600)}…` : args}</span>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{result !== undefined ? (
|
||||
<div className="row">
|
||||
<span className="k">{ok === false ? t("cards.error") : t("cards.result")}</span>
|
||||
<span className="v">
|
||||
<span className={ok === false ? "num" : "str"}>
|
||||
{result.length > 1200 ? `${result.slice(0, 1200)}…` : result}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Diff ----
|
||||
|
||||
export type DiffLine =
|
||||
| { t: "hunk"; s: string }
|
||||
| { t: "ctx"; l?: number; r?: number; s: string }
|
||||
| { t: "add"; r: number; s: string }
|
||||
| { t: "rm"; l: number; s: string };
|
||||
|
||||
export function DiffCard({
|
||||
filename,
|
||||
lines,
|
||||
applied,
|
||||
onApply,
|
||||
onDiscard,
|
||||
}: {
|
||||
filename: string;
|
||||
lines: DiffLine[];
|
||||
applied?: boolean;
|
||||
onApply?: () => void;
|
||||
onDiscard?: () => void;
|
||||
}) {
|
||||
useLang();
|
||||
const adds = lines.filter((x) => x.t === "add").length;
|
||||
const rms = lines.filter((x) => x.t === "rm").length;
|
||||
return (
|
||||
<Card
|
||||
tone={applied ? "success" : "accent"}
|
||||
icon={<I.diff size={12} />}
|
||||
kind="edit"
|
||||
name={filename}
|
||||
meta={
|
||||
<>
|
||||
<span style={{ color: "var(--success)" }}>+{adds}</span>
|
||||
<span style={{ color: "var(--danger)" }}>−{rms}</span>
|
||||
{applied ? (
|
||||
<StatusIcon state="done" label={t("cards.applied")} />
|
||||
) : (
|
||||
<StatusIcon state="waiting" label={t("cards.diffAwaiting")} />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="diff">
|
||||
<div className="lines">
|
||||
{lines.map((ln, i) => {
|
||||
if (ln.t === "hunk")
|
||||
return (
|
||||
<div key={i} className="ln hunk">
|
||||
<span className="code">{ln.s}</span>
|
||||
</div>
|
||||
);
|
||||
const cls = ln.t === "add" ? "add" : ln.t === "rm" ? "rm" : "";
|
||||
const l = ln.t === "ctx" ? ln.l : ln.t === "rm" ? ln.l : undefined;
|
||||
const r = ln.t === "ctx" ? ln.r : ln.t === "add" ? ln.r : undefined;
|
||||
return (
|
||||
<div key={i} className={`ln ${cls}`}>
|
||||
<span className="num">{l ?? ""}</span>
|
||||
<span className="num">{r ?? ""}</span>
|
||||
<span className="code">
|
||||
{ln.t === "add" ? "+ " : ln.t === "rm" ? "− " : " "}
|
||||
{ln.s}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{!applied && (onApply || onDiscard) ? (
|
||||
<div className="approve-row">
|
||||
<div className="why">
|
||||
<b>{t("cards.diffApplyChanges")}</b> · +{adds} / −{rms}
|
||||
</div>
|
||||
<div className="actions">
|
||||
{onDiscard ? (
|
||||
<button type="button" className="btn" onClick={onDiscard}>
|
||||
{t("cards.diffDiscard")}
|
||||
</button>
|
||||
) : null}
|
||||
{onApply ? (
|
||||
<button type="button" className="btn primary" onClick={onApply}>
|
||||
{t("cards.diffApply")} <Shortcut keys={["mod", "enter"]} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Error ----
|
||||
|
||||
export function ErrorCard({ message, hint, code }: { message: string; hint?: ReactNode; code?: string }) {
|
||||
useLang();
|
||||
return (
|
||||
<Card
|
||||
tone="danger"
|
||||
icon={<I.warning size={12} />}
|
||||
kind="error"
|
||||
name={t("cards.errorName")}
|
||||
meta={code ? <span className="pill-tag err">{code}</span> : null}
|
||||
>
|
||||
<div className="error-body">
|
||||
<div className="msg-err">{message}</div>
|
||||
{hint ? <div className="hint">{hint}</div> : null}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Search results ----
|
||||
|
||||
export type SearchHit = { url: string; title: string; snippet: string };
|
||||
|
||||
export function WebSearchCard({ query, results }: { query: string; results: SearchHit[] }) {
|
||||
useLang();
|
||||
return (
|
||||
<Card
|
||||
tone="default"
|
||||
icon={<I.globe size={12} />}
|
||||
kind="web_search"
|
||||
name={t("cards.searchName")}
|
||||
meta={
|
||||
<>
|
||||
<span>"{query}"</span>
|
||||
<span className="pill-tag ok">{results.length} {t("cards.hits")}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="search-results">
|
||||
{results.map((r, i) => (
|
||||
<div className="search-result" key={i}>
|
||||
<div className="url">
|
||||
<span className="favicon" />
|
||||
<span>{r.url}</span>
|
||||
</div>
|
||||
<div className="title">{r.title}</div>
|
||||
<div className="snippet">{r.snippet}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Subagent ----
|
||||
|
||||
export type SubAgentChild = {
|
||||
avatar: string;
|
||||
what: string;
|
||||
role: string;
|
||||
status: "done" | "running" | "queued";
|
||||
};
|
||||
|
||||
export function SubagentCard({
|
||||
name,
|
||||
children,
|
||||
status,
|
||||
}: {
|
||||
name: string;
|
||||
children: SubAgentChild[];
|
||||
status: "running" | "done" | "failed";
|
||||
}) {
|
||||
useLang();
|
||||
const done = children.filter((c) => c.status === "done").length;
|
||||
return (
|
||||
<Card
|
||||
tone="violet"
|
||||
icon={<I.bot size={12} />}
|
||||
kind="subagent"
|
||||
name={name}
|
||||
meta={
|
||||
<>
|
||||
<span>
|
||||
{done} / {children.length} {t("cards.subagentDoneProgress")}
|
||||
</span>
|
||||
{status === "done" ? (
|
||||
<StatusIcon state="done" label={t("cards.subagentDone")} />
|
||||
) : status === "failed" ? (
|
||||
<StatusIcon state="failed" label={t("cards.subagentFailed")} />
|
||||
) : (
|
||||
<StatusIcon state="running" label={t("cards.subagentRunning")} />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="sub-card">
|
||||
{children.map((c, i) => (
|
||||
<div className="sub-row" key={i}>
|
||||
<span className="av">{c.avatar}</span>
|
||||
<div className="what">
|
||||
<div>{c.what}</div>
|
||||
<div className="role">{c.role}</div>
|
||||
</div>
|
||||
<span className="prog">
|
||||
{c.status === "done" ? (
|
||||
<I.check size={12} style={{ color: "var(--success)" }} />
|
||||
) : c.status === "running" ? (
|
||||
<span className="spin" />
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Memory rows ----
|
||||
|
||||
export type MemRow = { scope: string; txt: string };
|
||||
|
||||
export function MemoryCard({ rows }: { rows: MemRow[] }) {
|
||||
useLang();
|
||||
return (
|
||||
<Card
|
||||
tone="violet"
|
||||
icon={<I.bookmark size={12} />}
|
||||
kind="memory"
|
||||
name={t("cards.memoryName")}
|
||||
meta={<span>+ {rows.length} {t("cards.memoryCountSuffix")}</span>}
|
||||
>
|
||||
<div className="mem">
|
||||
{rows.map((m, i) => (
|
||||
<div className="mem-row" key={i}>
|
||||
<span className="scope">{m.scope}</span>
|
||||
<span className="txt">{m.txt}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Image attachment ----
|
||||
|
||||
export function AttachCard({
|
||||
filename,
|
||||
meta,
|
||||
preview,
|
||||
}: {
|
||||
filename: string;
|
||||
meta: string;
|
||||
preview?: string;
|
||||
}) {
|
||||
useLang();
|
||||
return (
|
||||
<Card
|
||||
tone="default"
|
||||
icon={<I.image size={12} />}
|
||||
kind="image"
|
||||
name={filename}
|
||||
meta={<span>{meta}</span>}
|
||||
>
|
||||
<div className="attach-card">
|
||||
<div className="ph">{preview ?? "PNG"}</div>
|
||||
<div className="info">
|
||||
<div className="n">{filename}</div>
|
||||
<div className="m">{meta}</div>
|
||||
</div>
|
||||
<button type="button" className="btn ghost">
|
||||
<I.download size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Metric strip (inline) ----
|
||||
|
||||
export function MetricStrip({
|
||||
cacheHit,
|
||||
promptTokens,
|
||||
outputTokens,
|
||||
costLabel,
|
||||
elapsed,
|
||||
}: {
|
||||
/** Pre-formatted cache percentage string (e.g. "72.12%"). */
|
||||
cacheHit?: string;
|
||||
promptTokens?: number;
|
||||
outputTokens?: number;
|
||||
costLabel?: string;
|
||||
elapsed?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="metric-strip">
|
||||
{cacheHit !== undefined ? (
|
||||
<span className="item">
|
||||
<I.zap size={11} style={{ color: "var(--accent)" }} />
|
||||
<span>{t("cards.cacheHit")}</span>
|
||||
<span className="v acc">{cacheHit}</span>
|
||||
</span>
|
||||
) : null}
|
||||
{promptTokens !== undefined ? (
|
||||
<span className="item">
|
||||
<span>{t("cards.prompt")}</span>
|
||||
<span className="v">{promptTokens.toLocaleString()} t</span>
|
||||
</span>
|
||||
) : null}
|
||||
{outputTokens !== undefined ? (
|
||||
<span className="item">
|
||||
<span>{t("cards.output")}</span>
|
||||
<span className="v">{outputTokens.toLocaleString()} t</span>
|
||||
</span>
|
||||
) : null}
|
||||
{costLabel ? (
|
||||
<span className="item">
|
||||
<I.coin size={11} />
|
||||
<span>{t("cards.cost")}</span>
|
||||
<span className="v ok">{costLabel}</span>
|
||||
</span>
|
||||
) : null}
|
||||
{elapsed ? (
|
||||
<span className="item">
|
||||
<span>{t("cards.elapsed")}</span>
|
||||
<span className="v">{elapsed}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Checkpoint marker (inline) ----
|
||||
|
||||
export function Checkpoint({
|
||||
hash,
|
||||
label,
|
||||
onRewind,
|
||||
}: {
|
||||
hash: string;
|
||||
label: string;
|
||||
onRewind?: () => void;
|
||||
}) {
|
||||
useLang();
|
||||
return (
|
||||
<div className="checkpoint">
|
||||
<I.history size={12} />
|
||||
<span className="hash">{hash}</span>
|
||||
<span>·</span>
|
||||
<span>{label}</span>
|
||||
{onRewind ? (
|
||||
<button type="button" onClick={onRewind}>
|
||||
{t("cards.checkpointRewind")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Plain text block (assistant content via markdown) ----
|
||||
|
||||
export const AssistantText = memo(function AssistantText({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="msg-text">
|
||||
<Markdown source={text} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,829 @@
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type KeyboardEvent,
|
||||
type RefObject,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type React from "react";
|
||||
import { open as openFileDialog } from "@tauri-apps/plugin-dialog";
|
||||
import { t, type TKey } from "../i18n";
|
||||
import { I } from "../icons";
|
||||
import { fmtElapsed } from "./live";
|
||||
import { Shortcut } from "./shortcut";
|
||||
|
||||
export type ReasoningEffort = "low" | "medium" | "high" | "max";
|
||||
export type EditMode = "review" | "auto" | "yolo" | "plan";
|
||||
|
||||
type ModeEntry = { k: EditMode; label: TKey; icon: React.ReactNode; hint: TKey };
|
||||
|
||||
const EFFORTS: readonly ReasoningEffort[] = ["low", "medium", "high", "max"];
|
||||
|
||||
const MODE_INFO: ModeEntry[] = [
|
||||
{ k: "plan", label: "editMode.plan", icon: <I.list size={11} />, hint: "editMode.planHint" },
|
||||
{ k: "review", label: "editMode.review", icon: <I.shield size={11} />, hint: "editMode.reviewHint" },
|
||||
{ k: "auto", label: "editMode.auto", icon: <I.zap size={11} />, hint: "editMode.autoHint" },
|
||||
{ k: "yolo", label: "editMode.yolo", icon: <I.warn size={11} />, hint: "editMode.yoloHint" },
|
||||
];
|
||||
|
||||
export function ModeSwitch({
|
||||
mode,
|
||||
onChange,
|
||||
}: {
|
||||
mode: EditMode;
|
||||
onChange: (m: EditMode) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mode-switch" data-mode={mode}>
|
||||
{MODE_INFO.map((m) => (
|
||||
<button
|
||||
key={m.k}
|
||||
type="button"
|
||||
className="ms-seg"
|
||||
data-on={mode === m.k}
|
||||
data-k={m.k}
|
||||
onClick={() => onChange(m.k)}
|
||||
title={t(m.hint)}
|
||||
>
|
||||
{m.icon}
|
||||
<span>{t(m.label)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type SlashCmd = {
|
||||
cmd: string;
|
||||
desc: string;
|
||||
run: () => void;
|
||||
kb?: string;
|
||||
insertOnly?: boolean;
|
||||
};
|
||||
export type MentionItem = {
|
||||
name: string;
|
||||
path?: string;
|
||||
kind: "file" | "dir" | "url" | "agent" | "clip";
|
||||
desc?: string;
|
||||
};
|
||||
|
||||
export type Chip =
|
||||
| { kind: "at"; label: string }
|
||||
| { kind: "slash"; label: string };
|
||||
|
||||
type Popup =
|
||||
| { kind: "slash"; query: string }
|
||||
| { kind: "at"; query: string; nonce: number }
|
||||
| null;
|
||||
|
||||
function slashIcon(cmd: string) {
|
||||
const m: Record<string, React.ReactNode> = {
|
||||
"/clear": <I.x size={12} />,
|
||||
"/new": <I.plus size={12} />,
|
||||
"/abort": <I.stop size={12} />,
|
||||
"/copy": <I.layers size={12} />,
|
||||
"/export": <I.download size={12} />,
|
||||
"/model": <I.cpu size={12} />,
|
||||
"/theme": <I.sun size={12} />,
|
||||
"/lang": <I.globe size={12} />,
|
||||
};
|
||||
return m[cmd] || <I.slash size={12} />;
|
||||
}
|
||||
|
||||
/** Parent dir of the current @ query, with trailing slash. `null` = no parent to show (at workspace root). */
|
||||
function parentOfAtQuery(query: string): string | null {
|
||||
const normalized = query.replace(/\\/g, "/");
|
||||
const trailingSlash = normalized.endsWith("/");
|
||||
const lastSlash = normalized.lastIndexOf("/");
|
||||
if (lastSlash < 0) return null;
|
||||
const dirContext = trailingSlash ? normalized.slice(0, -1) : normalized.slice(0, lastSlash);
|
||||
if (!dirContext) return null;
|
||||
const parentIdx = dirContext.lastIndexOf("/");
|
||||
return parentIdx >= 0 ? `${dirContext.slice(0, parentIdx)}/` : "";
|
||||
}
|
||||
|
||||
function displayMentionPath(path: string): { name: string; desc?: string } {
|
||||
const normalized = path.replace(/\\/g, "/");
|
||||
const isDir = /[\\/]$/.test(path);
|
||||
const trimmed = normalized.replace(/\/+$/, "");
|
||||
if (!trimmed) return { name: path };
|
||||
const slash = trimmed.lastIndexOf("/");
|
||||
const base = slash >= 0 ? trimmed.slice(slash + 1) : trimmed;
|
||||
const parent = slash >= 0 ? `${trimmed.slice(0, slash)}/` : "";
|
||||
return {
|
||||
name: isDir ? `${base}/` : base,
|
||||
desc: parent || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function atIcon(k: MentionItem["kind"]) {
|
||||
if (k === "file") return <I.file size={12} />;
|
||||
if (k === "dir") return <I.folder size={12} />;
|
||||
if (k === "url") return <I.globe size={12} />;
|
||||
if (k === "agent") return <I.bot size={12} />;
|
||||
if (k === "clip") return <I.layers size={12} />;
|
||||
return <I.at size={12} />;
|
||||
}
|
||||
|
||||
export function Composer({
|
||||
draft,
|
||||
setDraft,
|
||||
onSend,
|
||||
onAbort,
|
||||
disabled,
|
||||
busy,
|
||||
busyLabel,
|
||||
busyElapsedMs,
|
||||
modelLabel,
|
||||
reasoningEffort,
|
||||
onModelChange,
|
||||
onEffortChange,
|
||||
editMode,
|
||||
onEditModeChange,
|
||||
textareaRef,
|
||||
slashCommands,
|
||||
onMentionQuery,
|
||||
onMentionPreview,
|
||||
onMentionPicked,
|
||||
mentionResults,
|
||||
workspaceDir,
|
||||
queuedSends,
|
||||
onQueueWhileBusy,
|
||||
onDequeueSend,
|
||||
}: {
|
||||
draft: string;
|
||||
setDraft: (s: string) => void;
|
||||
onSend: () => void;
|
||||
onAbort: () => void;
|
||||
disabled?: boolean;
|
||||
busy?: boolean;
|
||||
/** Replaces the hint-row left side while the agent is running — typically "Reasoning" or "Skill · <name>". */
|
||||
busyLabel?: string;
|
||||
busyElapsedMs?: number;
|
||||
modelLabel: string;
|
||||
reasoningEffort: ReasoningEffort;
|
||||
onModelChange: (model: string) => void;
|
||||
onEffortChange: (effort: ReasoningEffort) => void;
|
||||
editMode: EditMode;
|
||||
onEditModeChange: (mode: EditMode) => void;
|
||||
textareaRef: RefObject<HTMLTextAreaElement | null>;
|
||||
slashCommands: SlashCmd[];
|
||||
onMentionQuery?: (q: string, nonce: number) => void;
|
||||
onMentionPreview?: (path: string, nonce: number) => void;
|
||||
onMentionPicked?: (path: string) => void;
|
||||
mentionResults?: { nonce: number; query: string; results: string[] } | null;
|
||||
workspaceDir?: string;
|
||||
/** Messages typed while busy=true; rendered as removable chips above the textarea and auto-drained FIFO on turn-complete. */
|
||||
queuedSends?: string[];
|
||||
/** Called when the user presses Enter while busy with a non-empty draft. Owns clearing the draft. */
|
||||
onQueueWhileBusy?: (text: string) => void;
|
||||
onDequeueSend?: (index: number) => void;
|
||||
}) {
|
||||
const [chips, setChips] = useState<Chip[]>([]);
|
||||
const [popup, setPopup] = useState<Popup>(null);
|
||||
const [activeIdx, setActiveIdx] = useState(0);
|
||||
const [modelMenuOpen, setModelMenuOpen] = useState(false);
|
||||
const nonceRef = useRef(0);
|
||||
const modelWrapRef = useRef<HTMLDivElement>(null);
|
||||
// macOS Chinese IME fires compositionend before the confirm keydown.
|
||||
const composingRef = useRef(false);
|
||||
const compositionEndedAtRef = useRef(0);
|
||||
|
||||
// Programmatic draft transitions to "/" (e.g. /help suggestion in EmptyState, #929) must open the slash popup, since handleChange only fires on actual user input.
|
||||
const prevDraftRef = useRef(draft);
|
||||
useEffect(() => {
|
||||
const prev = prevDraftRef.current;
|
||||
prevDraftRef.current = draft;
|
||||
if (draft === "/" && prev !== "/") {
|
||||
setPopup({ kind: "slash", query: "" });
|
||||
}
|
||||
}, [draft]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!modelMenuOpen) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (
|
||||
modelWrapRef.current &&
|
||||
!modelWrapRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setModelMenuOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("mousedown", onDown);
|
||||
return () => window.removeEventListener("mousedown", onDown);
|
||||
}, [modelMenuOpen]);
|
||||
|
||||
const attachFile = async (filter?: "image") => {
|
||||
try {
|
||||
const picked = await openFileDialog({
|
||||
multiple: false,
|
||||
directory: false,
|
||||
defaultPath: workspaceDir,
|
||||
filters:
|
||||
filter === "image"
|
||||
? [
|
||||
{
|
||||
name: t("composer.imageFilterName"),
|
||||
extensions: ["png", "jpg", "jpeg", "gif", "webp", "svg"],
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
});
|
||||
if (typeof picked !== "string" || !picked) return;
|
||||
const rel =
|
||||
workspaceDir && picked.startsWith(workspaceDir)
|
||||
? picked.slice(workspaceDir.length).replace(/^[\\/]+/, "")
|
||||
: picked;
|
||||
setDraft(draft ? `${draft.replace(/\s+$/, "")} @${rel} ` : `@${rel} `);
|
||||
setChips((c) => [...c, { kind: "at", label: rel }]);
|
||||
onMentionPicked?.(rel);
|
||||
textareaRef.current?.focus();
|
||||
} catch (err) {
|
||||
console.error("attach failed", err);
|
||||
}
|
||||
};
|
||||
|
||||
const slashItems = useMemo(() => {
|
||||
if (!popup || popup.kind !== "slash") return [];
|
||||
const q = popup.query.toLowerCase();
|
||||
if (!q) return slashCommands;
|
||||
return slashCommands.filter((c) => c.cmd.toLowerCase().includes(q));
|
||||
}, [popup, slashCommands]);
|
||||
|
||||
const atItems = useMemo<MentionItem[]>(() => {
|
||||
if (!popup || popup.kind !== "at") return [];
|
||||
if (!mentionResults || mentionResults.nonce !== popup.nonce) return [];
|
||||
const base: MentionItem[] = mentionResults.results.map((path) => {
|
||||
const display = displayMentionPath(path);
|
||||
return {
|
||||
name: display.name,
|
||||
path,
|
||||
kind: path.endsWith("/") || path.endsWith("\\") ? "dir" : "file",
|
||||
desc: display.desc,
|
||||
};
|
||||
});
|
||||
// "../" entry (#1019): one level up whenever the @ query is inside a subdir.
|
||||
const parent = parentOfAtQuery(popup.query);
|
||||
if (parent !== null) {
|
||||
base.unshift({
|
||||
name: "..",
|
||||
kind: "dir",
|
||||
desc: parent ? `↑ ${parent}` : `↑ ${t("composer.workspaceRoot")}`,
|
||||
});
|
||||
}
|
||||
return base;
|
||||
}, [popup, mentionResults]);
|
||||
|
||||
const items =
|
||||
popup?.kind === "slash" ? slashItems : popup?.kind === "at" ? atItems : [];
|
||||
|
||||
useEffect(() => {
|
||||
setActiveIdx(0);
|
||||
}, [items.length, popup?.kind]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!popup || popup.kind !== "at" || !onMentionQuery) return;
|
||||
onMentionQuery(popup.query, popup.nonce);
|
||||
}, [popup, onMentionQuery]);
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const v = e.target.value;
|
||||
setDraft(v);
|
||||
const trail = v.match(/(^|\s)([/@])([^\s]*)$/);
|
||||
if (trail) {
|
||||
const sigil = trail[2];
|
||||
const query = trail[3] ?? "";
|
||||
if (sigil === "/") {
|
||||
setPopup({ kind: "slash", query });
|
||||
} else {
|
||||
const nonce = ++nonceRef.current;
|
||||
setPopup({ kind: "at", query, nonce });
|
||||
}
|
||||
} else if (popup) {
|
||||
setPopup(null);
|
||||
}
|
||||
};
|
||||
|
||||
const dismiss = () => setPopup(null);
|
||||
|
||||
const pickItem = (idx: number) => {
|
||||
const it = items[idx];
|
||||
if (!it || !popup) return;
|
||||
if (popup.kind === "slash") {
|
||||
const cmd = (it as SlashCmd).cmd;
|
||||
const insertOnly = (it as SlashCmd).insertOnly === true;
|
||||
if (insertOnly) {
|
||||
const next = draft.replace(/[/@][^\s]*$/, "").trimEnd();
|
||||
setDraft(next ? `${next} ${cmd} ` : `${cmd} `);
|
||||
setChips((c) => [...c, { kind: "slash", label: cmd.replace(/^\//, "") }]);
|
||||
} else {
|
||||
const next = draft.replace(/[/@][^\s]*$/, "").trimEnd();
|
||||
setDraft(next);
|
||||
setChips((c) => [...c, { kind: "slash", label: cmd.replace(/^\//, "") }]);
|
||||
(it as SlashCmd).run();
|
||||
}
|
||||
} else {
|
||||
const mention = it as MentionItem;
|
||||
if (mention.name === "..") {
|
||||
const parent = parentOfAtQuery(popup.query) ?? "";
|
||||
const next = draft.replace(/[@][^\s]*$/, `@${parent}`);
|
||||
setDraft(next);
|
||||
const nonce = ++nonceRef.current;
|
||||
setPopup({ kind: "at", query: parent, nonce });
|
||||
textareaRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
const next = draft.replace(/[/@][^\s]*$/, "").trimEnd();
|
||||
const mentionPath = mention.path ?? mention.name;
|
||||
setDraft(next ? `${next} @${mentionPath} ` : `@${mentionPath} `);
|
||||
setChips((c) => [...c, { kind: "at", label: mentionPath }]);
|
||||
onMentionPicked?.(mentionPath);
|
||||
}
|
||||
setPopup(null);
|
||||
textareaRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (popup) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveIdx((i) => (items.length ? (i + 1) % items.length : 0));
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveIdx((i) =>
|
||||
items.length ? (i - 1 + items.length) % items.length : 0,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
dismiss();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Tab" && popup.kind === "at" && items.length > 0) {
|
||||
// Tab on a directory enters it — replaces `@src` with `@src/`
|
||||
// and re-queries so the popup shows that directory's children.
|
||||
// `..` is the synthetic parent-dir entry (#1019); same shape
|
||||
// but rewrites to the parent path.
|
||||
const it = items[activeIdx];
|
||||
if (it && (it as MentionItem).kind === "dir") {
|
||||
e.preventDefault();
|
||||
const mention = it as MentionItem;
|
||||
if (mention.name === "..") {
|
||||
const parent = parentOfAtQuery(popup.query) ?? "";
|
||||
const next = draft.replace(/[@][^\s]*$/, `@${parent}`);
|
||||
setDraft(next);
|
||||
const nonce = ++nonceRef.current;
|
||||
setPopup({ kind: "at", query: parent, nonce });
|
||||
return;
|
||||
}
|
||||
const dirPath = (mention.path ?? mention.name).replace(/[\\/]+$/, "");
|
||||
const next = draft.replace(/[@][^\s]*$/, `@${dirPath}/`);
|
||||
setDraft(next);
|
||||
const nonce = ++nonceRef.current;
|
||||
setPopup({ kind: "at", query: `${dirPath}/`, nonce });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
if (items.length > 0) {
|
||||
e.preventDefault();
|
||||
pickItem(activeIdx);
|
||||
return;
|
||||
}
|
||||
dismiss();
|
||||
}
|
||||
}
|
||||
if (composingRef.current || Date.now() - compositionEndedAtRef.current < 50) return;
|
||||
if (e.key === "Enter" && !e.shiftKey && !popup) {
|
||||
e.preventDefault();
|
||||
if (busy) {
|
||||
const text = draft.trim();
|
||||
if (text && onQueueWhileBusy) {
|
||||
onQueueWhileBusy(text);
|
||||
setChips([]);
|
||||
}
|
||||
} else if (!disabled && draft.trim()) {
|
||||
onSend();
|
||||
setChips([]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="composer-wrap">
|
||||
<div className="composer-inner">
|
||||
{queuedSends && queuedSends.length > 0 ? (
|
||||
<div className="composer-queued">
|
||||
<span className="composer-queued-label">
|
||||
{t("composer.queueCount", { count: queuedSends.length })}
|
||||
</span>
|
||||
{queuedSends.map((text, i) => (
|
||||
<span key={i} className="composer-queue-chip" title={text}>
|
||||
<span className="text">{text}</span>
|
||||
{onDequeueSend ? (
|
||||
<span className="x" onClick={() => onDequeueSend(i)}>
|
||||
<I.x size={10} />
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="hint-row">
|
||||
{busy && busyLabel ? (
|
||||
<>
|
||||
<span className="composer-busy-status">
|
||||
<span className="composer-busy-pip" />
|
||||
<span className="composer-busy-label">{busyLabel}</span>
|
||||
<span className="composer-busy-time">
|
||||
{fmtElapsed(busyElapsedMs ?? 0)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="grow" />
|
||||
<ModeSwitch mode={editMode} onChange={onEditModeChange} />
|
||||
<span className="hint-sep" />
|
||||
<span>
|
||||
<Shortcut keys={["enter"]} /> {t("composer.queue")} · {" "}
|
||||
<Shortcut keys={["esc"]} /> {t("composer.interrupt")}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>
|
||||
<Shortcut keys={["/"]} /> {t("composer.commands")} · {" "}
|
||||
<Shortcut keys={["@"]} /> {t("composer.mentionFiles")}
|
||||
· <Shortcut keys={["mod", "K"]} /> {t("composer.commandPalette")}
|
||||
</span>
|
||||
<span className="grow" />
|
||||
<ModeSwitch mode={editMode} onChange={onEditModeChange} />
|
||||
<span className="hint-sep" />
|
||||
<span>
|
||||
<Shortcut keys={["enter"]} /> {t("composer.send")} {" "}
|
||||
<Shortcut keys={["shift", "enter"]} /> {t("composer.newline")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="composer">
|
||||
{chips.length > 0 ? (
|
||||
<div className="composer-tags">
|
||||
{chips.map((c, i) => (
|
||||
<span key={i} className={`chip ${c.kind}`}>
|
||||
{c.kind === "slash" ? (
|
||||
<I.slash size={11} />
|
||||
) : (
|
||||
<I.at size={11} />
|
||||
)}
|
||||
<span>{c.label}</span>
|
||||
<span
|
||||
className="x"
|
||||
onClick={() =>
|
||||
setChips((cs) => cs.filter((_, j) => j !== i))
|
||||
}
|
||||
>
|
||||
<I.x size={10} />
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={draft}
|
||||
placeholder={t("composer.placeholder")}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onCompositionStart={() => {
|
||||
composingRef.current = true;
|
||||
}}
|
||||
onCompositionEnd={() => {
|
||||
composingRef.current = false;
|
||||
compositionEndedAtRef.current = Date.now();
|
||||
}}
|
||||
rows={2}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<div className="composer-foot">
|
||||
<button
|
||||
type="button"
|
||||
className="cf-btn"
|
||||
title={t("composer.insertFile")}
|
||||
onClick={() => void attachFile()}
|
||||
>
|
||||
<span className="ico">
|
||||
<I.paperclip size={14} />
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="cf-btn"
|
||||
title={t("composer.insertImage")}
|
||||
onClick={() => void attachFile("image")}
|
||||
>
|
||||
<span className="ico">
|
||||
<I.image size={14} />
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="cf-btn"
|
||||
onClick={() => setPopup({ kind: "slash", query: "" })}
|
||||
>
|
||||
<span className="ico">
|
||||
<I.slash size={14} />
|
||||
</span>
|
||||
<span className="label">{t("composer.commandsLabel")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="cf-btn"
|
||||
onClick={() => {
|
||||
const nonce = ++nonceRef.current;
|
||||
setPopup({ kind: "at", query: "", nonce });
|
||||
}}
|
||||
>
|
||||
<span className="ico">
|
||||
<I.at size={14} />
|
||||
</span>
|
||||
<span className="label">{t("composer.mentionLabel")}</span>
|
||||
</button>
|
||||
|
||||
<span className="grow" />
|
||||
|
||||
<div ref={modelWrapRef} style={{ position: "relative" }}>
|
||||
<button
|
||||
type="button"
|
||||
className="model-pill"
|
||||
onClick={() => setModelMenuOpen((v) => !v)}
|
||||
title={t("composer.switchModel")}
|
||||
>
|
||||
<I.brain size={12} />
|
||||
<span>{modelLabel}</span>
|
||||
<span className="badge">{reasoningEffort}</span>
|
||||
<I.chev size={10} />
|
||||
</button>
|
||||
{modelMenuOpen ? (
|
||||
<ModelEffortMenu
|
||||
modelLabel={modelLabel}
|
||||
currentEffort={reasoningEffort}
|
||||
onPickModel={(m) => {
|
||||
onModelChange(m);
|
||||
setModelMenuOpen(false);
|
||||
}}
|
||||
onPickEffort={(e) => {
|
||||
onEffortChange(e);
|
||||
setModelMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{busy ? (
|
||||
<button
|
||||
type="button"
|
||||
className="send-btn"
|
||||
style={{ background: "var(--danger)" }}
|
||||
onClick={onAbort}
|
||||
title={t("composer.interrupt")}
|
||||
>
|
||||
<I.stop size={14} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="send-btn"
|
||||
disabled={disabled || !draft.trim()}
|
||||
onClick={() => {
|
||||
if (!disabled && draft.trim()) {
|
||||
onSend();
|
||||
setChips([]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<I.send size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{popup ? (
|
||||
<Popup
|
||||
kind={popup.kind}
|
||||
items={items}
|
||||
activeIdx={activeIdx}
|
||||
onPick={(i) => pickItem(i)}
|
||||
onClose={dismiss}
|
||||
onHover={(i, item) => {
|
||||
setActiveIdx(i);
|
||||
if (popup.kind === "at" && onMentionPreview) {
|
||||
const mention = item as MentionItem;
|
||||
const path = mention.path ?? mention.name;
|
||||
onMentionPreview(path, popup.nonce);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Popup({
|
||||
kind,
|
||||
items,
|
||||
activeIdx,
|
||||
onPick,
|
||||
onClose,
|
||||
onHover,
|
||||
}: {
|
||||
kind: "slash" | "at";
|
||||
items: (SlashCmd | MentionItem)[];
|
||||
activeIdx: number;
|
||||
onPick: (i: number) => void;
|
||||
onClose: () => void;
|
||||
onHover: (i: number, item: SlashCmd | MentionItem) => void;
|
||||
}) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const el = listRef.current?.querySelector<HTMLElement>(`[data-active="true"]`);
|
||||
el?.scrollIntoView({ block: "nearest" });
|
||||
});
|
||||
}, [activeIdx]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={kind === "at" ? "popup at-popup" : "popup"}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="ph">
|
||||
<span className="tok">{kind === "slash" ? "/" : "@"}</span>
|
||||
<span>
|
||||
{kind === "slash"
|
||||
? t("composer.slashHeader")
|
||||
: t("composer.atHeader")}
|
||||
</span>
|
||||
<span className="grow" />
|
||||
<span style={{ cursor: "pointer" }} onClick={onClose}>
|
||||
<I.x size={11} />
|
||||
</span>
|
||||
</div>
|
||||
<div className={kind === "at" ? "popup-list at-popup-list" : "popup-list"} ref={listRef}>
|
||||
{items.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 8px",
|
||||
fontSize: 11.5,
|
||||
color: "var(--muted-2)",
|
||||
fontFamily: "Geist Mono, monospace",
|
||||
}}
|
||||
>
|
||||
{t("composer.noMatches")}
|
||||
</div>
|
||||
) : null}
|
||||
{items.map((it, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="popup-item"
|
||||
data-active={i === activeIdx}
|
||||
onClick={() => onPick(i)}
|
||||
onMouseEnter={() => onHover(i, it)}
|
||||
>
|
||||
<span className="ico">
|
||||
{kind === "slash"
|
||||
? slashIcon((it as SlashCmd).cmd)
|
||||
: atIcon((it as MentionItem).kind)}
|
||||
</span>
|
||||
<div className="nm">
|
||||
{kind === "slash" ? (
|
||||
<>
|
||||
<span className="cmd">{(it as SlashCmd).cmd}</span>
|
||||
<span className="desc">{(it as SlashCmd).desc}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="mention-name">{(it as MentionItem).name}</span>
|
||||
{(it as MentionItem).desc ? (
|
||||
<div className="desc">{(it as MentionItem).desc}</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span className="kb">
|
||||
{kind === "slash" ? ((it as SlashCmd).kb ?? "") : ""}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="popup-foot">
|
||||
<span>
|
||||
<Shortcut keys={["updown"]} /> {t("composer.select")}
|
||||
</span>
|
||||
<span>
|
||||
<Shortcut keys={["enter"]} /> {t("composer.confirm")}
|
||||
</span>
|
||||
<span>
|
||||
<Shortcut keys={["esc"]} /> {t("composer.close")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const KNOWN_MODELS: readonly string[] = ["deepseek-v4-flash", "deepseek-v4-pro"];
|
||||
|
||||
function ModelEffortMenu({
|
||||
modelLabel,
|
||||
currentEffort,
|
||||
onPickModel,
|
||||
onPickEffort,
|
||||
}: {
|
||||
modelLabel: string;
|
||||
currentEffort: ReasoningEffort;
|
||||
onPickModel: (model: string) => void;
|
||||
onPickEffort: (effort: ReasoningEffort) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(modelLabel);
|
||||
return (
|
||||
<div
|
||||
className="popup"
|
||||
style={{
|
||||
bottom: "calc(100% + 6px)",
|
||||
left: "auto",
|
||||
right: 0,
|
||||
width: 280,
|
||||
position: "absolute",
|
||||
}}
|
||||
>
|
||||
<div className="ph">
|
||||
<span className="tok">M</span>
|
||||
<span>{t("composer.switchModel")}</span>
|
||||
</div>
|
||||
<div className="popup-list">
|
||||
{KNOWN_MODELS.map((m) => (
|
||||
<div
|
||||
key={m}
|
||||
className="popup-item"
|
||||
data-active={m === modelLabel}
|
||||
onClick={() => onPickModel(m)}
|
||||
>
|
||||
<span className="ico">
|
||||
<I.brain size={12} />
|
||||
</span>
|
||||
<div className="nm">
|
||||
<span className="cmd">{m}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ padding: "6px 8px", display: "flex", gap: 6 }}>
|
||||
<input
|
||||
className="field mono"
|
||||
style={{ flex: 1 }}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder="custom model id"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={!draft.trim() || draft.trim() === modelLabel}
|
||||
onClick={() => onPickModel(draft.trim())}
|
||||
>
|
||||
{t("composer.confirm")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ph" style={{ marginTop: 4 }}>
|
||||
<span className="tok">E</span>
|
||||
<span>{t("composer.switchEffort")}</span>
|
||||
</div>
|
||||
<div className="popup-list">
|
||||
{EFFORTS.map((e) => (
|
||||
<div
|
||||
key={e}
|
||||
className="popup-item"
|
||||
data-active={e === currentEffort}
|
||||
onClick={() => onPickEffort(e)}
|
||||
>
|
||||
<span className="ico">
|
||||
<I.cpu size={12} />
|
||||
</span>
|
||||
<div className="nm">
|
||||
<span className="cmd">{e}</span>
|
||||
<div className="desc">{t(`effort.${e}Desc` as TKey)}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { SessionFile, Settings, UsageStats } from "../App";
|
||||
import { t, useLang } from "../i18n";
|
||||
import { I } from "../icons";
|
||||
import type { McpSpecInfo, MemoryDetail, MemoryEntryInfo } from "../protocol";
|
||||
import { PanelErrorBoundary } from "./error-boundary";
|
||||
|
||||
type Tab = "files" | "tools" | "memory" | "rules";
|
||||
|
||||
const CONTEXT_MAX_TOKENS = 1_000_000;
|
||||
|
||||
export function ContextPanel({
|
||||
settings,
|
||||
usage,
|
||||
mcpSpecs,
|
||||
mcpBridged,
|
||||
sessionFiles,
|
||||
memory,
|
||||
memoryDetail,
|
||||
onReadMemory,
|
||||
}: {
|
||||
settings: Settings | null;
|
||||
usage: UsageStats;
|
||||
mcpSpecs: McpSpecInfo[];
|
||||
mcpBridged: boolean;
|
||||
sessionFiles: SessionFile[];
|
||||
memory: MemoryEntryInfo[];
|
||||
memoryDetail: MemoryDetail | null;
|
||||
onReadMemory: (path: string) => void;
|
||||
}) {
|
||||
useLang();
|
||||
const [tab, setTab] = useState<Tab>("files");
|
||||
const reserved = usage.reservedTokens;
|
||||
// After a warm cache turn the API counts the reserved prefix inside cacheHit;
|
||||
// subtract to keep the bar segments visually disjoint. Cold cache shows the
|
||||
// reserved portion in cacheMiss instead, so do the same for `used`.
|
||||
const cached = Math.max(0, usage.cacheHitTokens - reserved);
|
||||
const used = Math.max(0, usage.cacheMissTokens - Math.max(0, reserved - usage.cacheHitTokens));
|
||||
const reservedPct = Math.min(100, (reserved / CONTEXT_MAX_TOKENS) * 100);
|
||||
const usedPct = Math.min(100, (used / CONTEXT_MAX_TOKENS) * 100);
|
||||
const cachedPct = Math.min(100, (cached / CONTEXT_MAX_TOKENS) * 100);
|
||||
const free = Math.max(0, CONTEXT_MAX_TOKENS - reserved - used - cached);
|
||||
return (
|
||||
<aside className="ctx">
|
||||
<div className="ctx-tabs">
|
||||
<div className="ctx-tab" data-active={tab === "files"} onClick={() => setTab("files")}>
|
||||
{t("contextPanel.filesTab")}
|
||||
</div>
|
||||
<div className="ctx-tab" data-active={tab === "tools"} onClick={() => setTab("tools")}>
|
||||
{t("contextPanel.toolsTab")}
|
||||
</div>
|
||||
<div className="ctx-tab" data-active={tab === "memory"} onClick={() => setTab("memory")}>
|
||||
{t("contextPanel.memoryTab")}
|
||||
</div>
|
||||
<div className="ctx-tab" data-active={tab === "rules"} onClick={() => setTab("rules")}>
|
||||
{t("contextPanel.rulesTab")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ctx-body">
|
||||
<div className="ctx-block">
|
||||
<div className="h">
|
||||
<span>{t("contextPanel.contextTokens")}</span>
|
||||
<span className="right">
|
||||
{(reserved + used + cached).toLocaleString()} /{" "}
|
||||
{CONTEXT_MAX_TOKENS.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="meter">
|
||||
<span className="rsvd" style={{ width: `${reservedPct}%` }} />
|
||||
<span className="cached" style={{ width: `${cachedPct}%` }} />
|
||||
<span className="used" style={{ width: `${usedPct}%` }} />
|
||||
</div>
|
||||
<div className="legend">
|
||||
<span className="l">
|
||||
<span className="sw r" />
|
||||
{t("contextPanel.reservedKey")} <span className="v">{reserved.toLocaleString()}</span>
|
||||
</span>
|
||||
<span className="l">
|
||||
<span className="sw c" />
|
||||
{t("contextPanel.cacheKey")} <span className="v">{cached.toLocaleString()}</span>
|
||||
</span>
|
||||
<span className="l">
|
||||
<span className="sw u" />
|
||||
{t("contextPanel.usedKey")} <span className="v">{used.toLocaleString()}</span>
|
||||
</span>
|
||||
<span className="l">
|
||||
{t("contextPanel.freeKey")} <span className="v">{free.toLocaleString()}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PanelErrorBoundary key={tab} label={tab}>
|
||||
{tab === "files" && <CtxFiles files={sessionFiles} />}
|
||||
{tab === "tools" && <CtxTools specs={mcpSpecs} bridged={mcpBridged} />}
|
||||
{tab === "memory" && (
|
||||
<CtxMemory entries={memory} detail={memoryDetail} onRead={onReadMemory} />
|
||||
)}
|
||||
{tab === "rules" && <CtxRules settings={settings} />}
|
||||
</PanelErrorBoundary>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
type TreeNode =
|
||||
| { kind: "dir"; depth: number; name: string; key: string }
|
||||
| { kind: "file"; depth: number; name: string; key: string; status: "c" | "m" };
|
||||
|
||||
function buildSessionTree(files: SessionFile[]): TreeNode[] {
|
||||
const sorted = [...files].sort((a, b) =>
|
||||
a.path.replace(/\\/g, "/").localeCompare(b.path.replace(/\\/g, "/")),
|
||||
);
|
||||
const out: TreeNode[] = [];
|
||||
const seenDirs = new Set<string>();
|
||||
for (const f of sorted) {
|
||||
const parts = f.path.replace(/\\/g, "/").split("/").filter(Boolean);
|
||||
if (parts.length === 0) continue;
|
||||
let prefix = "";
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const seg = parts[i] ?? "";
|
||||
prefix = prefix ? `${prefix}/${seg}` : seg;
|
||||
if (!seenDirs.has(prefix)) {
|
||||
seenDirs.add(prefix);
|
||||
out.push({ kind: "dir", depth: i, name: seg, key: `d:${prefix}` });
|
||||
}
|
||||
}
|
||||
const leaf = parts[parts.length - 1] ?? "";
|
||||
out.push({
|
||||
kind: "file",
|
||||
depth: parts.length - 1,
|
||||
name: leaf,
|
||||
key: `f:${f.path}`,
|
||||
status: f.status,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function CtxFiles({ files }: { files: SessionFile[] }) {
|
||||
const tree = useMemo(() => buildSessionTree(files), [files]);
|
||||
return (
|
||||
<div className="ctx-block">
|
||||
<div className="h">
|
||||
<span>{t("contextPanel.filesTitle")}</span>
|
||||
<span className="right">
|
||||
{files.length === 0 ? "—" : t("contextPanel.filesCount", { count: files.length })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="tree">
|
||||
{files.length === 0 ? (
|
||||
<div className="ctx-empty">{t("contextPanel.noFilesMsg")}</div>
|
||||
) : (
|
||||
tree.map((n) =>
|
||||
n.kind === "dir" ? (
|
||||
<div className="node" key={n.key} data-d={n.depth} data-kind="dir">
|
||||
<span className="ico">
|
||||
<I.folder size={12} />
|
||||
</span>
|
||||
<span className="nm">{n.name}/</span>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="node"
|
||||
key={n.key}
|
||||
data-d={n.depth}
|
||||
data-kind="file"
|
||||
title={n.name}
|
||||
>
|
||||
<span className="ico">
|
||||
<I.file size={12} />
|
||||
</span>
|
||||
<span className="nm">{n.name}</span>
|
||||
<span
|
||||
className="dot"
|
||||
data-s={n.status}
|
||||
title={n.status === "m" ? t("contextPanel.fileModified") : t("contextPanel.fileInContext")}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CtxTools({ specs, bridged }: { specs: McpSpecInfo[]; bridged: boolean }) {
|
||||
const readyCount = specs.filter((s) => s.status === "connected").length;
|
||||
return (
|
||||
<div className="ctx-block">
|
||||
<div className="h">
|
||||
<span>{t("contextPanel.mcpTitle")}</span>
|
||||
<span className="right">
|
||||
{specs.length === 0
|
||||
? "—"
|
||||
: bridged
|
||||
? t("contextPanel.mcpReadyAll", { count: specs.length })
|
||||
: t("contextPanel.mcpReadySome", { ready: readyCount, count: specs.length })}
|
||||
</span>
|
||||
</div>
|
||||
{specs.length === 0 ? (
|
||||
<div className="ctx-empty">{t("contextPanel.mcpEmpty")}</div>
|
||||
) : (
|
||||
specs.map((s) => {
|
||||
const dot =
|
||||
s.status === "connected"
|
||||
? "ok"
|
||||
: s.status === "failed" || s.parseError
|
||||
? "off"
|
||||
: "pending";
|
||||
const suffix = s.statusReason
|
||||
? ` · ${s.statusReason}`
|
||||
: s.status === "connected"
|
||||
? typeof s.toolCount === "number"
|
||||
? ` · ${t("contextPanel.mcpTools", { count: s.toolCount })}`
|
||||
: ` · ${t("contextPanel.mcpReady")}`
|
||||
: s.status === "handshake"
|
||||
? ` · ${t("contextPanel.mcpConnecting")}`
|
||||
: s.status === "disabled"
|
||||
? ` · ${t("contextPanel.mcpDisabled")}`
|
||||
: s.status === "failed"
|
||||
? ` · ${t("contextPanel.mcpFailed")}`
|
||||
: ` · ${t("contextPanel.mcpConfigured")}`;
|
||||
return (
|
||||
<div className="mcp-row" key={s.raw}>
|
||||
<span className="ico">
|
||||
<I.wrench size={12} />
|
||||
</span>
|
||||
<div className="body">
|
||||
<div className="n">{s.name ?? s.summary}</div>
|
||||
<div className="m">
|
||||
{s.transport}
|
||||
{suffix}
|
||||
</div>
|
||||
</div>
|
||||
<span className="status" data-s={dot} />
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CtxMemory({
|
||||
entries,
|
||||
detail,
|
||||
onRead,
|
||||
}: {
|
||||
entries: MemoryEntryInfo[];
|
||||
detail: MemoryDetail | null;
|
||||
onRead: (path: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="ctx-block">
|
||||
<div className="h">
|
||||
<span>{t("contextPanel.memoryTitle")}</span>
|
||||
<span className="right">
|
||||
{entries.length === 0 ? "—" : t("contextPanel.itemCount", { count: entries.length })}
|
||||
</span>
|
||||
</div>
|
||||
{entries.length === 0 ? (
|
||||
<div className="ctx-empty">{t("contextPanel.noMemoriesMsg")}</div>
|
||||
) : (
|
||||
<div className="mem">
|
||||
{entries.map((m) => (
|
||||
<button
|
||||
type="button"
|
||||
className="mem-row"
|
||||
data-active={detail?.path === m.path}
|
||||
key={m.path}
|
||||
onClick={() => onRead(m.path)}
|
||||
>
|
||||
<span className="scope" data-s={m.scope}>
|
||||
{m.scope === "project" ? t("contextPanel.scopeProject") : t("contextPanel.scopeGlobal")}
|
||||
</span>
|
||||
<span className="txt">{m.description || m.name}</span>
|
||||
</button>
|
||||
))}
|
||||
{detail ? <pre className="mem-detail">{detail.body}</pre> : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CtxRules({ settings }: { settings: Settings | null }) {
|
||||
const editMode = settings?.editMode ?? "review";
|
||||
const items: { p: string; allow: boolean; desc: string }[] =
|
||||
editMode === "yolo"
|
||||
? [{ p: "*", allow: true, desc: t("contextPanel.ruleYolo") }]
|
||||
: editMode === "auto"
|
||||
? [
|
||||
{ p: "read_file, list_directory, search_files, *", allow: true, desc: t("contextPanel.ruleReadOnly") },
|
||||
{ p: "run_command (allowlist)", allow: true, desc: t("contextPanel.ruleShellAllowlist") },
|
||||
{ p: "edit_file, write_file, run_command (other)", allow: false, desc: t("contextPanel.ruleWritesAsk") },
|
||||
]
|
||||
: [
|
||||
{ p: "*", allow: false, desc: t("contextPanel.ruleReview") },
|
||||
];
|
||||
return (
|
||||
<div className="ctx-block">
|
||||
<div className="h">
|
||||
<span>{t("contextPanel.autoApproveTitle")}</span>
|
||||
<span className="right">{editMode}</span>
|
||||
</div>
|
||||
{items.map((r) => (
|
||||
<div className="rule" key={r.p}>
|
||||
<div className="top">
|
||||
<span className={`pat ${r.allow ? "" : "deny"}`}>{r.p}</span>
|
||||
<span className={`sw ${r.allow ? "" : "deny"}`}>
|
||||
{r.allow ? t("contextPanel.allow") : t("contextPanel.ask")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="desc">{r.desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Component, type ReactNode } from "react";
|
||||
import { t } from "../i18n";
|
||||
|
||||
type Props = { label: string; children: ReactNode };
|
||||
type State = { error: Error | null };
|
||||
|
||||
export class PanelErrorBoundary extends Component<Props, State> {
|
||||
state: State = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: { componentStack?: string | null }): void {
|
||||
console.error(`[panel:${this.props.label}] render error`, error, info);
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (!this.state.error) return this.props.children;
|
||||
return (
|
||||
<div
|
||||
className="ctx-empty"
|
||||
style={{
|
||||
color: "var(--danger, #e25555)",
|
||||
padding: 12,
|
||||
fontSize: 12,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{t("panel.renderError", { panel: this.props.label })}
|
||||
{": "}
|
||||
{this.state.error.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { I } from "../icons";
|
||||
import { t, useLang } from "../i18n";
|
||||
|
||||
export type ApprovalTone = "ok" | "warn" | "danger" | "info" | "brand" | "ghost";
|
||||
|
||||
export function ApprovalCard({
|
||||
kind,
|
||||
tone = "info",
|
||||
title,
|
||||
sub,
|
||||
body,
|
||||
preview,
|
||||
meta,
|
||||
primaryLabel,
|
||||
secondaryLabel,
|
||||
tertiaryLabel,
|
||||
onPrimary,
|
||||
onSecondary,
|
||||
onTertiary,
|
||||
}: {
|
||||
kind: string;
|
||||
tone?: ApprovalTone;
|
||||
title: string;
|
||||
sub?: string;
|
||||
body?: ReactNode;
|
||||
preview?: ReactNode;
|
||||
meta?: ReactNode;
|
||||
primaryLabel?: string;
|
||||
secondaryLabel?: string;
|
||||
tertiaryLabel?: string;
|
||||
onPrimary?: () => void;
|
||||
onSecondary?: () => void;
|
||||
onTertiary?: () => void;
|
||||
}) {
|
||||
useLang();
|
||||
return (
|
||||
<div className="approval" data-tone={tone}>
|
||||
<div className="ap-head">
|
||||
<span className="ap-ico">
|
||||
<I.shield size={13} />
|
||||
</span>
|
||||
<div>
|
||||
<div className="ap-kind">{kind}</div>
|
||||
<div className="ap-title">{title}</div>
|
||||
{sub ? <div className="ap-sub">{sub}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
{body ? <div className="ap-body">{body}</div> : null}
|
||||
{preview ? <div className="ap-preview">{preview}</div> : null}
|
||||
<div className="ap-foot">
|
||||
{onPrimary ? (
|
||||
<button type="button" className="btn primary" onClick={onPrimary}>
|
||||
{primaryLabel ?? t("extraCards.approve")}
|
||||
</button>
|
||||
) : null}
|
||||
{onSecondary ? (
|
||||
<button type="button" className="btn ghost" onClick={onSecondary}>
|
||||
{secondaryLabel ?? t("extraCards.reject")}
|
||||
</button>
|
||||
) : null}
|
||||
{onTertiary && tertiaryLabel ? (
|
||||
<button type="button" className="btn ghost" onClick={onTertiary}>
|
||||
{tertiaryLabel}
|
||||
</button>
|
||||
) : null}
|
||||
<span className="grow" />
|
||||
{meta ? <span className="meta">{meta}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Task Card (multi-step execution from active plan) ----
|
||||
|
||||
export type TaskStepView = {
|
||||
n: string;
|
||||
state: "queued" | "running" | "done" | "failed" | "blocked" | "skipped";
|
||||
label: string;
|
||||
hint?: string;
|
||||
durationLabel?: string;
|
||||
};
|
||||
|
||||
export function TaskCard({
|
||||
title,
|
||||
subtitle,
|
||||
steps,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
steps: TaskStepView[];
|
||||
}) {
|
||||
useLang();
|
||||
const done = steps.filter((x) => x.state === "done").length;
|
||||
const pct = steps.length ? (done / steps.length) * 100 : 0;
|
||||
return (
|
||||
<div className="task-card">
|
||||
<div className="th">
|
||||
<span className="ico">
|
||||
<I.list size={13} />
|
||||
</span>
|
||||
<div>
|
||||
<div className="tt">{title}</div>
|
||||
{subtitle ? <div className="ss">{subtitle}</div> : null}
|
||||
</div>
|
||||
<span className="grow" />
|
||||
<span className="ss">
|
||||
{done}/{steps.length}
|
||||
</span>
|
||||
<div className="meter">
|
||||
<span style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="tb">
|
||||
{steps.map((st) => (
|
||||
<div className="task-step" key={st.n} data-state={st.state}>
|
||||
<span className="nx">step.{st.n}</span>
|
||||
<span className="st" />
|
||||
<div className="l">
|
||||
{st.label}
|
||||
{st.hint ? <div className="h">{st.hint}</div> : null}
|
||||
</div>
|
||||
<span className="t">{st.durationLabel ?? "—"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Warn / Tip / Doctor ----
|
||||
|
||||
export function WarnCard({ title, body }: { title: string; body: ReactNode }) {
|
||||
return (
|
||||
<div className="warn-card">
|
||||
<span className="ico">
|
||||
<I.warn size={16} />
|
||||
</span>
|
||||
<div>
|
||||
<div className="tt">{title}</div>
|
||||
<div className="ds">{body}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type TipSection = { title: string; rows: ReactNode[] };
|
||||
|
||||
export function TipCard({
|
||||
topic,
|
||||
command,
|
||||
sections,
|
||||
footer,
|
||||
}: {
|
||||
topic: string;
|
||||
command?: string;
|
||||
sections: TipSection[];
|
||||
footer?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="tip-card">
|
||||
<div className="head">
|
||||
<span className="ico">
|
||||
<I.help size={12} />
|
||||
</span>
|
||||
<span className="topic">{topic}</span>
|
||||
<span className="grow" />
|
||||
{command ? <span className="pill">{command}</span> : null}
|
||||
</div>
|
||||
{sections.map((sec, i) => (
|
||||
<div className="sec" key={i}>
|
||||
<div className="stt">{sec.title}</div>
|
||||
{sec.rows.map((r, j) => (
|
||||
<div className="row" key={j}>
|
||||
{r}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{footer ? <div className="foot">{footer}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type DoctorRow = { s: "ok" | "warn" | "fail"; nm: string; sub: string; v: string };
|
||||
|
||||
export function DoctorCard({ rows, headerSubtitle }: { rows: DoctorRow[]; headerSubtitle?: string }) {
|
||||
useLang();
|
||||
const c = { ok: 0, warn: 0, fail: 0 };
|
||||
for (const r of rows) c[r.s]++;
|
||||
return (
|
||||
<div className="doctor-card">
|
||||
<div className="dh">
|
||||
<span className="ico">
|
||||
<I.shield size={13} />
|
||||
</span>
|
||||
<div>
|
||||
<div className="tt">{t("extraCards.doctorTitle")}</div>
|
||||
{headerSubtitle ? (
|
||||
<div
|
||||
className="ss"
|
||||
style={{
|
||||
fontFamily: "Geist Mono, monospace",
|
||||
fontSize: 10.5,
|
||||
color: "var(--muted)",
|
||||
}}
|
||||
>
|
||||
{headerSubtitle}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="grow" />
|
||||
<div className="summary">
|
||||
<span>
|
||||
<span className="b ok">{c.ok}</span> ok
|
||||
</span>
|
||||
<span>
|
||||
<span className="b warn">{c.warn}</span> warn
|
||||
</span>
|
||||
<span>
|
||||
<span className="b err">{c.fail}</span> fail
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{rows.map((r, i) => (
|
||||
<div className="doctor-row" key={i} data-s={r.s}>
|
||||
<span className="ic" data-mark={r.s === "ok" ? "✓" : r.s === "warn" ? "!" : "✕"} />
|
||||
<div className="body">
|
||||
<div className="nm">{r.nm}</div>
|
||||
<div className="sub">{r.sub}</div>
|
||||
</div>
|
||||
<span className="v">{r.v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Usage (full) ----
|
||||
|
||||
export function UsageFull({
|
||||
promptTokens,
|
||||
reasoningTokens,
|
||||
outputTokens,
|
||||
cacheHitTokens,
|
||||
costLabel,
|
||||
balanceLabel,
|
||||
range,
|
||||
}: {
|
||||
promptTokens: number;
|
||||
reasoningTokens: number;
|
||||
outputTokens: number;
|
||||
cacheHitTokens: number;
|
||||
costLabel: string;
|
||||
balanceLabel?: string;
|
||||
range?: string;
|
||||
}) {
|
||||
useLang();
|
||||
const fmt = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`);
|
||||
const total = promptTokens + reasoningTokens + outputTokens + cacheHitTokens || 1;
|
||||
const pct = (n: number): number => {
|
||||
const raw = (n / total) * 100;
|
||||
if (raw === 100) return 100;
|
||||
const rounded = Math.round(raw * 100) / 100;
|
||||
if (rounded >= 100) return 99.99;
|
||||
return rounded;
|
||||
};
|
||||
return (
|
||||
<div className="usage-full">
|
||||
<div className="uh">
|
||||
<div>
|
||||
<div className="tt">{t("extraCards.sessionUsage")}</div>
|
||||
{range ? <div className="ss">{range}</div> : null}
|
||||
</div>
|
||||
<span className="grow" />
|
||||
</div>
|
||||
<div className="ub">
|
||||
<div className="ucol">
|
||||
<div className="l">{t("extraCards.prompt")}</div>
|
||||
<div className="v acc">{fmt(promptTokens)}</div>
|
||||
<div className="pct">{pct(promptTokens)}%</div>
|
||||
</div>
|
||||
<div className="ucol">
|
||||
<div className="l">{t("extraCards.reasoning")}</div>
|
||||
<div className="v vio">{fmt(reasoningTokens)}</div>
|
||||
<div className="pct">{pct(reasoningTokens)}%</div>
|
||||
</div>
|
||||
<div className="ucol">
|
||||
<div className="l">{t("extraCards.output")}</div>
|
||||
<div className="v ok">{fmt(outputTokens)}</div>
|
||||
<div className="pct">{pct(outputTokens)}%</div>
|
||||
</div>
|
||||
<div className="ucol">
|
||||
<div className="l">{t("extraCards.cacheHit")}</div>
|
||||
<div className="v">{fmt(cacheHitTokens)}</div>
|
||||
<div className="pct">{pct(cacheHitTokens)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stack">
|
||||
<span className="s1" style={{ width: `${pct(promptTokens)}%` }} />
|
||||
<span className="s2" style={{ width: `${pct(reasoningTokens)}%` }} />
|
||||
<span className="s3" style={{ width: `${pct(outputTokens)}%` }} />
|
||||
<span className="s4" style={{ width: `${pct(cacheHitTokens)}%` }} />
|
||||
</div>
|
||||
<div className="uf">
|
||||
<span className="x">
|
||||
<span className="sw" style={{ background: "var(--accent)" }} />
|
||||
{t("extraCards.prompt")}
|
||||
</span>
|
||||
<span className="x">
|
||||
<span className="sw" style={{ background: "var(--violet)" }} />
|
||||
{t("extraCards.reasoning")}
|
||||
</span>
|
||||
<span className="x">
|
||||
<span className="sw" style={{ background: "var(--tone-ok)" }} />
|
||||
{t("extraCards.output")}
|
||||
</span>
|
||||
<span className="x">
|
||||
<span className="sw" style={{ background: "var(--border-strong)" }} />
|
||||
{t("extraCards.cache")}
|
||||
</span>
|
||||
<span style={{ marginLeft: "auto" }}>
|
||||
{t("extraCards.sessionCost", { costLabel })}
|
||||
{balanceLabel ? ` · ${t("extraCards.balance", { balanceLabel })}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Context window breakdown ----
|
||||
|
||||
export type CtxPart = { k: "system" | "tools" | "log" | "input"; label: string; value: string; widthPct: number };
|
||||
export type CtxTopRow = { name: string; widthPct: number; value: string };
|
||||
|
||||
export function CtxCard({
|
||||
totalLabel,
|
||||
parts,
|
||||
topTools,
|
||||
}: {
|
||||
totalLabel: string;
|
||||
parts: CtxPart[];
|
||||
topTools: CtxTopRow[];
|
||||
}) {
|
||||
useLang();
|
||||
return (
|
||||
<div className="ctx-card">
|
||||
<div className="h">
|
||||
<span className="tt">{t("extraCards.contextWindow")}</span>
|
||||
<span className="grow" />
|
||||
<span className="v">{totalLabel}</span>
|
||||
</div>
|
||||
<div className="bar">
|
||||
{parts.map((p) => (
|
||||
<span key={p.k} className={p.k} style={{ width: `${p.widthPct}%` }} />
|
||||
))}
|
||||
</div>
|
||||
<div className="legend">
|
||||
{parts.map((p) => {
|
||||
const color =
|
||||
p.k === "system"
|
||||
? "var(--accent)"
|
||||
: p.k === "tools"
|
||||
? "var(--violet)"
|
||||
: p.k === "log"
|
||||
? "var(--tone-ok)"
|
||||
: "var(--tone-warn)";
|
||||
return (
|
||||
<div key={p.k}>
|
||||
<span className="sw" style={{ background: color }} />
|
||||
<span className="l">{p.label}</span>
|
||||
<span className="v">{p.value}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{topTools.length > 0 ? (
|
||||
<div className="ttop">
|
||||
<div className="stt">{t("extraCards.topToolsUsage")}</div>
|
||||
{topTools.map((t, i) => (
|
||||
<div className="row" key={i}>
|
||||
<span className="n">{t.name}</span>
|
||||
<div className="bbar">
|
||||
<span style={{ width: `${t.widthPct}%` }} />
|
||||
</div>
|
||||
<span className="v">{t.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Memory groups ----
|
||||
|
||||
export type MemGroupKey = "user" | "feedback" | "project" | "reference";
|
||||
export type MemEntry = { text: string; meta?: string };
|
||||
export type MemGroups = Partial<Record<MemGroupKey, MemEntry[]>>;
|
||||
|
||||
const GROUP_LABELS: Record<MemGroupKey, Parameters<typeof t>[0]> = {
|
||||
user: "extraCards.memoryUser",
|
||||
feedback: "extraCards.memoryFeedback",
|
||||
project: "extraCards.memoryProject",
|
||||
reference: "extraCards.memoryReference",
|
||||
};
|
||||
|
||||
export function MemoryGroups({ data }: { data: MemGroups }) {
|
||||
useLang();
|
||||
const keys: MemGroupKey[] = ["user", "feedback", "project", "reference"];
|
||||
return (
|
||||
<div className="mem-groups">
|
||||
{keys.map((g) => {
|
||||
const rows = data[g] ?? [];
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<div key={g}>
|
||||
<div className="gh" data-g={g}>
|
||||
<span className="sw" />
|
||||
<span>{t(GROUP_LABELS[g])}</span>
|
||||
<span className="grow" />
|
||||
<span className="cnt">{rows.length}</span>
|
||||
</div>
|
||||
{rows.map((r, i) => (
|
||||
<div className="mrow" key={i}>
|
||||
<span className="b">·</span>
|
||||
<div className="t">{r.text}</div>
|
||||
{r.meta ? <span className="meta">{r.meta}</span> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Fallback ----
|
||||
|
||||
export function FallbackCard({ kindLabel, payload }: { kindLabel: string; payload: Record<string, string> }) {
|
||||
useLang();
|
||||
return (
|
||||
<div className="fallback-card">
|
||||
<div className="hd">{t("extraCards.unknownKind")}</div>
|
||||
<div className="kv">
|
||||
<span className="k">kind</span>
|
||||
<span className="v">"{kindLabel}"</span>
|
||||
{Object.entries(payload).map(([k, v]) => (
|
||||
<span key={k} style={{ display: "contents" }}>
|
||||
<span className="k">{k}</span>
|
||||
<span className="v">{v}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- LiveCard (pill variants) ----
|
||||
|
||||
export type LiveVariant =
|
||||
| "thinking"
|
||||
| "undo"
|
||||
| "ctxPressure"
|
||||
| "aborted"
|
||||
| "retry"
|
||||
| "checkpoint"
|
||||
| "stepProgress"
|
||||
| "mcpEvent"
|
||||
| "sessionOp";
|
||||
|
||||
export function LiveCard({
|
||||
variant,
|
||||
icon,
|
||||
body,
|
||||
action,
|
||||
onAction,
|
||||
}: {
|
||||
variant: LiveVariant;
|
||||
icon: ReactNode;
|
||||
body: ReactNode;
|
||||
action?: string;
|
||||
onAction?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<span className={`live-card ${variant === "stepProgress" ? "step" : ""}`} data-v={variant}>
|
||||
<span className="lc-ico">{icon}</span>
|
||||
<span className="lc-body">{body}</span>
|
||||
{action ? (
|
||||
<button type="button" className="lc-act" onClick={onAction}>
|
||||
{action}
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { I } from "../icons";
|
||||
import { t, useLang } from "../i18n";
|
||||
import type { JobInfo } from "../protocol";
|
||||
import { Shortcut } from "./shortcut";
|
||||
|
||||
export function JobsPop({
|
||||
open,
|
||||
onClose,
|
||||
jobs,
|
||||
onStop,
|
||||
onStopAll,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
jobs: JobInfo[];
|
||||
onStop: (jobId: number) => void;
|
||||
onStopAll: () => void;
|
||||
}) {
|
||||
useLang();
|
||||
const [tick, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const id = window.setInterval(() => setTick((n) => n + 1), 600);
|
||||
return () => window.clearInterval(id);
|
||||
}, [open]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
const running = useMemo(() => jobs.filter((j) => j.running), [jobs]);
|
||||
const exited = useMemo(() => jobs.filter((j) => !j.running), [jobs]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="jobs-mask" onClick={onClose}>
|
||||
<div className="jobs-pop" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="jobs-head">
|
||||
<span className="ico">
|
||||
<I.cpu size={13} />
|
||||
</span>
|
||||
<div>
|
||||
<div className="tt">{t("jobs.title")}</div>
|
||||
<div className="ss">
|
||||
<span className="b ok">{running.length}</span> {t("jobs.running")} ·{" "}
|
||||
<span className="b mut">{exited.length}</span> {t("jobs.exited")}
|
||||
</div>
|
||||
</div>
|
||||
<span className="grow" />
|
||||
{running.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn danger sm"
|
||||
onClick={onStopAll}
|
||||
title={t("jobs.stopAllTip")}
|
||||
>
|
||||
<I.stop size={10} /> {t("jobs.stopAll")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="jobs-body">
|
||||
{jobs.length === 0 ? (
|
||||
<div className="jobs-empty">{t("jobs.empty")}</div>
|
||||
) : (
|
||||
<>
|
||||
{running.length > 0 ? <div className="jobs-grp">{t("jobs.running")}</div> : null}
|
||||
{running.map((j) => (
|
||||
<JobRow key={j.id} job={j} tick={tick} onStop={onStop} />
|
||||
))}
|
||||
{exited.length > 0 ? <div className="jobs-grp">{t("jobs.exited")}</div> : null}
|
||||
{exited.map((j) => (
|
||||
<JobRow key={j.id} job={j} tick={tick} onStop={onStop} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="jobs-foot">
|
||||
<div className="row">
|
||||
<I.zap size={11} />
|
||||
<span>
|
||||
{t("jobs.running")} <span className="v">{running.length}</span>
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>
|
||||
{t("jobs.exited")} <span className="v">{exited.length}</span>
|
||||
</span>
|
||||
<span className="grow" />
|
||||
<span>
|
||||
<Shortcut keys={["mod", "J"]} /> {t("jobs.kbToggle")} ·{" "}
|
||||
<Shortcut keys={["esc"]} /> {t("jobs.kbClose")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatElapsed(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const s = ms / 1000;
|
||||
if (s < 60) return `${s.toFixed(1)}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
const r = Math.floor(s % 60);
|
||||
return `${m}m${r}s`;
|
||||
}
|
||||
|
||||
function JobRow({
|
||||
job,
|
||||
tick,
|
||||
onStop,
|
||||
}: {
|
||||
job: JobInfo;
|
||||
tick: number;
|
||||
onStop: (jobId: number) => void;
|
||||
}) {
|
||||
const [exp, setExp] = useState(false);
|
||||
const elapsedMs = (job.running ? Date.now() : job.startedAt + 0) - job.startedAt;
|
||||
const liveMs = job.running ? Date.now() - job.startedAt : elapsedMs;
|
||||
// tick is referenced so the row re-renders for the running clock
|
||||
void tick;
|
||||
return (
|
||||
<div className="job-row" data-status={job.running ? "running" : "exited"}>
|
||||
<div className="jr-main" onClick={() => setExp((v) => !v)}>
|
||||
<span className="jr-state">
|
||||
{job.running ? (
|
||||
<span className="spin" />
|
||||
) : (
|
||||
<I.history size={11} style={{ color: "var(--muted-2)" }} />
|
||||
)}
|
||||
</span>
|
||||
<span className="jr-kind">
|
||||
<I.terminal size={11} />
|
||||
<span>shell</span>
|
||||
</span>
|
||||
<div className="jr-body">
|
||||
<div className="nm" title={job.command}>
|
||||
{job.command}
|
||||
</div>
|
||||
<div className="sub">
|
||||
<span className="ses">{job.sessionLabel}</span>
|
||||
{job.spawnError ? <span className="rk">· {job.spawnError}</span> : null}
|
||||
{!job.running && job.exitCode !== null && job.exitCode !== 0 ? (
|
||||
<span className="rk">· exit {job.exitCode}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="jr-time">{formatElapsed(liveMs)}</div>
|
||||
<div className="jr-act">
|
||||
{job.running ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn danger sm"
|
||||
title={t("jobs.stopOne")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onStop(job.id);
|
||||
}}
|
||||
>
|
||||
<I.stop size={10} /> {t("jobs.stop")}
|
||||
</button>
|
||||
) : (
|
||||
<span className="jr-exit" title={`exit ${job.exitCode ?? "—"}`}>
|
||||
{job.exitCode === 0 ? "ok" : `exit ${job.exitCode ?? "?"}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{exp ? (
|
||||
<div className="jr-detail">
|
||||
<div className="kv-grid">
|
||||
<div>
|
||||
<span className="k">job_id</span>
|
||||
<span className="v">{job.id}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="k">pid</span>
|
||||
<span className="v">{job.pid ?? "—"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="k">elapsed</span>
|
||||
<span className="v">{formatElapsed(liveMs)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{job.outputTail ? <pre className="jr-log">{job.outputTail}</pre> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { I } from "../icons";
|
||||
import { t, useLang } from "../i18n";
|
||||
|
||||
export function fmtElapsed(ms: number): string {
|
||||
const s = ms / 1000;
|
||||
return s < 10 ? `${s.toFixed(1)}s` : `${Math.floor(s)}s`;
|
||||
}
|
||||
|
||||
export function useElapsed(active: boolean, startAt?: number): number {
|
||||
const [ms, setMs] = useState(0);
|
||||
const start = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
setMs(0);
|
||||
start.current = null;
|
||||
return;
|
||||
}
|
||||
start.current = startAt ?? performance.now();
|
||||
const id = setInterval(() => {
|
||||
if (start.current !== null) setMs(performance.now() - start.current);
|
||||
}, 80);
|
||||
return () => clearInterval(id);
|
||||
}, [active, startAt]);
|
||||
return ms;
|
||||
}
|
||||
|
||||
export function ThinkingPill({
|
||||
phase = "thinking",
|
||||
label,
|
||||
elapsedMs,
|
||||
}: {
|
||||
phase?: "queued" | "thinking" | "tool";
|
||||
label: string;
|
||||
elapsedMs: number;
|
||||
}) {
|
||||
const color =
|
||||
phase === "queued"
|
||||
? "var(--muted)"
|
||||
: phase === "tool"
|
||||
? "var(--warning)"
|
||||
: "var(--accent)";
|
||||
return (
|
||||
<div className="thinking">
|
||||
<span className="dots" style={{ color }}>
|
||||
<span style={{ background: color }} />
|
||||
<span style={{ background: color }} />
|
||||
<span style={{ background: color }} />
|
||||
</span>
|
||||
<span className="label">
|
||||
<span className="sh">{label}</span>
|
||||
</span>
|
||||
<span className="timer">{fmtElapsed(elapsedMs)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LiveReasoning({ lines }: { lines: string[] }) {
|
||||
useLang();
|
||||
return (
|
||||
<div className="live-reason">
|
||||
<div className="head">
|
||||
<span className="dot" /> {t("live.reasoning")}
|
||||
</div>
|
||||
{lines.map((line, i) => (
|
||||
<div key={i}>
|
||||
{line}
|
||||
{i === lines.length - 1 ? <span className="stream-caret" /> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolRunningCard({
|
||||
kind = "tool",
|
||||
name,
|
||||
elapsedMs,
|
||||
logLines,
|
||||
}: {
|
||||
kind?: "shell" | "fetch" | "search" | "tool";
|
||||
name: string;
|
||||
elapsedMs: number;
|
||||
logLines?: { text: string; tone?: "ok" | "dim" }[];
|
||||
}) {
|
||||
useLang();
|
||||
const ic =
|
||||
kind === "shell" ? <I.terminal size={12} /> : kind === "fetch" ? <I.globe size={12} /> : kind === "search" ? <I.search size={12} /> : <I.wrench size={12} />;
|
||||
return (
|
||||
<div className="skel-card">
|
||||
<div className="h">
|
||||
<span className="ico">{ic}</span>
|
||||
<span className="kind">{kind}</span>
|
||||
<span style={{ color: "var(--fg)", fontWeight: 500 }}>{name}</span>
|
||||
<span className="grow" />
|
||||
<span className="spin-meta" role="img" aria-label={t("live.running")} title={t("live.running")} />
|
||||
<span className="timer">{fmtElapsed(elapsedMs)}</span>
|
||||
</div>
|
||||
{logLines && logLines.length > 0 ? (
|
||||
<div className="live-log">
|
||||
{logLines.map((ln, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`line ${ln.tone ?? ""}`}
|
||||
style={{ animationDelay: `${i * 0.25}s` }}
|
||||
>
|
||||
{ln.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="body">
|
||||
<div className="skel-line w-90" />
|
||||
<div className="skel-line w-70" />
|
||||
<div className="skel-line w-60" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PendingUserMsg({ text, elapsedMs }: { text: string; elapsedMs: number }) {
|
||||
useLang();
|
||||
return (
|
||||
<div className="msg user">
|
||||
<div className="avatar">YOU</div>
|
||||
<div className="body">
|
||||
<div className="who">
|
||||
<span className="name">{t("live.you")}</span>
|
||||
<span className="time">
|
||||
{t("live.secondsAgo", { seconds: (elapsedMs / 1000).toFixed(1) })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="msg-text user-pending">{text}</div>
|
||||
<div className="user-status">
|
||||
<span className="spin" />
|
||||
<span>{t("live.deliveredWaiting")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,60 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type ShortcutKey = "mod" | "shift" | "enter" | "tab" | "esc" | "updown" | string;
|
||||
|
||||
function isMacPlatform(): boolean {
|
||||
if (typeof document === "undefined") return false;
|
||||
return document.documentElement.dataset.platform === "macos";
|
||||
}
|
||||
|
||||
function keyLabel(key: ShortcutKey, mac: boolean): string {
|
||||
switch (key) {
|
||||
case "mod":
|
||||
case "⌘":
|
||||
return mac ? "⌘" : "Ctrl";
|
||||
case "shift":
|
||||
case "⇧":
|
||||
return mac ? "⇧" : "Shift";
|
||||
case "enter":
|
||||
case "⏎":
|
||||
return "Enter";
|
||||
case "tab":
|
||||
case "⇥":
|
||||
return "Tab";
|
||||
case "esc":
|
||||
return "Esc";
|
||||
case "updown":
|
||||
case "↑↓":
|
||||
return "↑↓";
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
export function shortcutText(keys: readonly ShortcutKey[]): string {
|
||||
const mac = isMacPlatform();
|
||||
return keys.map((key) => keyLabel(key, mac)).join(mac ? "" : "+");
|
||||
}
|
||||
|
||||
export function localizeShortcutText(text: string): string {
|
||||
return text.replace(/⌘/g, keyLabel("mod", isMacPlatform()));
|
||||
}
|
||||
|
||||
export function Shortcut({
|
||||
keys,
|
||||
className,
|
||||
}: {
|
||||
keys: readonly ShortcutKey[];
|
||||
className?: string;
|
||||
}): ReactNode {
|
||||
const mac = isMacPlatform();
|
||||
return (
|
||||
<span className={["shortcut", className].filter(Boolean).join(" ")}>
|
||||
{keys.map((key, index) => (
|
||||
<kbd key={`${key}-${index}`} data-key={key}>
|
||||
{keyLabel(key, mac)}
|
||||
</kbd>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import type { SessionInfo } from "../App";
|
||||
import { t, useLang } from "../i18n";
|
||||
import { I } from "../icons";
|
||||
import { Shortcut } from "./shortcut";
|
||||
|
||||
type PendingDelete = {
|
||||
name: string;
|
||||
pretty: string;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
function prettyName(s: SessionInfo): string {
|
||||
if (s.summary && s.summary.trim()) return s.summary.trim();
|
||||
const m = s.name.match(/^desktop-(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(?:-(\d+))?$/);
|
||||
if (m) {
|
||||
const [, , month, day, hh, mm, tab] = m;
|
||||
return `${t("sidebarPanel.sessionTitle", {
|
||||
month,
|
||||
day,
|
||||
hour: hh,
|
||||
minute: mm,
|
||||
})}${tab && tab !== "1" ? ` · #${tab}` : ""}`;
|
||||
}
|
||||
return s.name.replace(/^desktop-/, "").replace(/[-_]+/g, " ");
|
||||
}
|
||||
|
||||
function relative(ms: number): string {
|
||||
const min = ms / 60_000;
|
||||
if (min < 1) return t("sidebarPanel.justNow");
|
||||
if (min < 60) return t("sidebarPanel.minutesAgo", { n: Math.floor(min) });
|
||||
const hr = min / 60;
|
||||
if (hr < 24) return t("sidebarPanel.hoursAgo", { n: Math.floor(hr) });
|
||||
const d = hr / 24;
|
||||
if (d < 7) return t("sidebarPanel.daysAgo", { n: Math.floor(d) });
|
||||
return t("sidebarPanel.weeksAgo", { n: Math.floor(d / 7) });
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
sessions,
|
||||
activeName,
|
||||
loadingName,
|
||||
onNewChat,
|
||||
onLoadSession,
|
||||
onDeleteSession,
|
||||
onOpenSettings,
|
||||
onOpenRules,
|
||||
onOpenCommands,
|
||||
onOpenAbout,
|
||||
}: {
|
||||
sessions: SessionInfo[];
|
||||
activeName?: string;
|
||||
/** Name of the session currently being loaded — drives the per-row spinner. */
|
||||
loadingName?: string | null;
|
||||
onNewChat: () => void;
|
||||
onLoadSession: (name: string) => void;
|
||||
onDeleteSession: (name: string) => void;
|
||||
onOpenSettings: () => void;
|
||||
onOpenRules: () => void;
|
||||
onOpenCommands: () => void;
|
||||
onOpenAbout: () => void;
|
||||
}) {
|
||||
useLang();
|
||||
const [query, setQuery] = useState("");
|
||||
const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(null);
|
||||
const filtered = query
|
||||
? sessions.filter((s) => {
|
||||
const q = query.toLowerCase();
|
||||
return (
|
||||
prettyName(s).toLowerCase().includes(q) || s.name.toLowerCase().includes(q)
|
||||
);
|
||||
})
|
||||
: sessions;
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingDelete) return;
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (!target?.closest(".session-delete-popover")) setPendingDelete(null);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setPendingDelete(null);
|
||||
};
|
||||
window.addEventListener("mousedown", onMouseDown);
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
window.removeEventListener("mousedown", onMouseDown);
|
||||
window.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [pendingDelete]);
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="side-head">
|
||||
<button type="button" className="new-btn" onClick={onNewChat}>
|
||||
<I.plus size={14} />
|
||||
<span>{t("sidebarPanel.newChat")}</span>
|
||||
<Shortcut keys={["mod", "N"]} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn"
|
||||
title={t("sidebarPanel.commandPalette")}
|
||||
onClick={onOpenCommands}
|
||||
>
|
||||
<I.history size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="search-row">
|
||||
<div className="input">
|
||||
<I.search size={13} />
|
||||
<input
|
||||
placeholder={t("sidebarPanel.searchSessions")}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<Shortcut keys={["mod", "K"]} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="session-list">
|
||||
<div className="side-section">
|
||||
<div className="label">
|
||||
<span>{t("sidebarPanel.recent")}</span>
|
||||
<span className="count">{filtered.length}</span>
|
||||
</div>
|
||||
{sessions.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 8px",
|
||||
fontSize: 11,
|
||||
color: "var(--muted-2)",
|
||||
fontFamily: "Geist Mono, monospace",
|
||||
}}
|
||||
>
|
||||
{t("sidebarPanel.noSessions")}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 8px",
|
||||
fontSize: 11,
|
||||
color: "var(--muted-2)",
|
||||
fontFamily: "Geist Mono, monospace",
|
||||
}}
|
||||
>
|
||||
{t("sidebarPanel.noMatches")}
|
||||
</div>
|
||||
) : null}
|
||||
{filtered.map((s) => {
|
||||
const active = s.name === activeName;
|
||||
const loading = s.name === loadingName;
|
||||
const mtime = Date.parse(s.mtime);
|
||||
const updated = Number.isFinite(mtime) ? relative(Date.now() - mtime) : s.mtime;
|
||||
return (
|
||||
<div
|
||||
key={s.name}
|
||||
className="session-item"
|
||||
data-active={active}
|
||||
data-loading={loading || undefined}
|
||||
style={loadingName && !loading ? { opacity: 0.5, pointerEvents: "none" } : undefined}
|
||||
onClick={() => onLoadSession(s.name)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title={s.name}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onLoadSession(s.name);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="state"
|
||||
style={{
|
||||
background: loading
|
||||
? "var(--accent)"
|
||||
: active
|
||||
? "var(--accent)"
|
||||
: "var(--border-strong)",
|
||||
animation: loading ? "session-pulse 0.9s ease-in-out infinite" : undefined,
|
||||
}}
|
||||
/>
|
||||
<div className="body">
|
||||
<span className="title">{prettyName(s)}</span>
|
||||
<span className="meta">
|
||||
{loading ? (
|
||||
<span>{t("sidebarPanel.loading")}</span>
|
||||
) : (
|
||||
<>
|
||||
<span>{t("sidebarPanel.messageCount", { count: s.messageCount })}</span>
|
||||
<span className="sep">·</span>
|
||||
<span>{updated}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="delete-btn"
|
||||
title={t("sidebarPanel.deleteSession")}
|
||||
aria-label={t("sidebarPanel.deleteSession")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
setPendingDelete({
|
||||
name: s.name,
|
||||
pretty: prettyName(s),
|
||||
x: rect.right,
|
||||
y: rect.bottom,
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<I.x size={12} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="side-foot">
|
||||
<div className="row" onClick={onOpenRules}>
|
||||
<span className="ico">
|
||||
<I.shield size={13} />
|
||||
</span>
|
||||
<span>{t("sidebarPanel.approvalRules")}</span>
|
||||
</div>
|
||||
<div className="row" onClick={onOpenAbout}>
|
||||
<span className="ico">
|
||||
<I.help size={13} />
|
||||
</span>
|
||||
<span>{t("about.sidebarLabel")}</span>
|
||||
</div>
|
||||
<div className="row" onClick={onOpenSettings}>
|
||||
<span className="ico">
|
||||
<I.cog size={13} />
|
||||
</span>
|
||||
<span>{t("sidebarPanel.settings")}</span>
|
||||
<span className="right">
|
||||
<Shortcut keys={["mod", ","]} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pendingDelete ? (
|
||||
<SessionDeletePopover
|
||||
target={pendingDelete}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
onConfirm={() => {
|
||||
onDeleteSession(pendingDelete.name);
|
||||
setPendingDelete(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionDeletePopover({
|
||||
target,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
target: PendingDelete;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||
const [pos, setPos] = useState<{ left: number; top: number }>({
|
||||
left: target.x,
|
||||
top: target.y,
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const pad = 8;
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
let left = target.x;
|
||||
let top = target.y;
|
||||
if (left + rect.width + pad > vw) left = Math.max(pad, vw - rect.width - pad);
|
||||
if (top + rect.height + pad > vh) top = Math.max(pad, vh - rect.height - pad);
|
||||
if (left !== pos.left || top !== pos.top) setPos({ left, top });
|
||||
cancelRef.current?.focus();
|
||||
}, [target.x, target.y, pos.left, pos.top]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="session-delete-popover"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
style={{ left: pos.left, top: pos.top }}
|
||||
>
|
||||
<div className="msg">
|
||||
{t("sidebarPanel.deleteSession")}
|
||||
<span className="name">{target.pretty}</span>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button ref={cancelRef} type="button" className="cancel" onClick={onCancel}>
|
||||
{t("sidebarPanel.cancel")}
|
||||
</button>
|
||||
<button type="button" className="confirm" onClick={onConfirm}>
|
||||
<I.x size={11} />
|
||||
{t("sidebarPanel.delete")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
|
||||
const SPLASH_FLAG = "reasonix.splash.shown";
|
||||
|
||||
export function shouldShowSplash(): boolean {
|
||||
try {
|
||||
return sessionStorage.getItem(SPLASH_FLAG) !== "1";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function markSplashShown() {
|
||||
try {
|
||||
sessionStorage.setItem(SPLASH_FLAG, "1");
|
||||
} catch {
|
||||
/* sessionStorage unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
export function Splash({ onDone }: { onDone: () => void }) {
|
||||
const [leaving, setLeaving] = useState(false);
|
||||
const onDoneRef = useRef(onDone);
|
||||
onDoneRef.current = onDone;
|
||||
|
||||
useEffect(() => {
|
||||
const t1 = window.setTimeout(() => setLeaving(true), 1350);
|
||||
const t2 = window.setTimeout(() => {
|
||||
markSplashShown();
|
||||
onDoneRef.current();
|
||||
}, 1800);
|
||||
return () => {
|
||||
window.clearTimeout(t1);
|
||||
window.clearTimeout(t2);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const skip = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape" && e.key !== "Enter" && e.key !== " ") return;
|
||||
markSplashShown();
|
||||
onDoneRef.current();
|
||||
};
|
||||
window.addEventListener("keydown", skip);
|
||||
return () => window.removeEventListener("keydown", skip);
|
||||
}, []);
|
||||
|
||||
const skipClick = () => {
|
||||
markSplashShown();
|
||||
onDone();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="splash" data-leaving={leaving} onClick={skipClick}>
|
||||
<div className="splash-card">
|
||||
<div className="splash-mark" />
|
||||
<div className="splash-name">Reasonix</div>
|
||||
<div className="splash-sub">{t("app.splashSubtitle")}</div>
|
||||
<div className="splash-dots">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { I } from "../icons";
|
||||
import { t } from "../i18n";
|
||||
import type { Balance, Settings, UsageStats } from "../App";
|
||||
import type { JobInfo } from "../protocol";
|
||||
import { THEME, THEME_STYLES, type Theme, type ThemeStyle, themeForStyle } from "../theme";
|
||||
import { localizeShortcutText } from "./shortcut";
|
||||
|
||||
const USD_TO_CNY = 7.2;
|
||||
|
||||
function formatMoney(amountUsd: number, currency: "CNY" | "USD"): string {
|
||||
const symbol = currency === "CNY" ? "¥" : "$";
|
||||
const amount = currency === "CNY" ? amountUsd * USD_TO_CNY : amountUsd;
|
||||
return `${symbol} ${amount.toFixed(4)}`;
|
||||
}
|
||||
|
||||
function tokenLabel(n: number): string {
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
|
||||
return `${n}`;
|
||||
}
|
||||
|
||||
/** Format a 0–100 percentage to 2 decimal places.
|
||||
* 100.00 is shown only when the value is exactly 100; values that would round
|
||||
* to 100.00 are capped at 99.99. */
|
||||
function formatPct(value: number): string {
|
||||
if (value === 100) return "100.00";
|
||||
const rounded = Math.round(value * 100) / 100;
|
||||
if (rounded >= 100) return "99.99";
|
||||
return rounded.toFixed(2);
|
||||
}
|
||||
|
||||
export function StatusBar({
|
||||
settings,
|
||||
balance,
|
||||
usage,
|
||||
busy,
|
||||
ready,
|
||||
currency,
|
||||
theme,
|
||||
themeStyle,
|
||||
jobs,
|
||||
jobsOpen,
|
||||
onToggleJobs,
|
||||
onSetThemeStyle,
|
||||
onToggleCurrency,
|
||||
onOpenSettings,
|
||||
onOpenWorkdir,
|
||||
}: {
|
||||
settings: Settings | null;
|
||||
balance: Balance | null;
|
||||
usage: UsageStats;
|
||||
busy: boolean;
|
||||
ready: boolean;
|
||||
currency: "CNY" | "USD";
|
||||
theme: Theme;
|
||||
themeStyle: ThemeStyle;
|
||||
jobs: JobInfo[];
|
||||
jobsOpen: boolean;
|
||||
onToggleJobs: () => void;
|
||||
onSetThemeStyle: (style: ThemeStyle) => void;
|
||||
onToggleCurrency: () => void;
|
||||
onOpenSettings: () => void;
|
||||
onOpenWorkdir?: (anchor: { bottom: number; left: number }) => void;
|
||||
}) {
|
||||
const totalTokens = usage.cacheHitTokens + usage.cacheMissTokens;
|
||||
const cacheHitRaw = totalTokens > 0 ? (usage.cacheHitTokens / totalTokens) * 100 : 0;
|
||||
const cacheHitPctDisplay = formatPct(cacheHitRaw);
|
||||
const cacheHitDetail =
|
||||
totalTokens > 0
|
||||
? `${usage.cacheHitTokens.toLocaleString()} / ${totalTokens.toLocaleString()} tokens (${cacheHitPctDisplay}%)`
|
||||
: "";
|
||||
const runningJobs = jobs.filter((j) => j.running).length;
|
||||
const spent = formatMoney(usage.totalCostUsd, currency);
|
||||
const balanceLabel = balance
|
||||
? `${balance.currency === "USD" ? "$" : "¥"} ${balance.total.toFixed(2)}`
|
||||
: "—";
|
||||
const connState = !ready ? "off" : busy ? "running" : "online";
|
||||
const [themeOpen, setThemeOpen] = useState(false);
|
||||
const themePopRef = useRef<HTMLDivElement | null>(null);
|
||||
const themeButtonRef = useRef<HTMLSpanElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!themeOpen) return;
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (themePopRef.current?.contains(target) || themeButtonRef.current?.contains(target)) return;
|
||||
setThemeOpen(false);
|
||||
};
|
||||
window.addEventListener("pointerdown", onPointerDown);
|
||||
return () => window.removeEventListener("pointerdown", onPointerDown);
|
||||
}, [themeOpen]);
|
||||
|
||||
return (
|
||||
<footer className="statusbar">
|
||||
<span className="seg" title={`API · ${settings?.baseUrl ?? "api.deepseek.com"}`}>
|
||||
<span
|
||||
className={connState === "off" ? "sw warn" : "sw"}
|
||||
style={connState === "off" ? { background: "var(--danger)" } : undefined}
|
||||
/>
|
||||
<span>{settings?.baseUrl?.replace(/^https?:\/\//, "") ?? "api.deepseek.com"}</span>
|
||||
<span className="v">{!ready ? t("statusbar.offline") : busy ? t("statusbar.busy") : t("statusbar.online")}</span>
|
||||
</span>
|
||||
<span className="seg" title={cacheHitDetail || t("statusbar.cacheHit")}>
|
||||
<I.zap size={11} style={{ color: "var(--accent)" }} />
|
||||
<span>{t("statusbar.cache")}</span>
|
||||
<span className="v acc">{cacheHitPctDisplay}%</span>
|
||||
</span>
|
||||
<span className="seg">
|
||||
<I.cpu size={11} />
|
||||
<span>{t("statusbar.tokens")}</span>
|
||||
<span className="v">{tokenLabel(totalTokens)}</span>
|
||||
</span>
|
||||
<span className="seg">
|
||||
<I.coin size={11} />
|
||||
<span>{t("statusbar.thisTurn")}</span>
|
||||
<span className="v ok">{spent}</span>
|
||||
</span>
|
||||
|
||||
<span className="grow" />
|
||||
|
||||
<span
|
||||
className={`seg jobs ${jobsOpen ? "active" : ""}`}
|
||||
onClick={onToggleJobs}
|
||||
title={localizeShortcutText(t("statusbar.jobsTip"))}
|
||||
>
|
||||
<I.cpu size={11} />
|
||||
<span>{t("statusbar.jobs")}</span>
|
||||
<span className={runningJobs > 0 ? "v acc" : "v"}>{runningJobs}</span>
|
||||
</span>
|
||||
|
||||
{settings?.workspaceDir ? (
|
||||
<span
|
||||
className="seg"
|
||||
title={t("statusbar.switchWorkspace", { workspace: settings.workspaceDir })}
|
||||
style={onOpenWorkdir ? { cursor: "pointer" } : undefined}
|
||||
onClick={(e) => {
|
||||
if (!onOpenWorkdir) return;
|
||||
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
onOpenWorkdir({ bottom: window.innerHeight - r.top + 6, left: r.left });
|
||||
}}
|
||||
>
|
||||
<I.folder size={11} />
|
||||
<span className="v">{settings.workspaceDir.split(/[\\/]/).pop() || "ws"}</span>
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
className="seg"
|
||||
title={`model · effort ${settings?.reasoningEffort ?? "high"}`}
|
||||
onClick={onOpenSettings}
|
||||
>
|
||||
<I.brain size={11} style={{ color: "var(--violet)" }} />
|
||||
<span className="v vio">{settings?.model ?? "—"}</span>
|
||||
<span className="v">{settings?.reasoningEffort ?? "high"}</span>
|
||||
</span>
|
||||
<span className="seg" title={t("statusbar.switchCurrency")} onClick={onToggleCurrency}>
|
||||
<I.coin size={11} />
|
||||
<span>{t("statusbar.balance")}</span>
|
||||
<span className="v ok">{balanceLabel}</span>
|
||||
</span>
|
||||
<span
|
||||
ref={themeButtonRef}
|
||||
className={`seg theme-trigger ${themeOpen ? "active" : ""}`}
|
||||
title={t("statusbar.switchTheme")}
|
||||
onClick={() => setThemeOpen((open) => !open)}
|
||||
>
|
||||
{theme === THEME.DARK ? <I.moon size={11} /> : <I.sun size={11} />}
|
||||
<span className="v">
|
||||
{t(`statusbar.themeStyle${themeStyle[0]!.toUpperCase()}${themeStyle.slice(1)}` as any)}
|
||||
</span>
|
||||
</span>
|
||||
{themeOpen ? (
|
||||
<div ref={themePopRef} className="theme-pop" role="menu" aria-label={t("settings.themeStyle")}>
|
||||
<div className="theme-pop-head">
|
||||
<div className="tt">{t("settings.themeStyle")}</div>
|
||||
<div className="ss">{t("statusbar.switchTheme")}</div>
|
||||
</div>
|
||||
<div className="theme-pop-list">
|
||||
{THEME_STYLES.map((style) => (
|
||||
<button
|
||||
key={style}
|
||||
type="button"
|
||||
className="theme-pop-item"
|
||||
data-on={themeStyle === style}
|
||||
data-style={style}
|
||||
onClick={() => {
|
||||
onSetThemeStyle(style);
|
||||
setThemeOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="style-swatches" aria-hidden="true">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
<span className="txt">
|
||||
<span className="nm">
|
||||
{t(`statusbar.themeStyle${style[0]!.toUpperCase()}${style.slice(1)}` as any)}
|
||||
</span>
|
||||
<span className="md">
|
||||
{themeForStyle(style) === THEME.DARK
|
||||
? t("statusbar.themeDark")
|
||||
: t("statusbar.themeLight")}
|
||||
</span>
|
||||
</span>
|
||||
{themeStyle === style ? <I.check size={13} /> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
import { isCompactionSummary, stripCompactionMarker } from "@reasonix/core-utils/compaction";
|
||||
import { derivePrefix } from "@reasonix/core-utils/derive-prefix";
|
||||
import { memo, useState, type ReactNode } from "react";
|
||||
import { Copy } from "lucide-react";
|
||||
import { I } from "../icons";
|
||||
import { t, useLang } from "../i18n";
|
||||
import type { AssistantSegment, ActivePlan, PendingPlan, PendingCheckpoint, PendingRevision, PendingConfirm, PendingChoice, SkillOrigin } from "../App";
|
||||
import { AssistantText, CompactionCard, PlanCardView, ReasoningCard, ShellCard, ToolCard, type PlanItem } from "./cards";
|
||||
import { ApprovalCard, TaskCard, type TaskStepView } from "./extra-cards";
|
||||
|
||||
export function TurnDivider({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="turn-divider">
|
||||
<span>{label}</span>
|
||||
<span className="line" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const UserMsg = memo(function UserMsg({
|
||||
text,
|
||||
time,
|
||||
skill,
|
||||
}: {
|
||||
text: string;
|
||||
time?: string;
|
||||
skill?: SkillOrigin;
|
||||
}) {
|
||||
useLang();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const onCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1200);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="msg user">
|
||||
<div className="avatar">YOU</div>
|
||||
<div className="body">
|
||||
<div className="who">
|
||||
<span className="name">{t("thread.you")}</span>
|
||||
{skill ? (
|
||||
<span className="skill-chip" title={`skill · ${skill.runAs}`}>
|
||||
<I.zap size={10} /> /{skill.name}
|
||||
{skill.runAs === "subagent" ? <span className="sub">{t("thread.subagent")}</span> : null}
|
||||
</span>
|
||||
) : null}
|
||||
{time ? <span className="time">{time}</span> : null}
|
||||
</div>
|
||||
<div className="msg-text">{text}</div>
|
||||
<div className="msg-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`copy-btn ${copied ? "done" : ""}`}
|
||||
onClick={onCopy}
|
||||
title={t("thread.copyMessage")}
|
||||
>
|
||||
<Copy size={11} />
|
||||
{copied ? t("markdown.copied") : null}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const AssistantMsg = memo(function AssistantMsg({
|
||||
segments,
|
||||
pending,
|
||||
model,
|
||||
time,
|
||||
onApproveConfirm,
|
||||
onRejectConfirm,
|
||||
onAlwaysAllowConfirm,
|
||||
pendingConfirms,
|
||||
}: {
|
||||
segments: AssistantSegment[];
|
||||
pending: boolean;
|
||||
model?: string;
|
||||
time?: string;
|
||||
onApproveConfirm: (id: number) => void;
|
||||
onRejectConfirm: (id: number) => void;
|
||||
onAlwaysAllowConfirm: (id: number, prefix: string) => void;
|
||||
pendingConfirms: PendingConfirm[];
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const content = segments
|
||||
.filter((s): s is AssistantSegment & { kind: "text" } => s.kind === "text")
|
||||
.map((s) => s.text)
|
||||
.join("\n\n");
|
||||
const onCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1200);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="msg assistant">
|
||||
<div className="avatar">DS</div>
|
||||
<div className="body">
|
||||
<div className="who">
|
||||
<span className="name">Reasonix</span>
|
||||
{model ? <span className="model">{model}</span> : null}
|
||||
{time ? <span className="time">{time}</span> : null}
|
||||
</div>
|
||||
{segments.map((s, i) => {
|
||||
if (s.kind === "text") {
|
||||
if (!s.text.trim()) return null;
|
||||
if (isCompactionSummary(s.text)) {
|
||||
return <CompactionCard key={i} summary={stripCompactionMarker(s.text)} />;
|
||||
}
|
||||
return <AssistantText key={i} text={s.text} />;
|
||||
}
|
||||
if (s.kind === "reasoning") {
|
||||
return (
|
||||
<ReasoningCard
|
||||
key={i}
|
||||
text={s.text}
|
||||
streaming={pending && i === segments.length - 1}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// tool segment
|
||||
const pendingConfirm =
|
||||
(s.name === "run_command" || s.name === "run_background") && s.result === undefined
|
||||
? pendingConfirms.find((c) => c.command === extractCommand(s.args))
|
||||
: undefined;
|
||||
if (s.name === "run_command" || s.name === "run_background") {
|
||||
const cmd = extractCommand(s.args) ?? s.args;
|
||||
const state: "await" | "running" | "done" | "failed" =
|
||||
s.result === undefined
|
||||
? pendingConfirm
|
||||
? "await"
|
||||
: "running"
|
||||
: s.ok === false
|
||||
? "failed"
|
||||
: "done";
|
||||
return (
|
||||
<ShellCard
|
||||
key={i}
|
||||
command={cmd}
|
||||
output={s.result}
|
||||
state={state}
|
||||
durationMs={s.durationMs}
|
||||
onApprove={pendingConfirm ? () => onApproveConfirm(pendingConfirm.id) : undefined}
|
||||
onReject={pendingConfirm ? () => onRejectConfirm(pendingConfirm.id) : undefined}
|
||||
onAlwaysAllow={
|
||||
pendingConfirm
|
||||
? () => {
|
||||
onAlwaysAllowConfirm(pendingConfirm.id, derivePrefix(cmd));
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ToolCard
|
||||
key={i}
|
||||
name={s.name}
|
||||
args={s.args}
|
||||
result={s.result}
|
||||
ok={s.ok}
|
||||
durationMs={s.durationMs}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{content ? (
|
||||
<div className="msg-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`copy-btn ${copied ? "done" : ""}`}
|
||||
onClick={onCopy}
|
||||
title={t("thread.copyResponse")}
|
||||
>
|
||||
<Copy size={11} />
|
||||
{copied ? t("markdown.copied") : null}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function extractCommand(args: string): string | undefined {
|
||||
if (!args) return undefined;
|
||||
try {
|
||||
const v = JSON.parse(args);
|
||||
if (v && typeof v === "object" && typeof v.command === "string") return v.command;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function PlanBanner({
|
||||
plan,
|
||||
onDismiss,
|
||||
}: {
|
||||
plan: ActivePlan;
|
||||
onDismiss?: () => void;
|
||||
}) {
|
||||
useLang();
|
||||
const total = plan.steps.length || 1;
|
||||
const done = plan.completedStepIds.length;
|
||||
const pct = (done / total) * 100;
|
||||
const current = plan.steps.find((s) => !plan.completedStepIds.includes(s.id));
|
||||
return (
|
||||
<div className="plan-banner">
|
||||
<span className="ico">
|
||||
<I.list size={14} />
|
||||
</span>
|
||||
<div className="body">
|
||||
<div className="t">
|
||||
{t("thread.planRunning", { step: Math.min(done + 1, total), total })}
|
||||
{current ? ` — ${current.title}` : ""}
|
||||
</div>
|
||||
<div className="s">{plan.plan}</div>
|
||||
</div>
|
||||
<div className="prog">
|
||||
<div className="meter-mini">
|
||||
<span style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
{onDismiss ? (
|
||||
<button type="button" onClick={onDismiss}>
|
||||
{t("thread.collapse")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActivePlanCard({ plan }: { plan: ActivePlan }) {
|
||||
useLang();
|
||||
const done = new Set(plan.completedStepIds);
|
||||
const items: PlanItem[] = plan.steps.map((s) => {
|
||||
let status: PlanItem["status"];
|
||||
if (done.has(s.id)) status = "done";
|
||||
else if (s === plan.steps.find((x) => !done.has(x.id))) status = "active";
|
||||
else status = "todo";
|
||||
return {
|
||||
id: s.id,
|
||||
status,
|
||||
text: s.title,
|
||||
tool: s.action,
|
||||
note: s.risk ? `${t("thread.risk")}: ${s.risk}` : undefined,
|
||||
};
|
||||
});
|
||||
return <PlanCardView items={items} title={t("thread.activePlan")} />;
|
||||
}
|
||||
|
||||
// ---- Approval bindings ----
|
||||
|
||||
export function PlanApprovalCard({
|
||||
p,
|
||||
onApprove,
|
||||
onRefine,
|
||||
onCancel,
|
||||
}: {
|
||||
p: PendingPlan;
|
||||
onApprove: () => void;
|
||||
onRefine: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
useLang();
|
||||
const stepCount = p.steps?.length ?? 0;
|
||||
const sub = stepCount > 0 ? t("thread.planStepCount", { count: stepCount }) : undefined;
|
||||
return (
|
||||
<ApprovalCard
|
||||
kind={t("thread.planConfirmationKind")}
|
||||
tone="info"
|
||||
title={t("thread.startPlan")}
|
||||
sub={sub}
|
||||
body={
|
||||
<>
|
||||
{p.summary ? <div style={{ marginBottom: 6 }}>{p.summary}</div> : null}
|
||||
<div style={{ whiteSpace: "pre-wrap" }}>{p.plan}</div>
|
||||
</>
|
||||
}
|
||||
meta={`plan/#${p.id}`}
|
||||
primaryLabel={t("thread.approve")}
|
||||
secondaryLabel={t("thread.cancel")}
|
||||
tertiaryLabel={t("thread.refine")}
|
||||
onPrimary={onApprove}
|
||||
onSecondary={onCancel}
|
||||
onTertiary={onRefine}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CheckpointApprovalCard({
|
||||
c,
|
||||
onContinue,
|
||||
onRevise,
|
||||
onStop,
|
||||
}: {
|
||||
c: PendingCheckpoint;
|
||||
onContinue: () => void;
|
||||
onRevise: () => void;
|
||||
onStop: () => void;
|
||||
}) {
|
||||
useLang();
|
||||
return (
|
||||
<ApprovalCard
|
||||
kind={t("thread.checkpointKind")}
|
||||
tone="brand"
|
||||
title={c.title ?? t("thread.checkpointTitle", { completed: c.completed, total: c.total })}
|
||||
sub={t("thread.checkpointSub", { completed: c.completed, total: c.total })}
|
||||
body={
|
||||
<>
|
||||
<div style={{ whiteSpace: "pre-wrap" }}>{c.result}</div>
|
||||
{c.notes ? (
|
||||
<div style={{ marginTop: 8, fontSize: 11.5, color: "var(--muted)" }}>{c.notes}</div>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
meta={`checkpoint · ${c.stepId}`}
|
||||
primaryLabel={t("thread.continue")}
|
||||
secondaryLabel={t("thread.stop")}
|
||||
tertiaryLabel={t("thread.revise")}
|
||||
onPrimary={onContinue}
|
||||
onSecondary={onStop}
|
||||
onTertiary={onRevise}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function RevisionApprovalCard({
|
||||
r,
|
||||
onAccept,
|
||||
onReject,
|
||||
}: {
|
||||
r: PendingRevision;
|
||||
onAccept: () => void;
|
||||
onReject: () => void;
|
||||
}) {
|
||||
useLang();
|
||||
return (
|
||||
<ApprovalCard
|
||||
kind={t("thread.planRevisionKind")}
|
||||
tone="warn"
|
||||
title={t("thread.rewritePlan")}
|
||||
sub={t("thread.keepSteps", { n: r.remainingSteps.length })}
|
||||
body={
|
||||
<>
|
||||
<div style={{ marginBottom: 8 }}>{r.reason}</div>
|
||||
{r.summary ? (
|
||||
<div style={{ fontSize: 11.5, color: "var(--muted)", marginBottom: 8 }}>{r.summary}</div>
|
||||
) : null}
|
||||
<ul style={{ margin: 0, paddingLeft: 18 }}>
|
||||
{r.remainingSteps.map((s) => (
|
||||
<li key={s.id} style={{ fontSize: 12, marginBottom: 2 }}>
|
||||
{s.title}
|
||||
{s.risk ? (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 6,
|
||||
fontSize: 10,
|
||||
color:
|
||||
s.risk === "high"
|
||||
? "var(--tone-err)"
|
||||
: s.risk === "med"
|
||||
? "var(--tone-warn)"
|
||||
: "var(--muted)",
|
||||
}}
|
||||
>
|
||||
[{s.risk}]
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
}
|
||||
meta={t("thread.revisionMeta")}
|
||||
primaryLabel={t("thread.approveRewrite")}
|
||||
secondaryLabel={t("thread.keepOriginal")}
|
||||
onPrimary={onAccept}
|
||||
onSecondary={onReject}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfirmApprovalCard({
|
||||
c,
|
||||
onAllow,
|
||||
onAlwaysAllow,
|
||||
onDeny,
|
||||
}: {
|
||||
c: PendingConfirm;
|
||||
onAllow: () => void;
|
||||
onAlwaysAllow: (prefix: string) => void;
|
||||
onDeny: () => void;
|
||||
}) {
|
||||
useLang();
|
||||
const isBackground = c.kind === "run_background";
|
||||
const prefix = derivePrefix(c.command);
|
||||
return (
|
||||
<ApprovalCard
|
||||
kind={t("thread.shellConfirmationKind")}
|
||||
tone="warn"
|
||||
title={isBackground ? t("thread.runBackgroundCommand") : t("thread.runCommand")}
|
||||
sub={c.command.length > 80 ? `${c.command.slice(0, 80)}…` : c.command}
|
||||
preview={
|
||||
<>
|
||||
<span style={{ color: "var(--accent)" }}>$</span> {c.command}
|
||||
</>
|
||||
}
|
||||
meta={t("thread.riskMedium", { kind: c.kind })}
|
||||
primaryLabel={t("thread.execute")}
|
||||
secondaryLabel={t("thread.reject")}
|
||||
tertiaryLabel={t("thread.alwaysAllow", { prefix })}
|
||||
onPrimary={onAllow}
|
||||
onSecondary={onDeny}
|
||||
onTertiary={() => onAlwaysAllow(prefix)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function PathAccessApprovalCard({
|
||||
p,
|
||||
onAllow,
|
||||
onAlwaysAllow,
|
||||
onDeny,
|
||||
}: {
|
||||
p: {
|
||||
id: number;
|
||||
path: string;
|
||||
intent: "read" | "write";
|
||||
toolName: string;
|
||||
sandboxRoot: string;
|
||||
allowPrefix: string;
|
||||
};
|
||||
onAllow: () => void;
|
||||
onAlwaysAllow: (prefix: string) => void;
|
||||
onDeny: () => void;
|
||||
}) {
|
||||
useLang();
|
||||
const isWrite = p.intent === "write";
|
||||
return (
|
||||
<ApprovalCard
|
||||
kind={t("thread.pathAccessKind")}
|
||||
tone="warn"
|
||||
title={isWrite ? t("thread.writePathOutsideSandbox") : t("thread.readPathOutsideSandbox")}
|
||||
sub={p.path}
|
||||
preview={
|
||||
<>
|
||||
<div>{p.toolName} → {p.path}</div>
|
||||
<div style={{ color: "var(--muted)", marginTop: 4 }}>
|
||||
workspace: {p.sandboxRoot}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
meta={t("thread.riskMedium", { kind: p.intent })}
|
||||
primaryLabel={isWrite ? t("thread.allowWrite") : t("thread.allowRead")}
|
||||
secondaryLabel={t("thread.reject")}
|
||||
tertiaryLabel={t("thread.alwaysAllowPrefix", { prefix: p.allowPrefix })}
|
||||
onPrimary={onAllow}
|
||||
onSecondary={onDeny}
|
||||
onTertiary={() => onAlwaysAllow(p.allowPrefix)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChoiceApprovalCard({
|
||||
c,
|
||||
onPick,
|
||||
onCancel,
|
||||
}: {
|
||||
c: PendingChoice;
|
||||
onPick: (optionId: string) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
useLang();
|
||||
return (
|
||||
<ApprovalCard
|
||||
kind={t("thread.userChoiceKind")}
|
||||
tone="info"
|
||||
title={c.question}
|
||||
sub={t("thread.optionCount", { count: c.options.length })}
|
||||
body={
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{c.options.map((o) => (
|
||||
<button
|
||||
key={o.id}
|
||||
type="button"
|
||||
className="btn"
|
||||
style={{ justifyContent: "flex-start", textAlign: "left" }}
|
||||
onClick={() => onPick(o.id)}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>{o.title}</div>
|
||||
{o.summary ? (
|
||||
<div style={{ fontSize: 11, color: "var(--muted)", marginTop: 2 }}>
|
||||
{o.summary}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
primaryLabel={t("thread.cancel")}
|
||||
onPrimary={onCancel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function activePlanToTaskSteps(plan: ActivePlan): TaskStepView[] {
|
||||
const done = new Set(plan.completedStepIds);
|
||||
return plan.steps.map((s, i) => ({
|
||||
n: String(i + 1),
|
||||
state: done.has(s.id) ? "done" : i === plan.completedStepIds.length ? "running" : "queued",
|
||||
label: s.title,
|
||||
hint: s.action,
|
||||
durationLabel: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
export function ActivePlanTaskCard({ plan }: { plan: ActivePlan }) {
|
||||
useLang();
|
||||
return (
|
||||
<TaskCard
|
||||
title={t("thread.activePlan")}
|
||||
subtitle={plan.summary}
|
||||
steps={activePlanToTaskSteps(plan)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function HeaderHint({ children }: { children: ReactNode }) {
|
||||
return <div className="msg-text">{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
type TextSegmentLike = {
|
||||
kind: "text" | "reasoning";
|
||||
text: string;
|
||||
};
|
||||
|
||||
type ToolSegmentLike = {
|
||||
kind: "tool";
|
||||
args: string;
|
||||
result?: string;
|
||||
};
|
||||
|
||||
type SegmentLike = TextSegmentLike | ToolSegmentLike | { kind: string };
|
||||
|
||||
type AssistantMessageLike = {
|
||||
kind: "assistant";
|
||||
pending?: boolean;
|
||||
segments: SegmentLike[];
|
||||
};
|
||||
|
||||
export const TRANSCRIPT_RECENT_MESSAGE_WINDOW = 120;
|
||||
const MIN_ELIDE_CHARS = 4096;
|
||||
const ELIDED_PREFIX = "[elided -- older than the last ";
|
||||
|
||||
function isAssistantMessage(value: unknown): value is AssistantMessageLike {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
(value as { kind?: unknown }).kind === "assistant" &&
|
||||
Array.isArray((value as { segments?: unknown }).segments)
|
||||
);
|
||||
}
|
||||
|
||||
function isElided(value: unknown): boolean {
|
||||
return typeof value === "string" && value.startsWith(ELIDED_PREFIX);
|
||||
}
|
||||
|
||||
function elidedStub(chars: number): string {
|
||||
return `${ELIDED_PREFIX}${TRANSCRIPT_RECENT_MESSAGE_WINDOW} messages; ${chars.toLocaleString()} chars dropped to keep long sessions responsive. Full content remains in the session log.]`;
|
||||
}
|
||||
|
||||
function elideField(value: string): string {
|
||||
if (value.length <= MIN_ELIDE_CHARS || isElided(value)) return value;
|
||||
return elidedStub(value.length);
|
||||
}
|
||||
|
||||
function elideSegment<S extends SegmentLike>(segment: S): S {
|
||||
if (
|
||||
(segment.kind === "text" || segment.kind === "reasoning") &&
|
||||
typeof (segment as TextSegmentLike).text === "string"
|
||||
) {
|
||||
const textSegment = segment as TextSegmentLike;
|
||||
const text = elideField(textSegment.text);
|
||||
return text === textSegment.text ? segment : ({ ...segment, text } as S);
|
||||
}
|
||||
if (segment.kind !== "tool") return segment;
|
||||
|
||||
const toolSegment = segment as ToolSegmentLike;
|
||||
let next: S | null = null;
|
||||
const args = elideField(toolSegment.args);
|
||||
if (args !== toolSegment.args) next = { ...segment, args } as S;
|
||||
if (typeof toolSegment.result === "string") {
|
||||
const result = elideField(toolSegment.result);
|
||||
if (result !== toolSegment.result) next = { ...(next ?? segment), result } as S;
|
||||
}
|
||||
return next ?? segment;
|
||||
}
|
||||
|
||||
function elideSegments<S extends SegmentLike>(segments: S[]): S[] {
|
||||
let next: S[] | null = null;
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const segment = segments[i];
|
||||
if (!segment) continue;
|
||||
const elided = elideSegment(segment);
|
||||
if (elided !== segment) {
|
||||
if (next === null) next = segments.slice();
|
||||
next[i] = elided;
|
||||
}
|
||||
}
|
||||
return next ?? segments;
|
||||
}
|
||||
|
||||
export function elideTranscriptMessages<T>(messages: T[]): T[] {
|
||||
if (messages.length <= TRANSCRIPT_RECENT_MESSAGE_WINDOW) return messages;
|
||||
const cutoff = messages.length - TRANSCRIPT_RECENT_MESSAGE_WINDOW;
|
||||
let next: T[] | null = null;
|
||||
for (let i = 0; i < cutoff; i++) {
|
||||
const message = messages[i];
|
||||
if (!isAssistantMessage(message) || message.pending) continue;
|
||||
const segments = elideSegments(message.segments);
|
||||
if (segments !== message.segments) {
|
||||
if (next === null) next = messages.slice();
|
||||
next[i] = { ...message, segments } as T;
|
||||
}
|
||||
}
|
||||
return next ?? messages;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
const PIN_THRESHOLD = 80; // px from bottom to consider "pinned"
|
||||
|
||||
// Auto-scrolls while content grows; un-pins only on real user gestures.
|
||||
// Scroll events are avoided because smooth scrollTo can fire them mid-flight.
|
||||
export function useAutoScroll(
|
||||
containerRef: React.RefObject<HTMLDivElement | null>,
|
||||
contentRef: React.RefObject<HTMLDivElement | null>,
|
||||
busy: boolean,
|
||||
/** Optional boot-time restore: the offset the transcript should open at. */
|
||||
getRestoreScrollTop?: () => number | null,
|
||||
) {
|
||||
const [showJumpButton, setShowJumpButton] = useState(false);
|
||||
const isPinnedRef = useRef(true);
|
||||
const wasBusyRef = useRef(busy);
|
||||
const rafIdRef = useRef<number>(0);
|
||||
|
||||
const isAtBottom = useCallback(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return true;
|
||||
return el.scrollTop + el.clientHeight >= el.scrollHeight - PIN_THRESHOLD;
|
||||
}, [containerRef]);
|
||||
|
||||
const refreshJumpButton = useCallback(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
setShowJumpButton(
|
||||
!isPinnedRef.current && el.scrollHeight > el.clientHeight + PIN_THRESHOLD,
|
||||
);
|
||||
}, [containerRef]);
|
||||
|
||||
const scrollToBottom = useCallback(
|
||||
(smooth = true) => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
isPinnedRef.current = true;
|
||||
setShowJumpButton(false);
|
||||
el.scrollTo({
|
||||
top: el.scrollHeight,
|
||||
behavior: smooth ? "smooth" : "instant",
|
||||
});
|
||||
},
|
||||
[containerRef],
|
||||
);
|
||||
|
||||
// User-intent detection: only these gestures un-pin. Scroll events are
|
||||
// intentionally NOT listened to — they can't tell user gestures from our
|
||||
// own scrollTo.
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
// rAF lets the gesture's scroll delta land before we measure.
|
||||
let pendingFrame = 0;
|
||||
const onUserGesture = () => {
|
||||
if (pendingFrame) cancelAnimationFrame(pendingFrame);
|
||||
pendingFrame = requestAnimationFrame(() => {
|
||||
pendingFrame = 0;
|
||||
isPinnedRef.current = isAtBottom();
|
||||
refreshJumpButton();
|
||||
});
|
||||
};
|
||||
|
||||
el.addEventListener("wheel", onUserGesture, { passive: true });
|
||||
el.addEventListener("touchmove", onUserGesture, { passive: true });
|
||||
el.addEventListener("keydown", onUserGesture);
|
||||
// pointerdown on the scrollbar gutter starts a drag-scroll. The
|
||||
// drag itself fires no wheel/touch, but pointerdown's followup
|
||||
// scroll arrives within a frame; one rAF measure catches it.
|
||||
el.addEventListener("pointerdown", onUserGesture);
|
||||
|
||||
return () => {
|
||||
if (pendingFrame) cancelAnimationFrame(pendingFrame);
|
||||
el.removeEventListener("wheel", onUserGesture);
|
||||
el.removeEventListener("touchmove", onUserGesture);
|
||||
el.removeEventListener("keydown", onUserGesture);
|
||||
el.removeEventListener("pointerdown", onUserGesture);
|
||||
};
|
||||
}, [containerRef, isAtBottom, refreshJumpButton]);
|
||||
|
||||
// Both busy edges re-pin: turn start = user just sent and expects to
|
||||
// see the reply; turn end = settle on the final answer (issue #1182).
|
||||
useEffect(() => {
|
||||
if (wasBusyRef.current !== busy) {
|
||||
scrollToBottom(true);
|
||||
}
|
||||
wasBusyRef.current = busy;
|
||||
}, [busy, scrollToBottom]);
|
||||
|
||||
// Watch content size changes (streaming text, tool results, new
|
||||
// messages) and follow the bottom while pinned.
|
||||
useEffect(() => {
|
||||
const content = contentRef.current;
|
||||
if (!content) return;
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (rafIdRef.current) cancelAnimationFrame(rafIdRef.current);
|
||||
rafIdRef.current = requestAnimationFrame(() => {
|
||||
rafIdRef.current = 0;
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
if (isPinnedRef.current) {
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: "instant" });
|
||||
} else {
|
||||
refreshJumpButton();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ro.observe(content);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
if (rafIdRef.current) {
|
||||
cancelAnimationFrame(rafIdRef.current);
|
||||
rafIdRef.current = 0;
|
||||
}
|
||||
};
|
||||
}, [containerRef, contentRef, refreshJumpButton]);
|
||||
|
||||
// Initial scroll when the hook mounts (e.g. session loaded). Restores the
|
||||
// saved offset if there is one (#1244), otherwise pins to the bottom.
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const id = setTimeout(() => {
|
||||
const restore = getRestoreScrollTop?.() ?? null;
|
||||
if (restore != null && restore > PIN_THRESHOLD) {
|
||||
// Mid-transcript restore: stay un-pinned so content growth and the
|
||||
// ResizeObserver don't yank the view back to the bottom.
|
||||
isPinnedRef.current = false;
|
||||
el.scrollTop = restore;
|
||||
refreshJumpButton();
|
||||
} else {
|
||||
isPinnedRef.current = true;
|
||||
setShowJumpButton(false);
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: "instant" });
|
||||
}
|
||||
}, 60);
|
||||
return () => clearTimeout(id);
|
||||
}, [containerRef, getRestoreScrollTop, refreshJumpButton]);
|
||||
|
||||
return { showJumpButton, scrollToBottom };
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
const TEXT_INPUT_SELECTOR =
|
||||
'input, textarea, [contenteditable=""], [contenteditable="true"], [contenteditable="plaintext-only"]';
|
||||
|
||||
function getTextAssistAttrs() {
|
||||
return {
|
||||
autoCorrect: "off" as const,
|
||||
autoCapitalize: "off" as const,
|
||||
spellCheck: false as const,
|
||||
};
|
||||
}
|
||||
|
||||
function isMacPlatform() {
|
||||
if (typeof document === "undefined") return false;
|
||||
return document.documentElement.dataset.platform === "macos";
|
||||
}
|
||||
|
||||
function applyTextAssistAttrs(element: Element) {
|
||||
if (!(element instanceof HTMLElement)) return;
|
||||
if (
|
||||
!(element instanceof HTMLInputElement) &&
|
||||
!(element instanceof HTMLTextAreaElement) &&
|
||||
!element.isContentEditable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attrs = getTextAssistAttrs();
|
||||
element.setAttribute("autocorrect", attrs.autoCorrect);
|
||||
element.setAttribute("autocapitalize", attrs.autoCapitalize);
|
||||
element.setAttribute("spellcheck", String(attrs.spellCheck));
|
||||
element.spellcheck = attrs.spellCheck;
|
||||
}
|
||||
|
||||
function applyWithin(root: ParentNode) {
|
||||
if (root instanceof Element) {
|
||||
applyTextAssistAttrs(root);
|
||||
}
|
||||
root.querySelectorAll(TEXT_INPUT_SELECTOR).forEach(applyTextAssistAttrs);
|
||||
}
|
||||
|
||||
export function useDisableTextAssist() {
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined" || !isMacPlatform()) return;
|
||||
|
||||
const root = document.body;
|
||||
if (!root) return;
|
||||
|
||||
applyWithin(root);
|
||||
|
||||
const handleFocusIn = (event: FocusEvent) => {
|
||||
if (event.target instanceof Element) {
|
||||
applyTextAssistAttrs(event.target);
|
||||
}
|
||||
};
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node instanceof Element) {
|
||||
applyWithin(node);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("focusin", handleFocusIn, true);
|
||||
observer.observe(root, { childList: true, subtree: true });
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("focusin", handleFocusIn, true);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { I } from "../icons";
|
||||
import { t, useLang } from "../i18n";
|
||||
|
||||
interface BrowseEntry {
|
||||
name: string;
|
||||
full: string;
|
||||
}
|
||||
|
||||
interface BrowseResult {
|
||||
path: string;
|
||||
parent: string | null;
|
||||
entries: BrowseEntry[];
|
||||
}
|
||||
|
||||
async function fetchBrowse(path: string): Promise<BrowseResult> {
|
||||
const token = document
|
||||
.querySelector('meta[name="reasonix-token"]')
|
||||
?.getAttribute("content") ?? "";
|
||||
const params = new URLSearchParams();
|
||||
if (path) params.set("path", path);
|
||||
if (token && token !== "__REASONIX_TOKEN__") params.set("token", token);
|
||||
const res = await fetch(`/api/browse?${params.toString()}`);
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `browse failed (${res.status})`);
|
||||
}
|
||||
return (await res.json()) as BrowseResult;
|
||||
}
|
||||
|
||||
export function WorkdirInputModal({
|
||||
open,
|
||||
initialPath,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
initialPath?: string;
|
||||
onCancel: () => void;
|
||||
onConfirm: (path: string) => void;
|
||||
}) {
|
||||
useLang();
|
||||
const [typed, setTyped] = useState<string>(initialPath ?? "");
|
||||
const [browse, setBrowse] = useState<BrowseResult | null>(null);
|
||||
const [error, setError] = useState<string>("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const seededRef = useRef(false);
|
||||
|
||||
const navigate = useCallback(async (path: string) => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await fetchBrowse(path);
|
||||
setBrowse(result);
|
||||
setTyped(result.path);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
seededRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (seededRef.current) return;
|
||||
seededRef.current = true;
|
||||
void navigate(initialPath ?? "");
|
||||
}, [open, initialPath, navigate]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const trimmedTyped = typed.trim();
|
||||
const canConfirm = trimmedTyped.length > 0 && !loading;
|
||||
const sameAsCurrent = browse !== null && trimmedTyped === browse.path;
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!canConfirm) return;
|
||||
onConfirm(trimmedTyped);
|
||||
};
|
||||
|
||||
const handleTypedKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (sameAsCurrent) {
|
||||
handleConfirm();
|
||||
} else {
|
||||
void navigate(trimmedTyped);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="settings-mask" onMouseDown={onCancel}>
|
||||
<div
|
||||
className="workdir-input"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="workdir-input-head">
|
||||
<I.folder size={14} />
|
||||
<span>{t("workdir.title")}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="close-btn"
|
||||
onClick={onCancel}
|
||||
title={t("workdir.cancel")}
|
||||
>
|
||||
<I.x size={13} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="workdir-input-pathrow">
|
||||
<input
|
||||
autoFocus
|
||||
className="workdir-input-path"
|
||||
value={typed}
|
||||
placeholder={t("workdir.pathPlaceholder")}
|
||||
onChange={(e) => setTyped(e.target.value)}
|
||||
onKeyDown={handleTypedKey}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
onClick={() => void navigate(trimmedTyped)}
|
||||
disabled={loading || trimmedTyped.length === 0}
|
||||
title={t("workdir.goTip")}
|
||||
>
|
||||
{t("workdir.go")}
|
||||
</button>
|
||||
</div>
|
||||
{error ? <div className="workdir-input-error">{error}</div> : null}
|
||||
<div className="workdir-input-list">
|
||||
{browse?.parent ? (
|
||||
<div
|
||||
className="workdir-input-row up"
|
||||
onClick={() => void navigate(browse.parent!)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") void navigate(browse.parent!);
|
||||
}}
|
||||
>
|
||||
<span className="ic">↑</span>
|
||||
<span>..</span>
|
||||
</div>
|
||||
) : null}
|
||||
{browse?.entries.length === 0 && !loading ? (
|
||||
<div className="workdir-input-empty">{t("workdir.emptyDir")}</div>
|
||||
) : null}
|
||||
{browse?.entries.map((e) => (
|
||||
<div
|
||||
key={e.full}
|
||||
className="workdir-input-row"
|
||||
onClick={() => void navigate(e.full)}
|
||||
onDoubleClick={() => onConfirm(e.full)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title={e.full}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === "Enter") void navigate(e.full);
|
||||
}}
|
||||
>
|
||||
<span className="ic"><I.folder size={12} /></span>
|
||||
<span className="n">{e.name}</span>
|
||||
</div>
|
||||
))}
|
||||
{loading ? <div className="workdir-input-empty">{t("workdir.loading")}</div> : null}
|
||||
</div>
|
||||
<div className="workdir-input-foot">
|
||||
<button type="button" className="btn ghost" onClick={onCancel}>
|
||||
{t("workdir.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={handleConfirm}
|
||||
disabled={!canConfirm}
|
||||
>
|
||||
{t("workdir.openHere")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { I } from "../icons";
|
||||
import { t, useLang } from "../i18n";
|
||||
import { Shortcut } from "./shortcut";
|
||||
|
||||
type Anchor = { top?: number; bottom?: number; left: number };
|
||||
|
||||
export function WorkdirPop({
|
||||
open,
|
||||
onClose,
|
||||
recent,
|
||||
current,
|
||||
anchor,
|
||||
onPick,
|
||||
onBrowse,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
recent: string[];
|
||||
current?: string;
|
||||
anchor?: Anchor;
|
||||
onPick: (path: string) => void;
|
||||
onBrowse: () => void;
|
||||
}) {
|
||||
useLang();
|
||||
const [query, setQuery] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setQuery("");
|
||||
const id = window.setTimeout(() => inputRef.current?.focus(), 40);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [open]);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const list = recent.length > 0 ? recent : current ? [current] : [];
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return list;
|
||||
return list.filter((p) => p.toLowerCase().includes(q));
|
||||
}, [recent, current, query]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const left = anchor?.left ?? 240;
|
||||
const positionStyle =
|
||||
anchor?.bottom !== undefined
|
||||
? { bottom: anchor.bottom, left }
|
||||
: { top: anchor?.top ?? 56, left };
|
||||
|
||||
return (
|
||||
<div className="wd-mask" onMouseDown={onClose}>
|
||||
<div className="wd-pop" style={positionStyle} onMouseDown={(e) => e.stopPropagation()}>
|
||||
<div className="wd-head">
|
||||
<I.folder size={12} />
|
||||
<span>{t("workdir.title")}</span>
|
||||
<span
|
||||
style={{
|
||||
marginLeft: "auto",
|
||||
fontFamily: "Geist Mono, monospace",
|
||||
fontSize: 10,
|
||||
color: "var(--muted)",
|
||||
}}
|
||||
>
|
||||
<Shortcut keys={["mod", "O"]} />
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="wd-search"
|
||||
placeholder={t("workdir.searchPlaceholder")}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
} else if (e.key === "Enter" && items[0]) {
|
||||
e.preventDefault();
|
||||
onPick(items[0]);
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="wd-list">
|
||||
{items.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
padding: "16px 12px",
|
||||
fontSize: 11.5,
|
||||
color: "var(--muted)",
|
||||
fontFamily: "Geist Mono, monospace",
|
||||
}}
|
||||
>
|
||||
{t("workdir.empty")}
|
||||
</div>
|
||||
) : null}
|
||||
{items.map((p) => {
|
||||
const isCurrent = p === current;
|
||||
const name = p.split(/[\\/]/).filter(Boolean).pop() ?? p;
|
||||
return (
|
||||
<div
|
||||
key={p}
|
||||
className="wd-row"
|
||||
onClick={() => {
|
||||
if (!isCurrent) onPick(p);
|
||||
onClose();
|
||||
}}
|
||||
title={p}
|
||||
>
|
||||
<span className="ic">
|
||||
<I.folder size={12} />
|
||||
</span>
|
||||
<div className="b">
|
||||
<div className="p">{name}</div>
|
||||
<div className="br">{p}</div>
|
||||
</div>
|
||||
{isCurrent ? (
|
||||
<span className="pin">
|
||||
<I.check size={11} />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="wd-foot">
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
onClick={() => {
|
||||
onBrowse();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<I.plus size={11} /> {t("workdir.browse")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __APP_VERSION__: string;
|
||||
+26
-11
@@ -1,16 +1,31 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "dist",
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"types": [],
|
||||
"allowJs": true,
|
||||
"checkJs": false
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@tauri-apps/api/core": ["src/lib/tauri-bridge.ts"],
|
||||
"@tauri-apps/api/event": ["src/lib/tauri-bridge.ts"],
|
||||
"@tauri-apps/api/window": ["src/lib/tauri-bridge.ts"],
|
||||
"@tauri-apps/api/webview": ["src/lib/tauri-bridge.ts"],
|
||||
"@tauri-apps/plugin-dialog": ["src/lib/tauri-bridge.ts"],
|
||||
"@tauri-apps/plugin-opener": ["src/lib/tauri-bridge.ts"],
|
||||
"@tauri-apps/plugin-process": ["src/lib/tauri-bridge.ts"],
|
||||
"@reasonix/core-utils/compaction": ["../packages/core-utils/src/compaction.ts"],
|
||||
"@reasonix/core-utils/derive-prefix": ["../packages/core-utils/src/derive-prefix.ts"],
|
||||
"@reasonix/core-utils": ["../packages/core-utils/src/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "app.js"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
// 开发模式下:将生产路径重写为源码路径;移除不需要的 vendor CSS
|
||||
{
|
||||
name: "dev-html-rewrite",
|
||||
transformIndexHtml(html: string) {
|
||||
return html
|
||||
.replace('/assets/app.js?token=__REASONIX_TOKEN__', '/src/main.tsx')
|
||||
.replace('/assets/app.css?token=__REASONIX_TOKEN__', '/src/styles.css');
|
||||
},
|
||||
},
|
||||
],
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify("0.47.2"),
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@reasonix/core-utils/compaction": resolve(__dirname, "../packages/core-utils/src/compaction.ts"),
|
||||
"@reasonix/core-utils/derive-prefix": resolve(__dirname, "../packages/core-utils/src/derive-prefix.ts"),
|
||||
"@reasonix/core-utils": resolve(__dirname, "../packages/core-utils/src/index.ts"),
|
||||
"@tauri-apps/api/core": resolve(__dirname, "src/lib/tauri-bridge.ts"),
|
||||
"@tauri-apps/api/event": resolve(__dirname, "src/lib/tauri-bridge.ts"),
|
||||
"@tauri-apps/api/window": resolve(__dirname, "src/lib/tauri-bridge.ts"),
|
||||
"@tauri-apps/api/webview": resolve(__dirname, "src/lib/tauri-bridge.ts"),
|
||||
"@tauri-apps/plugin-dialog": resolve(__dirname, "src/lib/tauri-bridge.ts"),
|
||||
"@tauri-apps/plugin-opener": resolve(__dirname, "src/lib/tauri-bridge.ts"),
|
||||
"@tauri-apps/plugin-process": resolve(__dirname, "src/lib/tauri-bridge.ts"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
target: "es2022",
|
||||
minify: "esbuild",
|
||||
sourcemap: true,
|
||||
outDir: "dist",
|
||||
emptyOutDir: false, // 避免清空 dist 下原有的第三方资源 (如 vendor-css)
|
||||
rollupOptions: {
|
||||
input: {
|
||||
app: resolve(__dirname, "src/main.tsx")
|
||||
},
|
||||
output: {
|
||||
entryFileNames: "app.js",
|
||||
chunkFileNames: "[name].js",
|
||||
assetFileNames: (assetInfo) => {
|
||||
if (assetInfo.name === "app.css" || assetInfo.name === "index.css") return "app.css";
|
||||
// 字体等静态文件输出到 assets/ 子目录,匹配服务器 /assets/* 路由
|
||||
if (/\.(woff2?|ttf|otf)$/.test(assetInfo.name ?? "")) return "assets/[name].[ext]";
|
||||
return "[name].[ext]";
|
||||
},
|
||||
manualChunks(id) {
|
||||
if (!id.includes("node_modules")) return;
|
||||
if (id.includes("/katex/")) return "vendor-katex";
|
||||
if (
|
||||
id.includes("/react-markdown/") ||
|
||||
id.includes("/remark-") ||
|
||||
id.includes("/rehype-") ||
|
||||
id.includes("/mdast-") ||
|
||||
id.includes("/micromark") ||
|
||||
id.includes("/unist-") ||
|
||||
id.includes("/hast-")
|
||||
)
|
||||
return "vendor-markdown";
|
||||
if (id.includes("/prism-react-renderer/")) return "vendor-prism";
|
||||
if (id.includes("/lucide-react/")) return "vendor-icons";
|
||||
if (id.includes("/react-virtuoso/")) return "vendor-virtuoso";
|
||||
if (id.includes("/react/") || id.includes("/react-dom/") || id.includes("/scheduler/"))
|
||||
return "vendor-react";
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: "127.0.0.1",
|
||||
port: 3000,
|
||||
strictPort: true
|
||||
}
|
||||
});
|
||||
Generated
+25
-2
@@ -1,18 +1,20 @@
|
||||
{
|
||||
"name": "reasonix-desktop",
|
||||
"version": "0.43.0",
|
||||
"version": "0.52.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "reasonix-desktop",
|
||||
"version": "0.43.0",
|
||||
"version": "0.52.0",
|
||||
"dependencies": {
|
||||
"@fontsource/geist": "^5.2.8",
|
||||
"@fontsource/geist-mono": "^5.2.7",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@reasonix/core-utils": "file:../packages/core-utils",
|
||||
"@tauri-apps/api": "^2.1.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-notification": "^2.0.0",
|
||||
"@tauri-apps/plugin-opener": "^2.0.0",
|
||||
"@tauri-apps/plugin-process": "^2.0.0",
|
||||
"@tauri-apps/plugin-updater": "^2.0.0",
|
||||
@@ -37,6 +39,14 @@
|
||||
"vite": "^5.4.10"
|
||||
}
|
||||
},
|
||||
"../packages/core-utils": {
|
||||
"name": "@reasonix/core-utils",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.9.0",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
@@ -787,6 +797,10 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@reasonix/core-utils": {
|
||||
"resolved": "../packages/core-utils",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -1380,6 +1394,15 @@
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-notification": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmmirror.com/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
||||
"integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-opener": {
|
||||
"version": "2.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "reasonix-desktop",
|
||||
"private": true,
|
||||
"version": "0.43.0",
|
||||
"version": "0.52.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -14,8 +14,10 @@
|
||||
"@fontsource/geist": "^5.2.8",
|
||||
"@fontsource/geist-mono": "^5.2.7",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@reasonix/core-utils": "file:../packages/core-utils",
|
||||
"@tauri-apps/api": "^2.1.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-notification": "^2.0.0",
|
||||
"@tauri-apps/plugin-opener": "^2.0.0",
|
||||
"@tauri-apps/plugin-process": "^2.0.0",
|
||||
"@tauri-apps/plugin-updater": "^2.0.0",
|
||||
|
||||
Loaded 100 of 831 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user