Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d308b471a1 | ||
|
|
5905a3b857 | ||
|
|
56b54044f6 | ||
|
|
ec1fa7db89 | ||
|
|
fb9c7b9bbc | ||
|
|
5c500becfa | ||
|
|
ff1ef4f23a | ||
|
|
e97f8dd3d1 | ||
|
|
5b6d5be7ef | ||
|
|
41f480e921 | ||
|
|
0d0fbc5f28 | ||
|
|
98202d21ac | ||
|
|
d9643e005c | ||
|
|
4511e50cd6 | ||
|
|
68e4f439cc | ||
|
|
eb575ffcf1 | ||
|
|
f84a47d0a5 | ||
|
|
eba919d2e9 | ||
|
|
b5ade39c6d | ||
|
|
fc314c6eef | ||
|
|
aeeefa0ec5 | ||
|
|
1115a029d7 | ||
|
|
12b84c226b | ||
|
|
04fa3793b9 | ||
|
|
5754f8274f | ||
|
|
fd06db5f6e | ||
|
|
67149fd51e | ||
|
|
13f3acecee | ||
|
|
c8964e45b7 | ||
|
|
3908744234 | ||
|
|
d2051a49ab | ||
|
|
07d9595cf7 | ||
|
|
f562b2073a | ||
|
|
a27856e59e | ||
|
|
5e3b425487 | ||
|
|
30b3398ef9 | ||
|
|
1d6fddfb34 | ||
|
|
1c9bbaed37 | ||
|
|
5a15f5d18f | ||
|
|
07194c7aaf | ||
|
|
e63bc2fb3c | ||
|
|
22b70b74c6 | ||
|
|
86ebc4553c | ||
|
|
1ca9190d83 | ||
|
|
b506fd1f20 | ||
|
|
6f92a54688 | ||
|
|
d8f69c6791 | ||
|
|
7b02449c26 | ||
|
|
8bb6d92719 | ||
|
|
2f1d186c6e | ||
|
|
0a8f754989 | ||
|
|
8957c2c281 | ||
|
|
60c20c5fc6 | ||
|
|
1193255f13 | ||
|
|
a3fbad406f | ||
|
|
865a3db3c6 | ||
|
|
86f6b87882 | ||
|
|
12a367c338 | ||
|
|
285ce30510 | ||
|
|
a62eb08e67 | ||
|
|
78adfb637d | ||
|
|
89620d76fc | ||
|
|
6c6d473f0e | ||
|
|
3fd6b941b8 | ||
|
|
6686a6a100 | ||
|
|
9884aa31e4 | ||
|
|
f308329067 | ||
|
|
34289972ee | ||
|
|
dfd3069ce3 | ||
|
|
ed3fec11c9 | ||
|
|
cdbd0164be | ||
|
|
cd5d349bbf | ||
|
|
713514ca57 | ||
|
|
2f59a92fc1 | ||
|
|
96c63b68d1 | ||
|
|
f294216699 | ||
|
|
af69950b1a | ||
|
|
1c326a476b | ||
|
|
764a6bb215 | ||
|
|
5ea7be074b | ||
|
|
f0058ed652 | ||
|
|
e5cd483467 | ||
|
|
e4a9c151a1 | ||
|
|
8fd3989476 | ||
|
|
5129759b83 | ||
|
|
86b0dd730f | ||
|
|
907b53f71f | ||
|
|
f731566996 | ||
|
|
37597832af | ||
|
|
1db773a12b | ||
|
|
30bdc51728 | ||
|
|
c4601fcc8a | ||
|
|
f5972156c6 | ||
|
|
ec887b4452 | ||
|
|
82eff74ed7 | ||
|
|
857a9decd4 | ||
|
|
d5a976e4b0 | ||
|
|
00f9efa0e6 | ||
|
|
292c8aa056 | ||
|
|
3b0435a178 | ||
|
|
31146732d3 | ||
|
|
7c831fc02c | ||
|
|
d9d4971c36 | ||
|
|
adc0e4db79 | ||
|
|
c16c1d96ee | ||
|
|
2277c7dc76 | ||
|
|
e0d370b9c4 | ||
|
|
e8f95d671f | ||
|
|
61e8366fb9 | ||
|
|
11c3e68688 | ||
|
|
c12e15a7de | ||
|
|
25e2aed3fa | ||
|
|
159c500771 | ||
|
|
b3c567d48f | ||
|
|
9b3e55865c | ||
|
|
1d0db3d55b | ||
|
|
641c7aa9af | ||
|
|
95f9f355e7 | ||
|
|
3b3566a5c6 | ||
|
|
8a759adf24 | ||
|
|
2b421d580e | ||
|
|
011ec4e252 | ||
|
|
49a1661ff2 | ||
|
|
ecd80e7e8b | ||
|
|
ff3f7d453f | ||
|
|
d499928440 | ||
|
|
cbe186045a | ||
|
|
15686e292a | ||
|
|
d700c463c8 | ||
|
|
dc4a08eb0f | ||
|
|
d93cfd9152 | ||
|
|
05d29398c3 | ||
|
|
0db14607ee | ||
|
|
90edf57bfc | ||
|
|
43cae056f2 | ||
|
|
9d05f2907f | ||
|
|
85f8bad8ca | ||
|
|
d5bf60da95 | ||
|
|
c2372c6d7f | ||
|
|
564b1af966 | ||
|
|
263cddfbec | ||
|
|
3b08056e3c | ||
|
|
aff9ac8c74 | ||
|
|
6c6c8cca37 | ||
|
|
86088fdd2d | ||
|
|
db3c14ebf4 | ||
|
|
458c353148 | ||
|
|
a6dcbfab64 | ||
|
|
df232c7760 | ||
|
|
1268832a98 | ||
|
|
b19b43b5a4 | ||
|
|
e693dde1d4 | ||
|
|
4fe287a7dc | ||
|
|
3de802e102 | ||
|
|
ea6809a185 | ||
|
|
75dafb6100 | ||
|
|
7cd73f2714 | ||
|
|
b1669b6d42 | ||
|
|
3efa1f80c8 | ||
|
|
4d062f120e | ||
|
|
157d6a90c9 | ||
|
|
5fe0911ba6 | ||
|
|
e6f021b120 | ||
|
|
f074b8a390 | ||
|
|
1f7ea48bee | ||
|
|
e949b804ab | ||
|
|
e1106f0731 | ||
|
|
c8c21aba12 | ||
|
|
a68af293a6 | ||
|
|
9ea2f874d9 | ||
|
|
4032bd0ee7 | ||
|
|
e5e8779ce8 | ||
|
|
b84d944fa5 | ||
|
|
f014a3204c | ||
|
|
941a6e4c7b | ||
|
|
d545e064c7 | ||
|
|
bc75e09573 | ||
|
|
30bed64aab | ||
|
|
cfd9db28d1 | ||
|
|
bf5db976e4 | ||
|
|
06bae63a6f | ||
|
|
dc33ef6b29 | ||
|
|
d7666c89eb | ||
|
|
aa55552dd6 | ||
|
|
44a266c15c | ||
|
|
a924d8f7d6 | ||
|
|
474c27233f | ||
|
|
1fc41bbe71 | ||
|
|
50691f11f2 | ||
|
|
10dc6dae72 | ||
|
|
2a72b0c7d8 | ||
|
|
4366860245 | ||
|
|
410afd80d2 | ||
|
|
e16dc35659 | ||
|
|
7854afd00f | ||
|
|
5a24ddd23d | ||
|
|
2284d32aa5 | ||
|
|
2e63d598de | ||
|
|
4c654d0c9c | ||
|
|
d3cc598acf | ||
|
|
e4d4ef06bb | ||
|
|
9d769fd2ea | ||
|
|
5e9cfcb02b | ||
|
|
c8b79ad0bd | ||
|
|
f645fa3598 | ||
|
|
c27cee4e45 | ||
|
|
6c38e27be1 | ||
|
|
d25482b257 | ||
|
|
6e9c704b0e | ||
|
|
d0947b3a3b | ||
|
|
eb792068b8 | ||
|
|
014595a816 | ||
|
|
3f277bd32b | ||
|
|
56137c95e9 | ||
|
|
ec7e5f25ba | ||
|
|
b8afac331c | ||
|
|
dec8bd639e | ||
|
|
a4ebbb0de0 | ||
|
|
984704b175 | ||
|
|
33165742ea | ||
|
|
37a85e5df0 | ||
|
|
d77f86a60f | ||
|
|
d4431ad2e5 | ||
|
|
18d63c0a7a | ||
|
|
4844edbab8 | ||
|
|
bb5a7f2d1b | ||
|
|
2c3904f746 | ||
|
|
2d1de052d4 | ||
|
|
a187ffc7fb | ||
|
|
1ca147ec1e | ||
|
|
0b5e7dc06e | ||
|
|
f035493bdf | ||
|
|
d39ee15210 | ||
|
|
b6df93b3fd | ||
|
|
92c29a810a | ||
|
|
1870dabede | ||
|
|
13b49e0555 | ||
|
|
101efd6b0e | ||
|
|
eb3cc34dce | ||
|
|
1c04ba1230 | ||
|
|
4e1ca3c7c4 | ||
|
|
6ba8223d18 | ||
|
|
d62ceac8f1 | ||
|
|
0edf41c0d0 | ||
|
|
1e9ea2dd96 | ||
|
|
fb6085b4ab | ||
|
|
b5bed9b2b9 | ||
|
|
34a8a5bbed | ||
|
|
ad6e1c1676 | ||
|
|
87eec8f872 | ||
|
|
09799c7757 | ||
|
|
8e3cf16427 | ||
|
|
928742947f | ||
|
|
f6057313dd | ||
|
|
600bc396b8 | ||
|
|
8baed6e378 | ||
|
|
1d24ccd337 | ||
|
|
7920b359e2 | ||
|
|
74bdaf0d97 | ||
|
|
5c54808ff1 | ||
|
|
12317dab8c | ||
|
|
79c7246a28 | ||
|
|
4f247440f1 | ||
|
|
290d1d9db7 | ||
|
|
77d1674419 | ||
|
|
ddbe5d7036 | ||
|
|
9957d2b6b4 | ||
|
|
e1dbe9b312 | ||
|
|
f742e4aacd | ||
|
|
d6e0beb433 | ||
|
|
7ef6743005 | ||
|
|
ad7bac2a99 | ||
|
|
b9ef471657 | ||
|
|
0937c6c43b | ||
|
|
7a75d1b8f8 | ||
|
|
046e5b6e79 | ||
|
|
733aa8b6a1 | ||
|
|
9ce1acc7cc | ||
|
|
1b48ab59bc | ||
|
|
0ca2c6b021 | ||
|
|
adf62e8a32 | ||
|
|
0b2a2959ea | ||
|
|
9cfe3d3ea6 | ||
|
|
bb5bf676ac | ||
|
|
08a41e7933 | ||
|
|
581848181d | ||
|
|
2a9959c3c1 | ||
|
|
19ec49bebb | ||
|
|
140f97fc72 | ||
|
|
63472b6814 | ||
|
|
e22904df0b | ||
|
|
c07739e817 | ||
|
|
efdeb2559b | ||
|
|
cd9f12a017 | ||
|
|
60f5b279dc | ||
|
|
7527917dca | ||
|
|
24e30719df | ||
|
|
185b4cb7e1 | ||
|
|
5b7eeb62e2 | ||
|
|
7ac18f541e | ||
|
|
fa3a97a107 | ||
|
|
daf539913b | ||
|
|
6782e90baf | ||
|
|
f3a42511e9 | ||
|
|
8aed2079ad | ||
|
|
791691c7e3 | ||
|
|
cf0e920916 | ||
|
|
85ca307642 | ||
|
|
7aabb4a8fa | ||
|
|
4d3e868aed | ||
|
|
277098b6fb | ||
|
|
ec751a22a5 | ||
|
|
32a90668bc | ||
|
|
6f206e7853 | ||
|
|
940d8aab25 | ||
|
|
5afac824ab | ||
|
|
73c974ef21 | ||
|
|
b81f5d132b | ||
|
|
8797ed185f | ||
|
|
23a88e15fb | ||
|
|
faf8932826 | ||
|
|
d78cd92a0b | ||
|
|
009ca9fa4a | ||
|
|
22ea3af092 | ||
|
|
edbbb014ba | ||
|
|
6660244681 | ||
|
|
416537598a | ||
|
|
56fcb2a5a4 | ||
|
|
0c67b6cee6 | ||
|
|
13b738e6a7 | ||
|
|
32a2e8df44 | ||
|
|
10831c694d | ||
|
|
0324029636 | ||
|
|
2698e6f3a8 | ||
|
|
1436f5eed2 | ||
|
|
9d136b0713 | ||
|
|
2fc06c7a9b | ||
|
|
f0533783d9 | ||
|
|
736e615c8e | ||
|
|
447a31cc3a | ||
|
|
87eb8f168f | ||
|
|
2af4cb3fdd | ||
|
|
2813af8329 | ||
|
|
79da127680 | ||
|
|
8fab388608 | ||
|
|
484c40a02c | ||
|
|
54dd2ea8b4 | ||
|
|
07bddea41c | ||
|
|
08b7ced357 | ||
|
|
ffcc0443f1 | ||
|
|
3426f5a126 | ||
|
|
39685d0ab3 | ||
|
|
6e723e2e60 | ||
|
|
59babbb604 | ||
|
|
bcc64ff822 | ||
|
|
9a43aa214b | ||
|
|
d61d6806c8 | ||
|
|
5a4754ffc6 | ||
|
|
a9314cf23a | ||
|
|
26d2c527db | ||
|
|
054d3989ed | ||
|
|
bcfb7e586d | ||
|
|
122d2e0fc9 | ||
|
|
cf122c357d | ||
|
|
de4b342199 | ||
|
|
6761a31697 | ||
|
|
94dc1926f2 | ||
|
|
98183baf99 |
No files matched your search
@@ -0,0 +1,25 @@
|
||||
bug:
|
||||
- '/\b(crash|crashed|crashes|panic|panicked|stack trace|stacktrace|traceback)\b/i'
|
||||
- '/\b(broken|doesn''?t work|not working|stopped working|regression)\b/i'
|
||||
- '/\b(throws? (an? )?error|uncaught|unhandled)\b/i'
|
||||
|
||||
enhancement:
|
||||
- '/\b(feature request|would (be nice|like|love)|please add|please support)\b/i'
|
||||
- '/\b(add (a |the )?(flag|option|config|setting|switch)|allow (configuring|disabling|enabling))\b/i'
|
||||
- '/\b(can (we|you|reasonix) (add|support)|proposal|propose adding)\b/i'
|
||||
|
||||
documentation:
|
||||
- '/\b(docs?\b|documentation|readme|getting started|tutorial|wiki|man page)\b/i'
|
||||
- '/\b(missing (docs?|documentation)|undocumented)\b/i'
|
||||
|
||||
question:
|
||||
- '/\b(how (do|to|can) (i|we|you)|is it possible|is there a way|why does|why is)\b/i'
|
||||
- '/\b(question:|q:)\s/i'
|
||||
|
||||
dashboard:
|
||||
- '/\b(dashboard|\/dashboard|web UI|web companion|browser)\b/i'
|
||||
- '/\blocalhost:|127\.0\.0\.1/i'
|
||||
|
||||
rendering:
|
||||
- '/\b(flicker|flickering|render artifact|redraw|repaint|ghost row|ghosting|screen tear)\b/i'
|
||||
- '/\b(TUI (glitch|artifact|garbled)|terminal (glitch|garbled|broken display))\b/i'
|
||||
@@ -8,11 +8,12 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: build (node ${{ matrix.node }})
|
||||
runs-on: ubuntu-latest
|
||||
name: build (${{ matrix.os }} · node ${{ matrix.node }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
node: ["22"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -45,6 +46,19 @@ jobs:
|
||||
# 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
|
||||
# DEEPSEEK_API_KEY in CI.
|
||||
# DEEPSEEK_API_KEY in CI. RUNNER_TEMP is set on every GH Actions
|
||||
# runner (POSIX + Windows) — beats hardcoding /tmp.
|
||||
- name: τ-bench harness dry-run
|
||||
run: npx tsx benchmarks/tau-bench/runner.ts --dry --out /tmp/tau-dry.json
|
||||
run: npx tsx benchmarks/tau-bench/runner.ts --dry --out "${{ runner.temp }}/tau-dry.json"
|
||||
|
||||
- name: Notify Discord on main failure
|
||||
if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
shell: bash
|
||||
env:
|
||||
WEBHOOK_URL: ${{ secrets.DISCORD_CI_WEBHOOK }}
|
||||
run: |
|
||||
test -n "$WEBHOOK_URL" || { echo "DISCORD_CI_WEBHOOK unset, skipping"; exit 0; }
|
||||
RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
curl -sS -X POST -H "Content-Type: application/json" \
|
||||
-d "{\"content\":\"❌ CI failed on \`${{ github.ref_name }}\` (${{ matrix.os }}) — ${{ github.actor }}\n${RUN_URL}\"}" \
|
||||
"$WEBHOOK_URL"
|
||||
@@ -0,0 +1,22 @@
|
||||
name: Issue auto-label
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
label:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: github/[email protected]
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
configuration-path: .github/issue-labeler.yml
|
||||
enable-versioned-regex: 0
|
||||
include-title: 1
|
||||
include-body: 1
|
||||
sync-labels: 0
|
||||
@@ -0,0 +1,20 @@
|
||||
name: Issue similarity check
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
similarity:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions-cool/issues-similarity-analysis@v1
|
||||
with:
|
||||
filter-threshold: 0.7
|
||||
title-excludes: ""
|
||||
comment-title: "### Possibly related issues"
|
||||
comment-body: "- #${number} · ${title}"
|
||||
show-footer: false
|
||||
@@ -0,0 +1,17 @@
|
||||
name: oosmetrics Health Check
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 9 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
report:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: oosmetrics/health-check-action@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,66 @@
|
||||
name: Publish npm
|
||||
|
||||
# Tag namespace: plain `v<semver>` triggers npm publish for the
|
||||
# reasonix CLI package. Desktop Tauri releases use `desktop-v*`
|
||||
# (see release.yml) — pushing one of those never reaches this
|
||||
# workflow, so the two release tracks are fully decoupled.
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Tag to publish (e.g. v0.47.0). Required when running manually."
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
# `id-token: write` is the npm provenance handshake — harmless if we
|
||||
# don't enable provenance, required if we ever do.
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: publish to npm
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# workflow_dispatch lets a maintainer publish an older tag
|
||||
# without re-pushing it; tag pushes already point HEAD at the
|
||||
# right ref so the fallback is a no-op.
|
||||
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/
|
||||
|
||||
# Fail-fast if someone tags without bumping package.json. npm itself
|
||||
# would reject the duplicate publish, but the error there is
|
||||
# opaque ("403 You cannot publish over the previously published
|
||||
# versions") and CI is the place to catch it cleanly.
|
||||
- name: Verify tag matches package.json version
|
||||
shell: bash
|
||||
run: |
|
||||
tag="${{ github.event.inputs.tag || github.ref_name }}"
|
||||
tag_version="${tag#v}"
|
||||
pkg_version=$(node -p "require('./package.json').version")
|
||||
if [ "$tag_version" != "$pkg_version" ]; then
|
||||
echo "::error::tag $tag (version $tag_version) does not match package.json version $pkg_version"
|
||||
exit 1
|
||||
fi
|
||||
echo "tag $tag matches package.json $pkg_version"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
# `npm publish` runs the prepublishOnly hook (lint + typecheck +
|
||||
# test + build) automatically, so no separate verify step here.
|
||||
- name: Publish to npm
|
||||
run: npm publish --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
@@ -0,0 +1,74 @@
|
||||
name: Release mirror
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Tag to mirror (e.g. v0.42.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
name: Mirror release assets to R2
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve tag
|
||||
id: tag
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "name=${{ inputs.tag }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "name=${{ github.event.release.tag_name }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Download release assets from GitHub
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mkdir -p assets
|
||||
gh release download "${{ steps.tag.outputs.name }}" \
|
||||
-R "${{ github.repository }}" \
|
||||
-D assets
|
||||
|
||||
- name: Configure AWS CLI for R2
|
||||
if: env.HAS_R2 == 'true'
|
||||
run: |
|
||||
aws configure set aws_access_key_id "${{ secrets.R2_ACCESS_KEY_ID }}"
|
||||
aws configure set aws_secret_access_key "${{ secrets.R2_SECRET_ACCESS_KEY }}"
|
||||
aws configure set region auto
|
||||
|
||||
- name: Mirror to R2
|
||||
if: env.HAS_R2 == 'true'
|
||||
env:
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
TAG: ${{ steps.tag.outputs.name }}
|
||||
run: |
|
||||
ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
|
||||
aws s3 cp assets/ "s3://${R2_BUCKET}/${TAG}/" \
|
||||
--recursive \
|
||||
--endpoint-url "$ENDPOINT"
|
||||
aws s3 cp assets/ "s3://${R2_BUCKET}/latest/" \
|
||||
--recursive \
|
||||
--endpoint-url "$ENDPOINT"
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo "## Mirror result"
|
||||
echo ""
|
||||
echo "- Tag: \`${{ steps.tag.outputs.name }}\`"
|
||||
echo "- R2: ${{ env.HAS_R2 == 'true' && '✓ mirrored' || 'skipped (no R2 secrets)' }}"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -0,0 +1,157 @@
|
||||
name: Release desktop
|
||||
|
||||
# Tag namespace: `desktop-v<semver>` triggers desktop Tauri bundles.
|
||||
# Plain `v<semver>` tags are reserved for the npm CLI release (see
|
||||
# publish-npm.yml) — they intentionally do NOT trigger this workflow.
|
||||
# Bump the desktop with: `git tag desktop-vX.Y.Z && git push origin desktop-vX.Y.Z`.
|
||||
on:
|
||||
push:
|
||||
tags: ["desktop-v*"]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Tag to publish (e.g. desktop-v0.47.0). Required when running manually."
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
bundle:
|
||||
name: bundle (${{ matrix.target.label }})
|
||||
strategy:
|
||||
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.
|
||||
- { 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: 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
|
||||
# job-level env. Treat "true"/"false" strings as booleans downstream.
|
||||
env:
|
||||
HAS_APPLE_CERT: ${{ secrets.APPLE_CERTIFICATE != '' }}
|
||||
HAS_WIN_CERT: ${{ secrets.WINDOWS_CERTIFICATE != '' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve tag
|
||||
id: tag
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "name=${{ inputs.tag }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "name=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Setup Node 22
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
|
||||
- name: Setup Rust (${{ matrix.target.rust }})
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target.rust }}
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: desktop/src-tauri -> target
|
||||
key: ${{ matrix.target.label }}
|
||||
|
||||
- name: Install Linux bundle deps
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libgtk-3-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev \
|
||||
patchelf
|
||||
|
||||
- name: Install root deps
|
||||
run: npm ci
|
||||
|
||||
- name: Sync desktop version from tag
|
||||
run: node scripts/sync-desktop-version.mjs "${{ steps.tag.outputs.name }}"
|
||||
|
||||
- name: Build CLI bundle (dist/)
|
||||
run: npm run build
|
||||
|
||||
- name: Install desktop deps
|
||||
working-directory: desktop
|
||||
run: npm ci
|
||||
|
||||
- name: Download bundled Node runtime
|
||||
working-directory: desktop
|
||||
run: npm run bundle:node
|
||||
|
||||
# tauri-action signs OS bundles whenever the corresponding env vars are
|
||||
# defined — including when they're defined to the empty string, which is
|
||||
# what `secrets.UNSET` evaluates to. Gate each signing block on its
|
||||
# certificate being non-empty so unsigned prereleases don't trip
|
||||
# `security import` / `signtool` on missing certs.
|
||||
- name: Build Tauri bundle (unsigned)
|
||||
if: >-
|
||||
(matrix.target.os != 'windows-latest' || env.HAS_WIN_CERT != 'true')
|
||||
&& (!startsWith(matrix.target.os, 'macos-') || env.HAS_APPLE_CERT != 'true')
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
with:
|
||||
projectPath: desktop
|
||||
tagName: ${{ steps.tag.outputs.name }}
|
||||
releaseName: Reasonix ${{ steps.tag.outputs.name }}
|
||||
releaseDraft: true
|
||||
prerelease: false
|
||||
args: --target ${{ matrix.target.tauri }}
|
||||
|
||||
- name: Build Tauri bundle (Windows, signed)
|
||||
if: matrix.target.os == 'windows-latest' && env.HAS_WIN_CERT == 'true'
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
with:
|
||||
projectPath: desktop
|
||||
tagName: ${{ steps.tag.outputs.name }}
|
||||
releaseName: Reasonix ${{ steps.tag.outputs.name }}
|
||||
releaseDraft: true
|
||||
prerelease: false
|
||||
args: --target ${{ matrix.target.tauri }}
|
||||
|
||||
- name: Build Tauri bundle (macOS, signed + notarized)
|
||||
if: startsWith(matrix.target.os, 'macos-') && env.HAS_APPLE_CERT == 'true'
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
with:
|
||||
projectPath: desktop
|
||||
tagName: ${{ steps.tag.outputs.name }}
|
||||
releaseName: Reasonix ${{ steps.tag.outputs.name }}
|
||||
releaseDraft: true
|
||||
prerelease: false
|
||||
args: --target ${{ matrix.target.tauri }}
|
||||
@@ -36,3 +36,15 @@ scripts/.cm-entry.mjs
|
||||
# Personal bun lockfile — project uses npm officially.
|
||||
bun.lock
|
||||
|
||||
# Local portable Node/npm used for development on machines without npm.
|
||||
.tools/
|
||||
.npm-cache/
|
||||
|
||||
# Tauri desktop shell build artifacts.
|
||||
desktop/src-tauri/target/
|
||||
desktop/src-tauri/gen/
|
||||
desktop/src-tauri/binaries/
|
||||
|
||||
# Design mock-up reference (local, not part of repo)
|
||||
.design-ref/
|
||||
*.cpuprofile
|
||||
@@ -3,6 +3,926 @@
|
||||
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.46.0] — 2026-05-17
|
||||
|
||||
**Breaking — Rust renderer removed.** Reasonix is back to a pure Ink/Node
|
||||
TUI. The `reasonix-render` ratatui crate, the five
|
||||
`@reasonix/render-{platform}-{arch}` optional sub-packages, the NAPI
|
||||
loader (`src/cli/ui/scene/`), and the `--node` opt-out flag are all
|
||||
gone. Cross-terminal compat issues (Termux #1149 / #1026, mac eager-spawn
|
||||
chain, Windows alt-screen handoff, integrated-mode keyboard collisions)
|
||||
disappear with them — at the cost of the streaming/animation perf the
|
||||
rust renderer was supposed to buy us.
|
||||
|
||||
Notes for users:
|
||||
- `REASONIX_RENDERER`, `REASONIX_RENDER_BIN`, `REASONIX_RENDER_CMD`,
|
||||
`REASONIX_INPUT_CMD`, `REASONIX_RENDERER_INTEGRATED` env vars are no-ops
|
||||
— drop them from your shell config.
|
||||
- `--node` flag is no-op (and unrecognized; will error). Just drop it.
|
||||
- `--no-alt-screen` / `--no-mouse` flags also gone — Ink defaults take
|
||||
over end-to-end.
|
||||
- `npm install [email protected]` no longer pulls a per-platform binary —
|
||||
one dep tree on every OS, no optional resolution step.
|
||||
|
||||
**TUI overhaul** — same pass that removed the rust path also cleared
|
||||
the Ink-side workarounds we'd accumulated on top of it:
|
||||
- Card primitive drops the left `▎` stripe; cards are plain column
|
||||
boxes with a top margin
|
||||
- CardHeader drops the colored pill background behind titles
|
||||
- Single consistent glyph vocabulary: `●` running, `✓` done, `✗`
|
||||
failed, `⊘` aborted, `○` queued, `⚠` warn, `⎿` child row, `█`/`░`
|
||||
bars, braille spinner
|
||||
- Composer wrapped in a rounded border box; status row moved to the
|
||||
bottom; on narrow terminals status pills wrap to a new line instead
|
||||
of being truncated away (#1149)
|
||||
- `/dashboard` URL surfaces as a startup info row (was easy to miss)
|
||||
- `/copy` permanently in the startup hint list — vim-style copy mode
|
||||
was undiscoverable before
|
||||
|
||||
## [0.44.2-rc.2] — 2026-05-17
|
||||
|
||||
**Fix:** macOS hang on `npx reasonix@next code` — keep-alive interval
|
||||
from rc.1 prevented Node from exiting, but the rust child was never
|
||||
actually spawned. Root cause: spawn was triggered by a React
|
||||
`useEffect` (`useSceneTrace` → `emitSceneMessage` → `trace.ts`
|
||||
`ensureInitialized`), and that effect simply never fired in some
|
||||
macOS npx contexts. Node sat alive with the keep-alive holding the
|
||||
event loop open, nothing on screen, no rust process.
|
||||
|
||||
Fix: spawn rust eagerly in `chat.tsx` via new
|
||||
`ensureSceneTraceReady()` export, after `setIntegratedEventHandler` so
|
||||
the integrated event callback is wired before spawn. Also:
|
||||
- `renderer-process.ts` synthesizes an `exit` event when the rust child
|
||||
dies on its own (panic / SIGKILL / terminal close) so the integrated
|
||||
event handler tears Node down instead of hanging forever.
|
||||
- `trace.ts` now logs a clear warning to stderr when `resolveRenderer`
|
||||
returns no usable command, instead of bailing silently — anyone
|
||||
hitting "TUI never appears" can find the cause in
|
||||
`~/.reasonix/rust-render-stderr.log`.
|
||||
|
||||
## [0.44.2-rc.1] — 2026-05-17
|
||||
|
||||
**Fix:** macOS `npx reasonix code` (default rust + integrated TUI) exited
|
||||
back to the shell prompt immediately without rendering. Root cause:
|
||||
`makeNullStdin` / `makeNullStdout` are pure-JS Node streams with no
|
||||
underlying libuv handles, so they don't keep the event loop alive. The
|
||||
rust trace child is spawned by a React `useEffect` (via `useSceneTrace`
|
||||
→ `emitSceneMessage`) — that effect is enqueued microseconds AFTER
|
||||
`render()` returns, but on macOS the event loop sees no active handles
|
||||
in that window and exits before the effect runs. Linux/Windows happen
|
||||
to keep the loop alive via other handles in the boot path; macOS
|
||||
doesn't.
|
||||
|
||||
Defensive `setInterval(()=>{}, 0x7fffffff)` keep-alive held for the
|
||||
lifetime of `waitUntilExit()` and cleared in `finally`. Same renderer
|
||||
binary as 0.44.0; only chat.tsx changed.
|
||||
|
||||
## [0.44.1] — 2026-05-17
|
||||
|
||||
**Fix:** Mac/Linux `npx reasonix@latest` failed with `EACCES` when spawning
|
||||
the rust renderer (`spawn ... reasonix-render EACCES`). `actions/download-artifact@v4`
|
||||
strips Unix file modes during the release pipeline's artifact round-trip,
|
||||
so the binaries in the published 0.44.0 subpackages landed without the
|
||||
executable bit. Two-layer fix:
|
||||
|
||||
- Each `@reasonix/render-*` subpackage now declares its binary in the
|
||||
`bin` field of `package.json`, so `npm install` chmod +x's the file
|
||||
during extraction (standard npm behavior for bin-declared files).
|
||||
- The publish workflow explicitly `chmod +x`'s the non-Windows binaries
|
||||
after staging from artifacts, so the tarball itself ships with the
|
||||
right mode regardless of npm version.
|
||||
|
||||
No code changes — same renderer binary as 0.44.0, just a republish with
|
||||
correct file modes.
|
||||
|
||||
## [0.44.0] — 2026-05-17
|
||||
|
||||
**Headline:** The Rust TUI is now the default TUI. `npx reasonix@latest`
|
||||
on any supported platform (win32-x64, linux-x64, linux-arm64, darwin-x64,
|
||||
darwin-arm64) pulls a pre-built ratatui binary as an `optionalDependencies`
|
||||
sub-package and renders the full agent loop natively — no cargo, no
|
||||
toolchain setup, no opt-in flag. The Ink/Node TUI is still shipped and
|
||||
reachable via `--node` for users who hit a regression or run on an
|
||||
unsupported platform; the renderer-resolver auto-falls-back to Ink with
|
||||
a one-line stderr hint when no rust binary is locatable. Inside the rust
|
||||
TUI, **integrated mode** is the default too (rust owns keyboard + mouse +
|
||||
composer directly), which fixes the multi-press Ctrl+D + dropped preset
|
||||
clicks + Ctrl+C terminal-state leak that the split keystroke-bus path had.
|
||||
Bare `reasonix` (no subcommand) routes to `code` in the cwd instead of
|
||||
chat — explicit `reasonix chat` still works.
|
||||
|
||||
**Note:** Existing users with `REASONIX_RENDERER=rust` or
|
||||
`REASONIX_RENDERER_INTEGRATED=1` set in their shell rc will see no
|
||||
behavior change (both are now no-ops with the value `=node` / `=0`
|
||||
opting back to the old behaviors).
|
||||
|
||||
**Features:**
|
||||
|
||||
- feat(rust): rust TUI is the default. `--node` flag (or
|
||||
`REASONIX_RENDERER=node`) opts back to the Ink/Node renderer. Auto-
|
||||
fallback to Ink with a stderr hint when no rust binary is found
|
||||
(#1081)
|
||||
- feat(rust): integrated mode is the default once the rust renderer
|
||||
is active. `REASONIX_RENDERER_INTEGRATED=0` opts back to the
|
||||
--emit-input split keystroke bus (debug only) (#1081)
|
||||
- feat(cli): bare `reasonix` (no subcommand) now launches `code` in
|
||||
the cwd. Drops the project-marker heuristic that used to route to
|
||||
`chat` outside of project dirs; `reasonix chat` stays explicit (#1081)
|
||||
- feat(scene): renderer-resolver picks the binary by priority chain —
|
||||
`REASONIX_RENDER_CMD` / `REASONIX_INPUT_CMD` env (full-command
|
||||
override) > `REASONIX_RENDER_BIN` env (single binary path) >
|
||||
`@reasonix/render-{platform}-{arch}` optional dependency > source-
|
||||
tree `target/release/reasonix-render(.exe)` > `target/debug/...` >
|
||||
`cargo run --bin reasonix-render` (source tree + cargo on PATH) >
|
||||
missing → Ink fallback (#1081)
|
||||
- feat(scene): Setup wizard works end-to-end under the rust renderer.
|
||||
First-launch with no saved API key shows the masked input prompt in
|
||||
rust and accepts keystrokes via the input child / integrated event
|
||||
path (#1081)
|
||||
- feat(scene): dashboard URL with a long token wraps inside the boot
|
||||
panel instead of bleeding into the sidebar. `paint_link_wrapped`
|
||||
emits each wrapped chunk as its own OSC 8 hyperlink so click-to-open
|
||||
still hits the full URL (#1081)
|
||||
- feat(release): per-platform `@reasonix/render-*` subpackages
|
||||
published as `optionalDependencies` of the main package. CI workflow
|
||||
cross-compiles the rust renderer for 5 targets on tag push, uploads
|
||||
one binary per subpackage, and `npm publish`es the 5 subpackages +
|
||||
the main package together. `scripts/sync-render-versions.mjs` keeps
|
||||
every subpackage version + the main `optionalDependencies` pins
|
||||
lockstep, guarded by a `verify` + `prepublishOnly` `--check` (#1084)
|
||||
- feat(dev): `npm run dev` auto-runs `cargo build --release` ahead of
|
||||
tsx so contributors never spawn a stale rust binary after editing
|
||||
rust source. Silent no-op when cargo is missing (TS-only devs).
|
||||
Added `npm run rust:build` for explicit rebuilds (#1081)
|
||||
- feat(ui): Ctrl+D wired to quit in the Ink TUI as well — the boot
|
||||
banner already advertised it but the binding was never connected.
|
||||
Integrated rust mode already handled it directly (#1081)
|
||||
|
||||
**Fixes (inherited from #1070, included for 0.44.0 baseline):**
|
||||
|
||||
- fix(rust): zero-width character handling + tab expansion in the
|
||||
paint engine — control chars no longer overwrite adjacent cells,
|
||||
combining marks / ZWJ / variation selectors are skipped, `\t`
|
||||
expands to 2 spaces. Resolves the `tau-benc / tatus]` style card-
|
||||
body fragmentation seen in 0.43.0 (#1070)
|
||||
|
||||
## [0.43.0] — 2026-05-14
|
||||
|
||||
**Headline:** Desktop client graduates — `v0.42.0-*` prereleases hardened
|
||||
the bundle pipeline (sync-from-tag versioning, signed Windows + signed
|
||||
+ notarized macOS, Linux deb/AppImage, bundled-Node sidecar, R2-mirrored
|
||||
installers reachable from the new GitHub Pages landing), and 0.43.0 is
|
||||
the first stable cut where the installer is the recommended path for
|
||||
non-Node users. The subagent loop turns its iteration cap from a hard
|
||||
budget into a checkpoint cadence — pause/resume lets long jobs survive
|
||||
restarts, and the cap goes 32 → 256 (validated end-to-end on real API
|
||||
runs). Skills pick up a `max-iters` frontmatter knob so per-skill budget
|
||||
overrides don't need a config edit, and `/skill` arg picker
|
||||
autocompletes skill names. The CLI gets `--profile` for V8 CPU profiles
|
||||
(send the file with a perf bug report; no instrumentation needed). On
|
||||
the daily-driver side: `/btw` arg picker and `/skill` autocomplete in
|
||||
the slash menu, a context-usage pill in the TUI status row, full i18n
|
||||
sweep (settings UI, dashboard tool descriptions, residual hardcoded
|
||||
strings), and ~20 desktop UX fixes (HiDPI window clamping, @-mention
|
||||
drag-drop + tree picker, font scale + family pickers, language picker
|
||||
synced to `<html lang>`, theme toggle, skill-running indicator, memory
|
||||
panel mid-turn refresh, plan-checkpoint modal in dashboard, and several
|
||||
keyboard / pause-state regressions caught in the field).
|
||||
|
||||
**Note:** 0.41.0's `ReadOnlyLoopTracker` (auto-escalate flash → pro on
|
||||
read-only sequences) is removed in 0.43.0 — the heuristic fired on
|
||||
legitimate skim-mode reads and the cost ceiling didn't justify the
|
||||
behavior change. Preset escalation now happens only on explicit
|
||||
`/preset` or `--auto-escalate`. (#860)
|
||||
|
||||
**Features:**
|
||||
|
||||
- feat(subagent): pause/resume on the subagent loop. The iteration cap
|
||||
is now a checkpoint cadence — when it hits, the loop persists state
|
||||
(including `partial_summary` in the paused result) and a follow-up
|
||||
call resumes from there. Cap raised 32 → 256, validated end-to-end
|
||||
against the real API. (#822, #823, #838, #839)
|
||||
- feat(skills): `max-iters` frontmatter on skill files raises the
|
||||
subagent tool budget for that skill only — no config edit, no global
|
||||
bump. (#791)
|
||||
- feat(cli): `--profile <path>` records a V8 CPU profile for the
|
||||
session and writes it on exit (flushed before `useQuit`'s
|
||||
`process.exit` so the file actually lands). Attach to perf bug
|
||||
reports without rebuilding with instrumentation. (#846, #847)
|
||||
- feat(slash): `/skill` arg picker autocompletes skill names. (#805)
|
||||
- feat(tui): context-usage pill in the status row — see remaining
|
||||
context budget at a glance instead of doing the math from cache
|
||||
hit %. (#828, #837)
|
||||
- feat(web): landing + download site, ported from the design mockup
|
||||
and served from `docs/` via GitHub Pages. Installer links resolve to
|
||||
the R2-mirrored bundles. (#815, #817)
|
||||
- feat(desktop): @-mention UX — drag-drop files, tree picker, and a
|
||||
TTL'd file index so large repos don't re-walk on every keystroke.
|
||||
(#826)
|
||||
- feat(desktop): localize settings UI; language picker syncs
|
||||
`<html lang>` so the OS-level locale fall-through works. (#841,
|
||||
#842)
|
||||
- feat(desktop): font scale slider + family picker, with API key /
|
||||
base URL always visible in general settings (was hidden behind a
|
||||
toggle that surprised first-run users). (#877, #879)
|
||||
- feat(desktop): visible "skill running" indicator on the composer
|
||||
while a user skill executes — no more silent waits when a skill
|
||||
takes 10+ seconds. (#884)
|
||||
- feat(i18n): residual hardcoded CLI + desktop strings extracted into
|
||||
the i18n system; dashboard tool descriptions translated to zh-CN.
|
||||
(#850, #858)
|
||||
- feat(skills): `/btw` and `/search-engine` zh-CN keys filled; `/help`
|
||||
hanging-indent wrap fixed for long arg lists. (#875)
|
||||
|
||||
**Fixes:**
|
||||
|
||||
- fix(desktop): catch unknown `PauseKind` in the IPC bridge so the
|
||||
agent loop doesn't silently hang when the desktop ships ahead of a
|
||||
new pause variant. (#873)
|
||||
- fix(desktop): per-tab keyboard shortcuts gate on `active` — a
|
||||
background tab no longer reacts to global hotkeys meant for the
|
||||
foreground session. (#874)
|
||||
- fix(desktop): bundled CLI loads on installed targets (was looking
|
||||
for the dev path); CDN fonts dropped in favor of the @fontsource
|
||||
bundle so first paint works offline. (#806)
|
||||
- fix(desktop): kill the bundled Node child on Tauri exit — added
|
||||
`rpc_kill` so the front-end can request a clean shutdown. (#792)
|
||||
- fix(desktop): clamp the initial window to fit HiDPI / small
|
||||
monitors — startup no longer opens off-screen on 1366×768 laptops.
|
||||
(#833)
|
||||
- fix(desktop): refresh the memory panel mid-turn after `remember` /
|
||||
`forget` — the right-panel was stale until the next user input.
|
||||
(#829)
|
||||
- fix(desktop): @-mention UX — surface user skills in the `/`
|
||||
autocomplete popup; normalize workspace path so the session sidebar
|
||||
filter matches sessions opened from a different cwd casing. (#881,
|
||||
#883)
|
||||
- fix(desktop): wire `path_access` gate and scope the right-panel to
|
||||
the active session — cross-session leakage on workdir switch.
|
||||
(#787)
|
||||
- fix(desktop): composer-foot overflows on narrow center column.
|
||||
(cfd9db2)
|
||||
- fix(desktop): honor preset `autoEscalate` when building the loop —
|
||||
was always reading the global default. (#819)
|
||||
- fix(loop): preserve skill bodies verbatim across context fold — a
|
||||
fold that cut into a skill body could drop the body's leading
|
||||
fence, breaking the next subagent run. (#871)
|
||||
- fix(loop): yield reasoning before content so transition chunks
|
||||
don't fragment — long reasoning runs no longer get split across two
|
||||
message bubbles. (#859)
|
||||
- fix(tui): drop ESC-stripped SGR mouse reports instead of inserting
|
||||
them as keypresses. (#872)
|
||||
- fix(tui): strip bidi + zero-width controls from pasted text — RTL
|
||||
override and zero-width joiners no longer survive the round-trip.
|
||||
(#876)
|
||||
- fix(shell): route `2>nul` and `2>/dev/null` to the OS null device
|
||||
cross-platform, so the same shell line works in WSL and Windows
|
||||
cmd. (#821)
|
||||
- fix(code): honor the configured preset instead of hardcoding flash.
|
||||
(#824)
|
||||
- fix(cli): route bare project launches (`reasonix` with no
|
||||
subcommand, in a project directory) to `code` mode. (#812)
|
||||
- fix(policy): `--yolo` bypasses `path_access` too, parity with the
|
||||
shell `allowAll` shortcut. (#786)
|
||||
- fix(dashboard): plans API timed out on users with many sessions —
|
||||
switched to a paginated query + cache. (#880)
|
||||
- fix(dashboard): broadcast plan-checkpoint pause so the web UI
|
||||
actually shows the modal (was only firing in the desktop). (#832,
|
||||
#840)
|
||||
- fix(dashboard): swallow third-party-origin errors in the global
|
||||
overlay — extension scripts no longer surface as Reasonix crashes.
|
||||
(#825)
|
||||
|
||||
**Polish / refactor:**
|
||||
|
||||
- chore(desktop): theme toggle in settings; deduplicate the settings
|
||||
entry point so the dialog renders the same component everywhere.
|
||||
(#852)
|
||||
- chore(desktop): drop the fake traffic-light dots from the titlebar
|
||||
— they didn't wire to any window controls and read as broken on
|
||||
Linux. (#830, #831)
|
||||
- perf(desktop): paint the UI shell before `buildCodeToolset`
|
||||
finishes — first frame ~200 ms earlier on cold start. (#836)
|
||||
- perf(desktop): memoize the message list so historical messages skip
|
||||
re-renders on each new chunk. (#844, #845)
|
||||
- perf(ui): incremental wrap cache for streaming cards — long
|
||||
reasoning blocks no longer re-wrap from scratch on every chunk.
|
||||
(#800)
|
||||
- chore(release): sync desktop version from tag (single source of
|
||||
truth for tauri.conf.json + Cargo.toml + desktop/package.json), set
|
||||
minimum CSP. (#789)
|
||||
- ci(release): split the bundle step so unsigned builds skip the
|
||||
codesign envs; expose secret presence via job-level env so step
|
||||
`if:` can read it; pass `GITHUB_TOKEN` to tauri-action; switch
|
||||
macOS to universal-apple-darwin and drop the macos-13 shard
|
||||
(queue waits ran to hours). (#794, #795, #796, #799)
|
||||
- ci(release): mirror published releases to R2 (Gitee mirror tried
|
||||
for ~6 fixes, dropped — 90 MB cap, 200+null on missing tags, 3-min
|
||||
timeout per upload all stacked up; R2 is enough). (#804, #808,
|
||||
#809, #810, #811, #813, #814)
|
||||
- docs: render CLI reference and architecture pages in-site; repoint
|
||||
configuration CTAs at the new in-site pages. (#848, 06bae63)
|
||||
- docs(readme): announce the desktop client (prerelease at the time);
|
||||
install instructions. (#788, #793)
|
||||
|
||||
## [0.41.0] — 2026-05-13
|
||||
|
||||
**Headline:** ACP graduates — three stages of work on the headless
|
||||
entrypoint (NDJSON framing, `tool_call` streaming, `session/request_permission`
|
||||
bridging) plus three flags (`--transcript`, `--yolo`, `--mcp` /
|
||||
`--mcp-prefix`) make `reasonix acp` ready for non-interactive callers
|
||||
like CI harnesses and cost-tracking adapters. The loop picked up
|
||||
auto-escalation on read-only sequences (caps the dead-end fishing-trip
|
||||
cost), `read_file` now outlines four more languages, and `wait_for_job`
|
||||
got an exit-only mode so chatty long jobs (curl, wget, big installs)
|
||||
stop burning one tool call per progress tick. On the daily-driver side:
|
||||
`/btw` for side questions that don't pollute context, user-defined
|
||||
memory types with priority + expiry, status-bar field toggles, and
|
||||
field-reported fixes for `/new` silently keeping the old REASONIX.md,
|
||||
the dashboard search toggle's one-way-trap UX, preset/effort
|
||||
persistence on auto-preset switch, plus a stack of desktop UI surfaces
|
||||
gaining real data.
|
||||
|
||||
**Features:**
|
||||
|
||||
- feat(acp): three-stage rollout of `reasonix acp`, the headless ACP
|
||||
entrypoint. Stage 1 brings the NDJSON framing + `initialize` /
|
||||
`session/new` / `session/prompt` round-trip with `agent_thought_chunk`
|
||||
/ `agent_message_chunk` streaming. Stage 2 wires `tool_call` /
|
||||
`tool_call_update` notifications. Stage 3 bridges shell + plan
|
||||
permission gates to `session/request_permission`. (#714, #715, #717)
|
||||
- feat(acp): `--transcript <path>` writes JSONL receipts with `usage`
|
||||
/ `cost` / `prefixHash`, mirroring the same flag on `chat` / `code`.
|
||||
(#766)
|
||||
- feat(acp): `--yolo` flag bypasses plan checkpoints without mutating
|
||||
`~/.reasonix/config.json` — process-per-task callers don't have to
|
||||
rewrite user config to skip the modal. (#767)
|
||||
- feat(acp): `--mcp <spec>` (repeatable) + `--mcp-prefix <str>` for
|
||||
headless callers — domain-specific tools (custom MCP servers,
|
||||
downstream cost-tracking adapters) bridge cleanly into the cache
|
||||
key without config mutation. Shared `loadMcpServers` helper
|
||||
de-duplicates the spec/transport logic across `acp` / `chat` /
|
||||
`run`. (#780)
|
||||
- feat(loop): auto-escalate flash → pro on consecutive read-only tool
|
||||
calls. Caps the cost of dead-end fishing-trip turns where flash
|
||||
loops on file reads without ever committing; pro takes over. (#681,
|
||||
#768)
|
||||
- feat(read_file): outline support extended to Python, Go, Rust, and
|
||||
Markdown — the same skim-mode that worked on TypeScript / JavaScript
|
||||
files now answers "what's in here?" for the rest of the stack. (#769)
|
||||
- feat(bg-jobs): `wait_for_job` gains a `waitFor: 'exit'` mode
|
||||
(default) that doesn't wake on every output chunk — a 5-minute
|
||||
download costs ONE tool call, not one per progress line.
|
||||
`timeoutMs` cap raised to 300_000. (#777)
|
||||
- feat(chat): `/btw <question>` asks a side question without
|
||||
polluting the main turn's context — the answer shows in scrollback,
|
||||
but the message log stays clean. (#736)
|
||||
- feat(chat): hint when no MCP servers are configured — the
|
||||
marketplace pointer is visible from inside `chat` so users don't
|
||||
have to discover `/mcp marketplace` cold. (#716)
|
||||
- feat(memory): user-defined memory types with priority + expires.
|
||||
Type tags steer recall order and per-entry expiry kicks in so stale
|
||||
notes don't outlive the work they describe. (#711)
|
||||
- feat(config): `statusBar` toggles for each individual field
|
||||
(`showBalance` / `showSessionCost` / `showTurnCost` / `showCacheHit`
|
||||
/ `showVersion` / `showFeedbackHint`) — hide what doesn't matter,
|
||||
keep the bar dense for what does. (#626, #712)
|
||||
- feat(desktop): rebuild the UI on a card-based design system with
|
||||
splash intro, cmdk palette, workdir popover, statusbar workspace
|
||||
switch, and the right-panel Files / Tools / Memory tabs now showing
|
||||
real data (file status dots, dir expand/collapse, per-server MCP
|
||||
status, memory store entries). Reserved-tokens bucket added to the
|
||||
context meter; model cards updated to v4 names + 1M context. (#710,
|
||||
#718, #722, #741, #748, #750, #751, #758, #759, #761, #762)
|
||||
- feat(i18n): zh-CN overlay covers the new marketplace flow and
|
||||
closes remaining TUI gaps from the prior round. (#706)
|
||||
|
||||
**Fixes:**
|
||||
|
||||
- fix(loop): `/new` now re-runs the system-prompt builder, so editing
|
||||
REASONIX.md and starting a fresh conversation actually picks up the
|
||||
new content. Previously the prompt was baked into
|
||||
`ImmutablePrefix.system` at startup and `clearLog()` never touched
|
||||
it — users had to restart. Cache-key invalidation happens only when
|
||||
the file actually changed. (#781)
|
||||
- fix(settings): dashboard search toggle no longer one-way-traps users
|
||||
whose config has no `"search"` field. The runtime default is on,
|
||||
but the missing field made the button render ON before any explicit
|
||||
value was set, so a single "enable it" click flipped to OFF. GET
|
||||
now persists the default on first read; subsequent clicks toggle a
|
||||
real boolean. (#782)
|
||||
- fix(preset): switching preset (e.g. `/preset auto`) no longer
|
||||
clobbers the persisted `reasoningEffort` — a `/effort high` set
|
||||
before a preset switch used to silently revert. (#775)
|
||||
- fix(healing): bare `tool_calls` without an `id` now get a fallback
|
||||
stamp so the next DeepSeek call doesn't 400 on validation. (#713)
|
||||
- fix(markdown): emit OSC-8 hyperlinks for `[text](url)` so the
|
||||
rendered markdown is clickable in supporting terminals (iTerm2,
|
||||
WezTerm, Kitty, recent Windows Terminal). (#730)
|
||||
- fix(wallet): pick the largest balance from `balance_infos[]`
|
||||
instead of trusting `[0]` — the upstream can return multiple
|
||||
buckets in any order. (#731)
|
||||
- fix(embed): tolerate providers that drop inputs from a batch
|
||||
embedding response — fall back to per-input retry instead of
|
||||
failing the whole batch. (#732)
|
||||
- fix(composer): keep the prompt cursor visible when the ticker
|
||||
suspends. (#733)
|
||||
- fix(plan): stabilize the expanded approval modal layout so body,
|
||||
options, and footer no longer reflow on rerender. (#734)
|
||||
- fix(session): persist new sessions opened via `/session <name>`
|
||||
instead of dropping them on the next launch. (#740)
|
||||
- fix(jobs): settle the running state on `exit` (not just `close`)
|
||||
and after a taskkill timeout, so Windows + Node ≥ 24 no longer
|
||||
ghost-run a job after `stop_job` returns. (#746)
|
||||
- fix(mcp): silence the race-leg derivative on initialize timeout —
|
||||
the loser of the timer-vs-init race no longer logs a spurious
|
||||
abort. (#747)
|
||||
- fix(at-mention): allow CJK and other Unicode letters in
|
||||
`@`-mention paths — previously the regex narrowed to ASCII and
|
||||
silently dropped any path with non-ASCII characters. (#764)
|
||||
- fix(ui): capture keystrokes while scrolled up in history — typing
|
||||
no longer disappears when the user is browsing the scrollback.
|
||||
(#760)
|
||||
- fix(desktop): drop stale literals from preset, plan badge, and
|
||||
tool card — copy reads from i18n consistently. (#771)
|
||||
- fix(desktop): replace MCP "loading" copy with "configured" once
|
||||
the server actually loads. (#752)
|
||||
- fix(web): align the Changes page chat with `ChatPanel` core logic
|
||||
+ poll `/overview` every 2.5s. (#719, #720)
|
||||
- fix(dashboard): memoise chat feed + rails to kill input-typing lag
|
||||
— large message histories no longer make the composer feel glued.
|
||||
(#729)
|
||||
- fix(changes-ui): remove dead `+` button, widen editor / diff line
|
||||
number gutters, file tab click exits review mode. (#707)
|
||||
|
||||
**Polish / refactor:**
|
||||
|
||||
- refactor(ui): extract `HistoryTypingCapture` so `AppInner` stops
|
||||
subscribing to `pinned` — fewer renders, same behavior. (#763)
|
||||
- chore(plan): document magic numbers + swap the hand-rolled
|
||||
ANSI-strip for the standard `strip-ansi` package. (#738)
|
||||
- chore: oosmetrics badges + weekly health-check workflow; point
|
||||
Star History at the canonical DeepSeek-Reasonix slug. (#772, #773)
|
||||
- docs(readme): badge color pass; thank AIGC Link for XiaoHongShu
|
||||
promotions. (#726)
|
||||
|
||||
## [0.40.0] — 2026-05-12
|
||||
|
||||
**Headline:** npm-only release. The repo also gained a Tauri desktop
|
||||
client (`reasonix desktop`) wired against the agent loop with multi-
|
||||
tab concurrent runtimes — the source is here but the macOS / Windows /
|
||||
Linux installer bundles are NOT shipping this round; they'll get their
|
||||
own release once the signing pipeline is settled. The CLI side picked
|
||||
up a session-scoped checkpoint API + a git-changes panel in the
|
||||
dashboard, a shared pause-gate policy module so the desktop's
|
||||
auto-resolve rules don't drift from the CLI TUI's, plus a wave of
|
||||
field-reported fixes: the long-standing `Maximum update depth` crash
|
||||
inside CardStream is finally gone (this time for good — quantized
|
||||
window), `reasonix code` no longer boots dead when a user has only
|
||||
configured their key via `reasonix setup`, and a half-dozen MCP /
|
||||
shell / stdin / scroll papercuts are gone.
|
||||
|
||||
**Features:**
|
||||
|
||||
- feat(desktop): new Tauri client with multi-tab concurrent runtimes,
|
||||
bundled Node, native filesystem tools, and one persistent agent
|
||||
loop per tab. Source ships with this release; installer bundles
|
||||
ship separately. (#689)
|
||||
- feat(desktop): wallet balance + version chip in the sidebar, active-
|
||||
plan rail (collapsible, per-step risk + completion results),
|
||||
abortable pause-gates, checkpoint / revision cards, ⌘K palette,
|
||||
edit-gate pill, en + zh-CN i18n with a live toggle in Settings.
|
||||
(#701)
|
||||
- feat(changes): in-process checkpoint API + a git-changes panel in
|
||||
the embedded dashboard — zero external git deps, snapshots are
|
||||
session-scoped, restore is one click. (#682)
|
||||
- feat(sandbox): outside-sandbox file access (reads or writes that
|
||||
resolve outside the project root) is now gated behind an approval
|
||||
modal — protects against the model wandering into `/etc`, `~`, or
|
||||
a sibling repo by mistake. (#696)
|
||||
- feat(mcp): loading pill on each MCP server while tools are still
|
||||
being inspected; tool dispatch is gated on readiness so the model
|
||||
can't fire a call into a half-loaded server. (#687)
|
||||
- feat(loop): `--escalate-after <n>` flag +
|
||||
`escalation.failureThreshold` config field to tune how many repair
|
||||
signals it takes before flash → pro auto-escalates. Defaults
|
||||
unchanged. (#699)
|
||||
- feat(status): preset is now visible in the status bar; the model
|
||||
pin survives manual `/model` picks instead of being clobbered on
|
||||
the next preset reload. (#668)
|
||||
- feat(tools, loop): `readOnlyCheck` thrown errors are warned instead
|
||||
of silently swallowed — bugs in custom tool definitions surface
|
||||
immediately. (#670, #673)
|
||||
- feat(tools/web): `web_search` / `web_fetch` now bucket 5xx errors
|
||||
separately with a transient-retry hint so the model retries instead
|
||||
of giving up on a status code. (#676)
|
||||
- feat(i18n): zh-CN coverage extended to MCP lifecycle events,
|
||||
slow-network toast, external-editor messages. (#679)
|
||||
|
||||
**Fixes:**
|
||||
|
||||
- fix(tui): CardStream window position is now quantized to
|
||||
`VISIBLE_BUFFER_ROWS` buckets — sub-bucket `scrollRows` /
|
||||
`outer.height` wiggles no longer flip a boundary card live↔spacer,
|
||||
which was the residual `Maximum update depth exceeded` crash inside
|
||||
`useBoxMetrics` (the #549 fix only patched one instance of the
|
||||
pattern). (#700, #702)
|
||||
- fix(code): `reasonix code` now calls `loadDotenv()` + bridges
|
||||
`~/.reasonix/config.json` → `process.env.DEEPSEEK_API_KEY` like
|
||||
`chat` / `desktop` / `run` already did. Subagent's `DeepSeekClient`
|
||||
is constructed lazily so a missing key doesn't kill boot before the
|
||||
setup wizard can prompt. (#703)
|
||||
- fix(scroll): pinned-mode shrinks now coalesce — a burst of card-
|
||||
teardown re-measurements during an Esc-abort used to snap
|
||||
scrollRows N times, producing a visible flicker. Trailing-edge
|
||||
flush. (#653, #666)
|
||||
- fix(stdin): generic modifyOtherKeys / Kitty CSI envelopes are now
|
||||
decoded correctly (Ctrl/Alt + character keys on terminals that
|
||||
wrap modifier presses in `\e[27;` or `\e[u` sequences). (#692)
|
||||
- fix(ui/shell-confirm): long command previews clamp to the available
|
||||
width so the Allow / Allow-prefix / Deny options stay visible
|
||||
instead of scrolling off-screen. (#691)
|
||||
- fix(skills, memory): frontmatter parser handles BOM, folded lines,
|
||||
and quoted values; previously a BOM-prefixed `REASONIX.md` was
|
||||
silently dropped. (#690)
|
||||
- fix(mcp): inspect failures are classified into network / TLS / HTTP
|
||||
buckets — the error in the UI is now actionable instead of a raw
|
||||
Node `fetch failed`. (#688)
|
||||
- fix(mcp): debug log when the bridge receives a malformed JSON-RPC
|
||||
frame instead of crashing the parser silently. (#669)
|
||||
- fix(i18n): session-pruned banners and overflow errors now point at
|
||||
`/new` and `/sessions` (the live commands) rather than the
|
||||
deprecated `/forget` and `/sessions delete`. (#698)
|
||||
- fix(prompt): system prompt explicitly documents that the model may
|
||||
see OS-absolute paths (Windows `C:\...`) and forbids filesystem-
|
||||
refusal hallucinations like "I don't have access to your filesystem"
|
||||
when filesystem tools are clearly registered. (#667)
|
||||
|
||||
**Polish:**
|
||||
|
||||
- card border + composer cursor + shell modal info-row alignment all
|
||||
hand-tuned; nothing structural, just paper-cuts. (#672)
|
||||
|
||||
## [0.39.1] — 2026-05-11
|
||||
|
||||
**Hotfix:** `0.39.0` shipped with a `postinstall: patch-package` hook
|
||||
but neither `patch-package` (in `devDependencies` only) nor the
|
||||
`patches/` directory (omitted from `files`) made it into the published
|
||||
tarball. Fresh installs failed with `'patch-package' is not recognized
|
||||
as an internal or external command`, taking the whole install down.
|
||||
`0.39.0` is deprecated on npm.
|
||||
|
||||
The `patch-package` approach was wrong by design — it patches
|
||||
`./node_modules/<dep>`, but npm hoists `ink` to the consumer's
|
||||
top-level `node_modules/ink`, so patch-package never finds the target
|
||||
when reasonix is installed as a dependency. The install-time
|
||||
machinery is removed entirely. The ink alt-screen render fix (#639)
|
||||
will return via a forked `ink` package and an `npm:` alias in a later
|
||||
release; until then the alt-screen ghosting on CJK terminals matches
|
||||
0.38.0 behavior. Tracked at #663.
|
||||
|
||||
Three feature/fix PRs that were in flight piggyback on this release.
|
||||
|
||||
**Features:**
|
||||
|
||||
- feat(composer): Ctrl+X opens `$EDITOR` with the current input —
|
||||
drafts long prompts in vim/code/etc. instead of fighting the
|
||||
in-terminal editor. (#647, #661)
|
||||
- feat(composer): Ctrl+P / Ctrl+N navigate input-prefix pickers when
|
||||
open — slash, `@`-mention, slash-arg pickers move up/down with the
|
||||
same keys as ↑/↓ when active, fall through to history recall when
|
||||
closed. (#647, #662)
|
||||
|
||||
**Fixes:**
|
||||
|
||||
- fix(pkg): drop `postinstall: patch-package`, the `patches/`
|
||||
directory and the `patch-package` dependency. Fresh installs no
|
||||
longer fail on the missing patch-package binary.
|
||||
- fix(web): `web_search` / `web_fetch` errors now carry an actionable
|
||||
` — try: …` tail so the model self-corrects (back off on 429,
|
||||
switch engine on 403, pick a smaller URL on oversize, etc.) instead
|
||||
of giving up on a bare status code. (#16, #632)
|
||||
|
||||
## [0.39.0] — 2026-05-11
|
||||
|
||||
**Headline:** field-bug week — a wave of user-reported rendering, tool
|
||||
and network bugs all landed with focused fixes. Two structural wins:
|
||||
the open `AGENTS.md` spec now works without anyone having to create a
|
||||
separate `REASONIX.md`, and `HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY`
|
||||
are honored across every fetch path (DeepSeek API, web search, MCP,
|
||||
doctor) — Node's built-in fetch silently ignored them before. zh-CN
|
||||
coverage is now essentially complete for the TUI surface.
|
||||
|
||||
**Features:**
|
||||
|
||||
- feat(memory): `readProjectMemory` walks `REASONIX.md` → `AGENTS.md` →
|
||||
`AGENT.md` and picks the first that exists; writes target whichever
|
||||
file is already on disk. Projects on the open
|
||||
[agents.md](https://agents.md) spec no longer need a separate
|
||||
reasonix-only memory file. (#635, #636)
|
||||
- feat(net): HTTPS_PROXY / HTTP_PROXY / ALL_PROXY are now honored across
|
||||
every fetch path — undici's global dispatcher gets a `ProxyAgent` at
|
||||
CLI entry, before any client constructs. `/doctor` reports the active
|
||||
proxy URL (credentials redacted). (#646, #650)
|
||||
- feat(mcp): per-server env config via `mcpEnv` — set custom env vars
|
||||
on individual MCP server invocations instead of relying on the shell
|
||||
inherited environment. (#627)
|
||||
- feat(dashboard): `--dashboard-port <port>` flag + `dashboard.port`
|
||||
config field to pin the embedded dashboard to a fixed port; required
|
||||
for stable SSH tunnels. (#624, #625)
|
||||
- feat(doctor): `--json` flag for structured machine-readable output —
|
||||
scriptable / pipeable for CI checks and aggregator dashboards. (#620)
|
||||
- feat(tools): JSON-schema validation for tool-call arguments before
|
||||
dispatch. (#621)
|
||||
- feat(plan): `/plans done <stepId>` and `/plans done all` — manual
|
||||
escape valve when the model forgets to call `mark_step_complete`, so
|
||||
the resume banner doesn't get stuck at `0/N done`. (#641, #645)
|
||||
- feat(i18n): zh-CN now covers setup wizard, theme picker, plan editor,
|
||||
MCP hub, MCP marketplace, MCP browser, checkpoint picker, plan
|
||||
revise, diff/replay — every modal, picker, marketplace, and tool
|
||||
surface routes through `t()` with consistent translations. (#622,
|
||||
#654)
|
||||
|
||||
**Fixes:**
|
||||
|
||||
- fix(render): force per-frame clear in alt-screen — kills `log-update`
|
||||
line-count drift that produced duplicated status bars at the bottom
|
||||
of the viewport for users with CJK / ambiguous-width content on East
|
||||
Asian terminals (Windows Terminal at 120×30 + zh-CN was the field
|
||||
repro). Patched via `patch-package` against ink@7.0.2. (#639, #640)
|
||||
- fix(ui): reset chat scroll to bottom when a confirm modal mounts —
|
||||
shell-command, plan, checkpoint, revision, and ask_choice pickers all
|
||||
appeared off-screen for users who had scrolled up, and arrow-key
|
||||
inputs were captured by the picker so the user looked stuck. (#642,
|
||||
#643)
|
||||
- fix(ui): user-message cards render verbatim, not through Markdown —
|
||||
pasted code / stack traces / paths no longer get silently reformatted
|
||||
(asterisks → italic, leading `#` → H1, `[label](url)` → link with URL
|
||||
hidden, etc.). Assistant turns keep Markdown rendering. (#655, #656)
|
||||
- fix(markdown): decode HTML entities in code blocks and inline code —
|
||||
models sometimes HTML-escape JSON / HTML / XML output (`"`
|
||||
instead of `"`); terminals don't render entities so they leaked
|
||||
visibly. Common named + numeric forms decode at the rendering
|
||||
boundary; unknown names pass through. (#657, #658)
|
||||
- fix(config): `loadEditMode` preserves `yolo` instead of demoting it
|
||||
to `review` — the load path had only recognized `auto` and dropped
|
||||
every other value back to the safe default, so `/mode yolo` survived
|
||||
one process but reset on the next launch and the shell tool's
|
||||
`allowAll` getter never returned true. (#644, #648)
|
||||
- fix(net): same as above but listed under features — the proxy fix is
|
||||
both a new feature (we never had support) and a fix for a class of
|
||||
silent network failures.
|
||||
- fix(tools): short-circuit a 2nd consecutive identical malformed tool
|
||||
call. The model was caught calling `read_file({})` twice in a row
|
||||
with the same missing-required-param error in between; the existing
|
||||
`StormBreaker` threshold of 3 missed this shape. `ToolRegistry` now
|
||||
tracks per-tool fingerprints of validation failures and returns a
|
||||
sharper "DO NOT retry with identical args" error on the second
|
||||
identical malformed call. (#651, #652)
|
||||
- fix(ci): `issue-labeler.yml` now uses `/pattern/i` literal regex form
|
||||
instead of the PCRE `(?i)` inline flag, which JavaScript's RegExp
|
||||
doesn't accept. The auto-label workflow was dying on every issue
|
||||
before this. (#637)
|
||||
|
||||
**Internal:**
|
||||
|
||||
- chore(ci): add issue-triage workflows — auto-label by topic match,
|
||||
surface similar prior issues on new reports. (#634)
|
||||
|
||||
## [0.38.0] — 2026-05-10
|
||||
|
||||
**Headline:** new `/copy` slash command — a vim/tmux-style copy mode
|
||||
that gives users a keyboard path to yank chat text from the alt-screen
|
||||
buffer, where terminal drag-select can't extend past the visible
|
||||
viewport. Plus a long-overdue `docs/CLI-REFERENCE.md` covering every
|
||||
shell subcommand, every slash, and every keybinding, linked from both
|
||||
READMEs and the website footer.
|
||||
|
||||
**Features:**
|
||||
|
||||
- feat(ui): `/copy` enters a frozen-snapshot copy mode. `j`/`k` (or
|
||||
arrows) move the cursor by line; `v` toggles a selection anchor;
|
||||
`y`/`Enter` yanks via the existing OSC 52 path (with the temp-file
|
||||
fallback for >75 KB or terminals that don't honour OSC 52); `g`/`G`
|
||||
jump to top/bottom; `q`/`Esc` exits without yanking. Snapshot spans
|
||||
user / streaming / reasoning cards — tool / diff / etc. are skipped;
|
||||
headers are navigable but excluded from yank, so cross-card
|
||||
selections come out clean. Solves the SSH / mosh / tmux drag-select
|
||||
pain where alt-screen has nothing scrollable above the viewport for
|
||||
the terminal to extend the selection into. (#614, #616)
|
||||
|
||||
**Docs:**
|
||||
|
||||
- docs: `docs/CLI-REFERENCE.md` mirrors `/help` + `/keys` so the surface
|
||||
is greppable from the repo, indexable on the website, and printable
|
||||
for offline reference. Linked from `README.md`, `README.zh-CN.md`,
|
||||
`docs/index.html` (footer), and `docs/configuration.html` (outro CTA),
|
||||
with EN + zh strings in both website i18n dictionaries. (#616)
|
||||
|
||||
## [0.37.0] — 2026-05-10
|
||||
|
||||
**Headline:** boot splash + zh-CN status bar, MCP-handshake stall on
|
||||
launch is gone (bridging deferred to first paint), card virtualization
|
||||
keeps long sessions snappy, and four field-reported bugs that all
|
||||
shared a "silent failure" shape — `/new` was overwriting the live
|
||||
session file so prior transcripts vanished from the Sessions tab,
|
||||
flat-format skills (`<dir>/<name>.md`) didn't appear in the dashboard
|
||||
even though `/skill <name>` ran them, skills missing a `description:`
|
||||
frontmatter were silently dropped from the prefix index so a new
|
||||
session claimed they didn't exist, and the escalation contract told
|
||||
every session it was running on flash so `/preset pro` self-reported
|
||||
as flash when asked.
|
||||
|
||||
**Fixes:**
|
||||
|
||||
- fix(loop): `/new` truncated `~/.reasonix/sessions/code-<project>.jsonl`
|
||||
in place — multiple `/new`s in a project produced exactly one
|
||||
Sessions row and every prior turn was destroyed without warning.
|
||||
`clearLog` now rotates the live jsonl plus sidecars to
|
||||
`<name>__archive_<ts>` via `archiveSession` so the prior conversation
|
||||
survives in the dashboard. The `__archive_` infix sits outside the
|
||||
`${name}-` resume-prefix matcher so archives don't auto-resume on
|
||||
next launch. `sessionName` is unchanged so the cache-first prefix
|
||||
invariant holds. (#587, #590)
|
||||
|
||||
- fix(dashboard): `/api/skills` only walked folder-format skills
|
||||
(`<dir>/<name>/SKILL.md`); flat-format skills (`<dir>/<name>.md`)
|
||||
worked from `/skill <name>` in the TUI but the dashboard tab was
|
||||
silently empty for users who installed them flat. The listing now
|
||||
dispatches on `Dirent` and resolves both layouts; read / save /
|
||||
delete share the same resolver so a flat skill can be edited or
|
||||
removed from the dashboard without spawning a duplicate folder
|
||||
entry. (#586, #589)
|
||||
|
||||
- fix(skills): a skill whose frontmatter omitted `description:` worked
|
||||
in the install session (because `/skill <name>` calls `store.read`
|
||||
directly) and silently disappeared the next session (because
|
||||
`applySkillsIndex` filtered it out of the prefix). Two-layer fix:
|
||||
the dashboard install POST validates frontmatter via the new
|
||||
`validateSkillFrontmatter()` and returns 400 instead of writing a
|
||||
skill the model will never see; `applySkillsIndex` now lists blank-
|
||||
description skills with a placeholder line so the model can name
|
||||
them and tell the user how to fix the frontmatter. (#583, #591)
|
||||
|
||||
- fix(prompt): `ESCALATION_CONTRACT` was a module-level const with
|
||||
`deepseek-v4-flash` baked into the literal — interpolated into
|
||||
`DEFAULT_SYSTEM`, `CODE_SYSTEM_PROMPT`, and `DEFAULT_SUBAGENT_SYSTEM`
|
||||
at module load. A pro session got told it was running on flash and
|
||||
answered honestly when asked which model it was. `escalationContract`
|
||||
is now a function: pro tier gets a short "you are the escalation
|
||||
tier; <<<NEEDS_PRO>>> is a no-op" note (no ladder, since pro can't
|
||||
escalate to itself), other tiers get the full contract with the
|
||||
actual model id interpolated plus an explicit "if asked which model
|
||||
you are, answer `<id>`" line. The three system-prompt sites thread
|
||||
the resolved session model through. The public `CODE_SYSTEM_PROMPT`
|
||||
const is preserved for backward compat. (#582, #592)
|
||||
|
||||
- fix(ui): pressing `/` on the empty home screen left the bordered
|
||||
WelcomeBanner mounted while `SlashSuggestions` rendered below — both
|
||||
occupied the same flex column so the frame buffer interleaved them
|
||||
and the welcome card border drew through the menu rows. The empty-
|
||||
state guard now also requires `slashMatches === null`, so the
|
||||
welcome card yields the moment the menu opens and returns when it
|
||||
closes. (#594)
|
||||
|
||||
- fix(ui): wheel-up felt laggy because `schedule()` was trailing-edge —
|
||||
every tick paid a 16 ms timer before any visual feedback, and on
|
||||
top of Ink reconcile + Yoga layout a single tick cost 30-50 ms
|
||||
before the frame moved. `schedule()` is now leading-edge so the
|
||||
first delta lands immediately; subsequent calls inside the window
|
||||
accumulate. Wheel/PgUp/PgDn step jumps from 3 → 8 rows so each
|
||||
tick travels roughly a third of a viewport. (#571)
|
||||
|
||||
- fix(ui): the default frame flush was 16ms (60Hz), which on
|
||||
winpty / MINTTY / ConEmu / tmux / high-latency SSH couldn't
|
||||
atomically swap the cursor-up rewrite — the previous frame's
|
||||
bottom rows briefly bled through every redraw, visible as
|
||||
vertical bobbing. Default is now 50ms (20Hz); still reads as
|
||||
continuous streaming, no bob on any affected terminal. The
|
||||
`REASONIX_UI=plain` escape hatch (which suppressed every live row)
|
||||
is removed since the new default addresses the same terminals
|
||||
without losing the spinner / status line / live cards. Override
|
||||
via `REASONIX_FLUSH_MS=16` for terminals with atomic frame swap.
|
||||
(#570)
|
||||
|
||||
**Features:**
|
||||
|
||||
- feat(ui): boot splash for `reasonix code` / `reasonix chat`. Cold
|
||||
launch used to flash the alt-screen blank for a few hundred ms
|
||||
before AppInner's first paint completed; users read that as a
|
||||
freeze. The splash holds for one whale-spout cycle (~1.4s) so the
|
||||
REASONIX wordmark lands cleanly and AppInner's heavy first-paint
|
||||
cost (~150 hooks + several disk reads) hides under it. ANSI Shadow
|
||||
block letters in brand color; three-tone shaded whale silhouette
|
||||
with a 7-frame spout cycle and a shifting wave below. Setup screen
|
||||
and SessionPicker bypass the splash. (#588)
|
||||
|
||||
- feat(i18n): status bar, input placeholder, edit-mode hints, and
|
||||
composer prompts route through `t()` with zh-CN coverage. Final
|
||||
pieces of the chat surface that were still hardcoded English —
|
||||
turn / cache / spent / left / slow / disconnect labels in
|
||||
StatusRow, the "ask anything..." placeholder and "⏎ send · ^C quit"
|
||||
hint in PromptInput, and the REVIEW / AUTO / YOLO mode label in
|
||||
LiveRows. (#584)
|
||||
|
||||
**Perf:**
|
||||
|
||||
- perf(boot): MCP bridging moved from `chatCommand`'s pre-render
|
||||
serial loop to an App.tsx mount-time effect that runs in the
|
||||
background. Each `runtime.addSpec(raw)` handshake is 100ms-2s; users
|
||||
with several servers configured used to watch a black alt-screen
|
||||
until the last one finished. The UI now paints immediately, MCP
|
||||
lifecycle events surface as in-app toasts via `log.pushInfo` /
|
||||
`log.pushWarning`, and `loop.prefix.addTool` hot-adds tools as
|
||||
they bridge — first turn after bridging is one cache-miss, same as
|
||||
the existing `/mcp browse install` path. (#585)
|
||||
|
||||
- perf(ui): card virtualization. Yoga used to lay out every card in
|
||||
CardStream's inner Box on every scroll tick — for a 50-card
|
||||
history that's hundreds of rows re-measured per tick. Each card
|
||||
now reports its measured height to the chat-scroll store and
|
||||
CardStream collapses off-viewport ranges into a single spacer Box,
|
||||
so only the 5-10 cards under the viewport (± a 30-row buffer) go
|
||||
through Yoga per scroll. Streaming and freshly-mounted cards always
|
||||
render live for measurement. (#574)
|
||||
|
||||
- perf(ui): scroll state isolated from App.tsx via
|
||||
`chat-scroll-store` (same `useSyncExternalStore` pattern as the
|
||||
agent store). Wheel/arrow ticks no longer re-render AppInner's
|
||||
3,800 lines / 122 hooks per tick — only `CardStream` and the
|
||||
position indicator. The static `↑ earlier` hint is now a live
|
||||
position indicator (`↑ N / M rows above — K more`) that briefly
|
||||
highlights on each applied delta so the user gets instant
|
||||
confirmation. (#573)
|
||||
|
||||
## [0.36.2] — 2026-05-09
|
||||
|
||||
**Headline:** stability sweep on field-reported crashes and freezes —
|
||||
TUI no longer tears down on `/model` / `/sessions`, Esc and `/new`
|
||||
recover from a stuck plan checkpoint, the dashboard chat tab survives
|
||||
long streaming turns, plan-card spinners can't strand themselves on a
|
||||
missed end-event, and the model can't infer its identity from a
|
||||
foreign agent platform's data dir at the workspace root. New `/theme`
|
||||
picker for one-keystroke theme switching.
|
||||
|
||||
**Fixes:**
|
||||
|
||||
- fix(tui): a card-stream layout feedback loop (the `↑ earlier` hint
|
||||
conditionally rendered as a sibling of the measured outer Box) tied
|
||||
`outer.height` to `scrollRows`. Opening `/model` or `/sessions` —
|
||||
which mounts a picker that shrinks the outer column by 10+ rows in
|
||||
a single commit — could stack the cycle deep enough to trip React's
|
||||
`MAX_NESTED_UPDATES = 50`, raising "Maximum update depth exceeded"
|
||||
inside ink's `useBoxMetrics` and tearing down the TUI. The hint row
|
||||
is now reserved unconditionally so its visibility no longer feeds
|
||||
back into measurement. (#549)
|
||||
- fix(tui): `pauseGate.ask` ignored AbortSignal — when a tool was
|
||||
awaiting the gate (e.g. `mark_step_complete` → `plan_checkpoint`)
|
||||
and the user pressed Esc, the gate's promise stayed pending forever,
|
||||
`busy` stayed true, the prompt stayed disabled, and `/new` was
|
||||
silently dropped by `handleSubmit`'s `if (busy) return` guard. New
|
||||
`pauseGate.cancelAll()` resolves every outstanding request with its
|
||||
kind's safe-cancel verdict; Esc-during-busy and `/new` both flush
|
||||
pending modals through it so the awaiting tool fn returns cleanly
|
||||
and the user can recover. (#552)
|
||||
- fix(prompt): when the workspace root contained another agent
|
||||
platform's config (`SOUL.md`, `skills/`, `memories/`, a foreign
|
||||
`REASONIX.md`) the model would browse those files and claim a
|
||||
layered architectural relationship — "the underlying runtime is
|
||||
Hermes Agent" or similar. Top-of-prompt identity guard names the
|
||||
failure mode: workspace files describe the user's project, never
|
||||
what Reasonix is; identity questions are answered from the prompt,
|
||||
not from `ls`. Plus a launch-time detector that warns when those
|
||||
markers sit at the workspace root, suggesting `--dir <real-project>`.
|
||||
(#555)
|
||||
- fix(dashboard): the embedded chat tab triggered Chrome's "Page not
|
||||
responding" dialog during long sessions and concurrent jobs. Each
|
||||
`assistant_delta` (~20/sec, more under fan-in) called setState
|
||||
synchronously, re-rendering every historical `ChatMessage` with no
|
||||
memoization — every delta re-ran `marked.parse` and `hljs.highlight`
|
||||
on unchanged content. Memoized `ChatMessage` via `preact/compat`
|
||||
`memo`, stabilised the per-row `streaming` prop so memo's shallow
|
||||
compare actually bails out, and rAF-coalesced delta accumulation so
|
||||
the streaming bubble re-renders at most once per frame regardless
|
||||
of delta volume. (#560)
|
||||
- fix(loop): tool-card spinners occasionally kept spinning after the
|
||||
underlying work had finished — the `running` flag was set
|
||||
imperatively from paired events, and any exit path that forgot to
|
||||
emit the closing event (storm-breaker, network drop, parent abort
|
||||
propagating, hook block) left the card stuck. Replaced with a
|
||||
finally-guaranteed `InflightSet` on the loop: tools are added at
|
||||
dispatch entry and deleted in `finally` regardless of how the call
|
||||
exits. UI tool cards consult the set via `useIsInflight(card.id)`
|
||||
for the spinner, decoupling running-or-not from end-event delivery.
|
||||
(#566)
|
||||
|
||||
**Features:**
|
||||
|
||||
- feat(ui): bare `/theme` opens a SingleSelect picker listing `auto`
|
||||
+ every registered theme; `/theme <name>` keeps its existing
|
||||
persist-and-report behaviour. (#543, contributed by @J3y0r;
|
||||
re-landed via #567 after rebasing onto current main)
|
||||
|
||||
## [0.36.1] — 2026-05-09
|
||||
|
||||
**Fixes:**
|
||||
|
||||
@@ -9,20 +9,31 @@
|
||||
·
|
||||
<a href="https://esengine.github.io/DeepSeek-Reasonix/">Website</a>
|
||||
·
|
||||
<a href="https://esengine.github.io/DeepSeek-Reasonix/configuration.html">Guide</a>
|
||||
·
|
||||
<a href="./docs/ARCHITECTURE.md">Architecture</a>
|
||||
·
|
||||
<a href="./benchmarks/">Benchmarks</a>
|
||||
·
|
||||
<a href="https://discord.gg/XF78rEME2D">Discord</a>
|
||||
</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=0d1117&labelColor=161b22" 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&color=0d1117&labelColor=161b22" alt="CI"/></a>
|
||||
<a href="./LICENSE"><img src="https://img.shields.io/npm/l/reasonix.svg?style=flat-square&color=0d1117&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=0d1117&labelColor=161b22" alt="downloads"/></a>
|
||||
<a href="./package.json"><img src="https://img.shields.io/node/v/reasonix.svg?style=flat-square&color=0d1117&labelColor=161b22" 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=0d1117&labelColor=161b22&logo=github" alt="GitHub 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=0d1117&labelColor=161b22&logo=github" 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=0d1117&labelColor=161b22&logo=github" alt="Discussions"/></a>
|
||||
<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://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/>
|
||||
@@ -46,105 +57,127 @@
|
||||
|
||||
<br/>
|
||||
|
||||
## Web search
|
||||
## Install
|
||||
|
||||
Reasonix includes `web_search` and `web_fetch` tools. By default it uses **Mojeek** (no setup required). You can switch to a **self-hosted SearXNG** instance — a metasearch engine that aggregates whatever upstream engines your instance is configured for.
|
||||
Requires Node ≥ 22. Works on macOS · Linux · Windows (PowerShell · Git Bash · Windows Terminal).
|
||||
|
||||
### Switching engines (persists to disk)
|
||||
Install Reasonix globally if you want the `reasonix` command available on your `PATH`:
|
||||
|
||||
The `/search-engine` slash command (alias `/se`) writes your choice to `~/.reasonix/config.json` immediately — it survives restarts:
|
||||
~~~bash
|
||||
npm install -g reasonix
|
||||
reasonix code my-project # paste a DeepSeek API key on first run; persists after
|
||||
~~~
|
||||
|
||||
```
|
||||
/search-engine mojeek # default, no external deps
|
||||
/search-engine searxng # SearXNG at http://localhost:8080
|
||||
/search-engine searxng http://192.168.1.100:8888 # custom endpoint
|
||||
```
|
||||
Or run it once without installing globally:
|
||||
|
||||
Equivalent `~/.reasonix/config.json`:
|
||||
~~~bash
|
||||
cd my-project
|
||||
npx reasonix code # always uses the latest package by default
|
||||
~~~
|
||||
|
||||
```json
|
||||
{
|
||||
"webSearchEngine": "searxng",
|
||||
"webSearchEndpoint": "http://localhost:8080"
|
||||
}
|
||||
```
|
||||
Grab a [DeepSeek API key →](https://platform.deepseek.com/api_keys) · `reasonix code --help` for flags.
|
||||
|
||||
The tool picks up the change on the next call — no restart needed.
|
||||
If you use Reasonix daily, global install is the simplest path. If you just want to try it, use `npx`.
|
||||
|
||||
### Starting SearXNG
|
||||
Bare `reasonix` (no subcommand) launches `code` in the current directory — typing `reasonix` and `reasonix code` are equivalent.
|
||||
|
||||
```sh
|
||||
podman run -d --replace --name searxng -p 8080:8080 docker.io/searxng/searxng
|
||||
# or: docker run -d -p 8080:8080 searxng/searxng
|
||||
```
|
||||
| Command | When |
|
||||
|---|---|
|
||||
| `reasonix` / `reasonix code [dir]` | The coding agent. **Start here.** |
|
||||
| `reasonix chat` | Plain chat — no filesystem or shell tools. |
|
||||
| `reasonix run "task"` | One-shot, streams to stdout. Good for pipes. |
|
||||
| `reasonix doctor` | Health check: Node, API key, MCP wiring. |
|
||||
| `reasonix update` | Upgrade Reasonix itself. |
|
||||
|
||||
Verify it's running:
|
||||
Other subcommands (`replay` · `diff` · `events` · `stats` · `index` · `mcp` · `prune-sessions`) are in `reasonix --help` and the [CLI reference](https://esengine.github.io/DeepSeek-Reasonix/#cli).
|
||||
|
||||
```sh
|
||||
curl http://localhost:8080/search?q=test
|
||||
# → HTML search results page
|
||||
```
|
||||
### QQ channel
|
||||
|
||||
> **Note:** The endpoint must include the protocol (`http://`). `localhost:8080` alone will fail — the tool will show a clear error telling you to install SearXNG if the server is unreachable.
|
||||
Reasonix can attach QQ as a remote communication channel for existing `chat` and `code` sessions. It is not a separate runtime mode.
|
||||
|
||||
Start a session first:
|
||||
|
||||
~~~bash
|
||||
reasonix code
|
||||
# or
|
||||
reasonix chat
|
||||
~~~
|
||||
|
||||
Then connect QQ from inside the session:
|
||||
|
||||
~~~text
|
||||
/qq connect
|
||||
~~~
|
||||
|
||||
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.
|
||||
|
||||
See [QQ channel setup](./docs/qq-connect.md) for setup details and QQ Open Platform bot registration.
|
||||
### Desktop client (prerelease)
|
||||
|
||||
A native Tauri client for users who want a GUI over the same loop. Multi-tab, the right-panel shows files the agent has read or edited this session, the same cost / cache / token meters live at the bottom. Same DeepSeek API key, same `~/.reasonix` config — the desktop bundles its own Node runtime, no separate `npm install` step.
|
||||
|
||||
Download platform installers from [GitHub Releases](https://github.com/esengine/DeepSeek-Reasonix/releases). The desktop ships as a **prerelease**: the loop and protocol are the same as the CLI, but the UI is still being polished and the installers aren't code-signed yet.
|
||||
|
||||
- **macOS** — first launch hits Gatekeeper. One-time fix: `xattr -dr com.apple.quarantine /Applications/Reasonix.app` (or right-click → Open → confirm).
|
||||
- **Windows** — SmartScreen warns "Unknown publisher". Click **More info → Run anyway**.
|
||||
- **Linux** — `.deb` and `.AppImage` ship plain, no extra step.
|
||||
|
||||
The CLI remains the canonical surface. Anything that lands in the CLI is also available from the desktop's composer.
|
||||
|
||||
<details>
|
||||
<summary><strong>Working in another folder · chat vs. code · author a skill</strong></summary>
|
||||
|
||||
**Working in a different folder.** Reasonix scopes filesystem tools to the launch directory; pass `--dir` to retarget. Mid-session switching isn't supported by design (memory paths would tangle with stale roots) — quit and relaunch.
|
||||
|
||||
~~~bash
|
||||
npx reasonix code --dir /path/to/project
|
||||
~~~
|
||||
|
||||
**Picking `chat` vs `code`.** `code` is the default and the only mode with filesystem / shell tools and SEARCH/REPLACE review. `chat` is the lighter, tools-off shell — reach for it when you want a thinking partner with MCP attached but no disk access.
|
||||
|
||||
| What you get | `code` | `chat` |
|
||||
|---|---|---|
|
||||
| Filesystem tools + `edit_file` | ✓ | — |
|
||||
| SEARCH/REPLACE → `/apply` review | ✓ | — |
|
||||
| Shell tool (gated) | ✓ | — |
|
||||
| Plan mode · `/todo` · `/skill new` · `/mcp add` | ✓ | — |
|
||||
| Memory (`remember` / `recall_memory`) | project + global | global only |
|
||||
| MCP servers from config · web search · `ask_choice` | ✓ | ✓ |
|
||||
| Coding system prompt | ✓ | generic |
|
||||
| Session scope | per-directory | shared default |
|
||||
|
||||
**Author your first skill.** No remote registry — write them directly. Edit the file (`description:` frontmatter + body), then `/skill list`. Add `runAs: subagent` to spawn an isolated subagent loop instead of inlining the body.
|
||||
|
||||
~~~bash
|
||||
/skill new my-skill # <project>/.reasonix/skills/my-skill.md
|
||||
/skill new my-skill --global # ~/.reasonix/skills for cross-project use
|
||||
~~~
|
||||
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
|
||||
## Install
|
||||
## Configuration
|
||||
|
||||
```bash
|
||||
cd my-project
|
||||
npx reasonix code # paste a DeepSeek API key on first run; persists after
|
||||
```
|
||||
One JSON file at `~/.reasonix/config.json` plus per-project overrides under `<project>/.reasonix/`. The full bilingual reference — every key, every slash command, the on-disk shape of skills/memory/hooks — lives at:
|
||||
|
||||
Requires Node ≥ 22. Tested on macOS · Linux · Windows (PowerShell · Git Bash · Windows Terminal). Get a [DeepSeek API key →](https://platform.deepseek.com/api_keys) · `reasonix code --help` for flags.
|
||||
> 📘 **[Configuration Guide](https://esengine.github.io/DeepSeek-Reasonix/configuration.html)** · [中文](https://esengine.github.io/DeepSeek-Reasonix/configuration.html?lang=zh)
|
||||
|
||||
`npx` is the recommended path — no global install, always picks up the latest version. If you'll use Reasonix daily and want `reasonix` on your `PATH`, run `reasonix update` once and it'll do the `npm install -g` for you.
|
||||
|
||||
### Subcommand cheatsheet
|
||||
|
||||
| Command | When to use |
|
||||
| Topic | Quick read |
|
||||
|---|---|
|
||||
| `reasonix code [dir]` | Coding agent rooted at a project. **Start here.** |
|
||||
| `reasonix chat` | Plain chat — no filesystem tools, just a conversation with persisted history. |
|
||||
| `reasonix run "task"` | One-shot, streams the answer to stdout. Good for shell pipes. |
|
||||
| `reasonix doctor` | Environment health check (Node version, API key, MCP wiring). |
|
||||
| `reasonix update` | Upgrade Reasonix itself. |
|
||||
|
||||
Other subcommands (`replay` · `diff` · `events` · `stats` · `index` · `mcp` · `prune-sessions`) are listed in `reasonix --help` and on the [CLI reference](https://esengine.github.io/DeepSeek-Reasonix/#cli).
|
||||
|
||||
#### When to pick `chat` over `code`
|
||||
|
||||
`code` is the default — it's the only mode that can read, write, or edit files, run shell commands, or apply SEARCH/REPLACE blocks. `chat` strips all of that down to a plain conversation; reach for it when you want a thinking-partner shell without granting filesystem or shell access.
|
||||
|
||||
| What you get | `reasonix code` | `reasonix chat` |
|
||||
|---|---|---|
|
||||
| Native filesystem tools (read · write · `edit_file`) | ✓ | — |
|
||||
| SEARCH/REPLACE edit blocks → `/apply` review | ✓ | — |
|
||||
| Shell tool (with confirm gate, `/mode yolo` to skip) | ✓ | — |
|
||||
| Plan mode · `submit_plan` · `/todo` · `/skill new` · `/mcp add` scaffolding | ✓ | — |
|
||||
| Memory tools (`remember` / `recall_memory`) | project + global | global only |
|
||||
| Web search · `ask_choice` · MCP servers from config | ✓ | ✓ |
|
||||
| Coding-focused system prompt (SEARCH/REPLACE, repo etiquette) | ✓ | generic |
|
||||
| Session scope | per-directory (`code-<basename>`) | shared default |
|
||||
|
||||
`chat` still picks up MCP servers from `~/.reasonix/config.json`, so if all you want is a chat shell with one or two MCP integrations, it's the lighter entrypoint. Otherwise `code` is what every screenshot, benchmark, and slash-command example in the docs assumes.
|
||||
|
||||
**Working in a different folder:** Reasonix scopes filesystem tools to the launch directory. To work elsewhere, pass `--dir`:
|
||||
|
||||
```bash
|
||||
npx reasonix code --dir /path/to/project # or use a relative path
|
||||
```
|
||||
|
||||
Mid-session switching isn't supported by design (the message log + memory paths get tangled with stale roots). Quit and relaunch with a new `--dir` to retarget. `/status` always shows the current pinned workspace.
|
||||
|
||||
**Author your first skill:** Skills are markdown playbooks the model can invoke (`/skill <name>`). There's no remote registry yet — you author them directly:
|
||||
|
||||
```bash
|
||||
/skill new my-skill # scaffolds <project>/.reasonix/skills/my-skill.md
|
||||
/skill new my-skill --global # or under ~/.reasonix/skills for cross-project use
|
||||
```
|
||||
|
||||
Edit the file (`description:` frontmatter + body), then `/skill list` to see it. Add `runAs: subagent` to the frontmatter to spawn an isolated subagent loop instead of inlining the body.
|
||||
| [MCP servers](https://esengine.github.io/DeepSeek-Reasonix/configuration.html#mcp) | stdio · SSE · Streamable HTTP. One spec format works for both `config.json` and `--mcp`. |
|
||||
| [Skills](https://esengine.github.io/DeepSeek-Reasonix/configuration.html#skills) | Markdown playbooks the model can invoke. `inline` or `subagent` mode. |
|
||||
| [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`. |
|
||||
| [Semantic index](https://esengine.github.io/DeepSeek-Reasonix/configuration.html#index) | `reasonix index` — local Ollama or any OpenAI-compatible embedding endpoint. |
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -180,7 +213,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) | yes | yes | yes | yes |
|
||||
| Web search (Mojeek + SearXNG + Metaso) | 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.
|
||||
@@ -190,6 +223,8 @@ For live cache-hit rates, costs, and methodology, see [`benchmarks/`](./benchmar
|
||||
## Documentation
|
||||
|
||||
- [**Architecture**](./docs/ARCHITECTURE.md) — three pillars: cache-first loop, tool-call repair, cost control
|
||||
- [**CLI Reference**](./docs/CLI-REFERENCE.md) — every shell subcommand, every slash command, every keybinding
|
||||
- [**QQ channel setup**](./docs/qq-connect.md) — what it is, commands, setup flow, and QQ Open Platform credentials
|
||||
- [**Benchmarks**](./benchmarks/) — τ-bench-lite harness, transcripts, cost methodology
|
||||
- [**Website**](https://esengine.github.io/DeepSeek-Reasonix/) — getting started, dashboard mockup, TUI mockup
|
||||
- [**Contributing**](./CONTRIBUTING.md) — comment policy, error-handling rules, library-over-hand-rolled
|
||||
@@ -222,7 +257,7 @@ Scoped starter tickets — each with background, code pointers, acceptance crite
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/esengine/reasonix/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=esengine/reasonix&max=100&columns=12" alt="Contributors to esengine/reasonix" width="860"/>
|
||||
<img src="https://contrib.rocks/image?repo=esengine/DeepSeek-Reasonix&max=100&columns=12" alt="Contributors to esengine/DeepSeek-Reasonix" width="860"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -242,11 +277,11 @@ Scoped starter tickets — each with background, code pointers, acceptance crite
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=esengine%2Freasonix&type=timeline&logscale=&legend=top-left">
|
||||
<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/reasonix&type=timeline&theme=dark&logscale&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=esengine/reasonix&type=timeline&logscale&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=esengine/reasonix&type=timeline&logscale&legend=top-left" />
|
||||
<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>
|
||||
|
||||
@@ -258,4 +293,6 @@ Scoped starter tickets — each with background, code pointers, acceptance crite
|
||||
<sub>MIT — see <a href="./LICENSE">LICENSE</a></sub>
|
||||
<br/>
|
||||
<sub>Built by the community at <a href="https://github.com/esengine/reasonix/graphs/contributors">esengine/reasonix</a></sub>
|
||||
<br/>
|
||||
<sub>Special thanks to <a href="https://xhslink.com/m/80ngts127cA">AIGC Link</a> for the promotions on XiaoHongShu.</sub>
|
||||
</p>
|
||||
@@ -9,20 +9,31 @@
|
||||
·
|
||||
<a href="https://esengine.github.io/DeepSeek-Reasonix/">官方网站</a>
|
||||
·
|
||||
<a href="https://esengine.github.io/DeepSeek-Reasonix/configuration.html?lang=zh">配置指南</a>
|
||||
·
|
||||
<a href="./docs/ARCHITECTURE.md">架构文档</a>
|
||||
·
|
||||
<a href="./benchmarks/">基准测试</a>
|
||||
·
|
||||
<a href="https://discord.gg/XF78rEME2D">Discord</a>
|
||||
</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=0d1117&labelColor=161b22" 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&color=0d1117&labelColor=161b22" alt="CI"/></a>
|
||||
<a href="./LICENSE"><img src="https://img.shields.io/npm/l/reasonix.svg?style=flat-square&color=0d1117&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=0d1117&labelColor=161b22" alt="downloads"/></a>
|
||||
<a href="./package.json"><img src="https://img.shields.io/node/v/reasonix.svg?style=flat-square&color=0d1117&labelColor=161b22" 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=0d1117&labelColor=161b22&logo=github" alt="GitHub 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=0d1117&labelColor=161b22&logo=github" 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=0d1117&labelColor=161b22&logo=github" alt="Discussions"/></a>
|
||||
<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://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 — Agents 速度榜 Top 2"/></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 — LLMs 速度榜 Top 3"/></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 — CLI 速度榜 Top 3"/></a>
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
@@ -45,60 +56,100 @@
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
~~~bash
|
||||
cd my-project
|
||||
npx reasonix code # 首次运行粘贴 DeepSeek API Key,之后会记住
|
||||
```
|
||||
~~~
|
||||
|
||||
要求 Node ≥ 22。已在 macOS · Linux · Windows(PowerShell · Git Bash · Windows Terminal)测过。[去拿 DeepSeek API Key →](https://platform.deepseek.com/api_keys) · 完整 flag 看 `reasonix code --help`。
|
||||
要求 Node ≥ 22。在 macOS · Linux · Windows(PowerShell · Git Bash · Windows Terminal)都跑得顺。[去拿 DeepSeek API Key →](https://platform.deepseek.com/api_keys) · 完整 flag 看 `reasonix code --help`。
|
||||
|
||||
`npx` 是推荐路径 —— 不用全局安装,每次都拿到最新版本。如果你天天用、想把 `reasonix` 装到 `PATH` 上,跑一次 `reasonix update` 就行,它会替你跑 `npm install -g`。
|
||||
`npx` 是推荐路径 —— 不用全局安装,每次都拿最新版。如果你天天用、想把 `reasonix` 装到 `PATH`,跑一次 `reasonix update`。
|
||||
|
||||
### 子命令速查
|
||||
|
||||
| 命令 | 适用场景 |
|
||||
| 命令 | 何时用 |
|
||||
|---|---|
|
||||
| `reasonix code [dir]` | 锁在某个项目根目录的编码 agent。**先用这个。** |
|
||||
| `reasonix chat` | 纯聊天 —— 不挂文件系统工具,只是带历史的对话。 |
|
||||
| `reasonix run "task"` | 一次性,把答案直接流到 stdout。适合 shell 管道。 |
|
||||
| `reasonix doctor` | 环境体检(Node 版本、API Key、MCP 接线)。 |
|
||||
| `reasonix code [dir]` | 编码 agent。**先用这个。** |
|
||||
| `reasonix chat` | 纯聊天 —— 不挂文件系统 / shell 工具。 |
|
||||
| `reasonix run "task"` | 一次性,结果流到 stdout。适合 shell 管道。 |
|
||||
| `reasonix doctor` | 体检:Node 版本、API Key、MCP 接线。 |
|
||||
| `reasonix update` | 升级 Reasonix 本身。 |
|
||||
|
||||
其他子命令(`replay` · `diff` · `events` · `stats` · `index` · `mcp` · `prune-sessions`)见 `reasonix --help` 和 [CLI 参考](https://esengine.github.io/DeepSeek-Reasonix/#cli)。
|
||||
其他子命令(`replay` · `diff` · `events` · `stats` · `index` · `mcp` · `prune-sessions`)在 `reasonix --help` 和 [CLI 参考](https://esengine.github.io/DeepSeek-Reasonix/#cli)。
|
||||
|
||||
#### 什么时候用 `chat` 而不是 `code`
|
||||
### QQ 通道
|
||||
|
||||
`code` 是默认入口 —— 只有它能读写、编辑文件、跑 shell 命令、解析 SEARCH/REPLACE 块。`chat` 把这些全砍掉,只剩一个纯对话外壳;当你只想找模型对一对思路、又不想授予文件系统或 shell 权限时用它。
|
||||
Reasonix 可以把 QQ 挂到现有的 `chat` / `code` 会话上,作为远程通信通道使用。它不是一个独立的新模式。
|
||||
|
||||
| 你拿到什么 | `reasonix code` | `reasonix chat` |
|
||||
先启动一个会话:
|
||||
|
||||
~~~bash
|
||||
reasonix code
|
||||
# 或
|
||||
reasonix chat
|
||||
~~~
|
||||
|
||||
然后在会话里连接 QQ:
|
||||
|
||||
~~~text
|
||||
/qq connect
|
||||
~~~
|
||||
|
||||
可用命令:
|
||||
|
||||
- `/qq connect`
|
||||
- `/qq status`
|
||||
- `/qq disconnect`
|
||||
|
||||
启用后,后续的 `chat` / `code` 会话会自动启动 QQ 通道。斜杠命令、确认提示,以及后续助手回复都可以通过 QQ 继续完成,不需要回到终端交互。
|
||||
|
||||
详细说明见 [QQ 连接指南](./docs/qq-connect.zh-CN.md)。
|
||||
<details>
|
||||
<summary><strong>切换工作区 · chat vs. code · 写第一个 Skill</strong></summary>
|
||||
|
||||
**切换工作区。** Reasonix 把文件系统工具作用域绑定在启动目录,传 `--dir` 可以指别处。中途切换是有意不支持的(消息日志和 memory 路径会和旧根目录混在一起)—— 退出再启动。
|
||||
|
||||
~~~bash
|
||||
npx reasonix code --dir /path/to/project
|
||||
~~~
|
||||
|
||||
**`chat` 还是 `code`?** `code` 是默认入口、唯一带文件系统 / shell 工具和 SEARCH/REPLACE 审阅的模式。`chat` 是更轻量的纯对话壳——想要一个挂着 MCP 但没有磁盘权限的“思路助手”时用它。
|
||||
|
||||
| 你拿到什么 | `code` | `chat` |
|
||||
|---|---|---|
|
||||
| 原生文件系统工具(read · write · `edit_file`) | ✓ | — |
|
||||
| SEARCH/REPLACE 编辑块 → `/apply` 审核 | ✓ | — |
|
||||
| Shell 工具(带 confirm gate,`/mode yolo` 跳过) | ✓ | — |
|
||||
| Plan 模式 · `submit_plan` · `/todo` · `/skill new` · `/mcp add` 脚手架 | ✓ | — |
|
||||
| 记忆工具(`remember` / `recall_memory`) | 项目 + 全局 | 仅全局 |
|
||||
| Web 搜索 · `ask_choice` · 从配置加载的 MCP 服务 | ✓ | ✓ |
|
||||
| 编码导向系统提示词(SEARCH/REPLACE、仓库礼仪) | ✓ | 通用 |
|
||||
| Session 作用域 | 按目录(`code-<basename>`) | 共享默认 |
|
||||
| 文件系统工具 + `edit_file` | ✓ | — |
|
||||
| SEARCH/REPLACE → `/apply` 审阅 | ✓ | — |
|
||||
| Shell 工具(带 gate) | ✓ | — |
|
||||
| Plan 模式 · `/todo` · `/skill new` · `/mcp add` | ✓ | — |
|
||||
| Memory(`remember` / `recall_memory`) | 项目 + 全局 | 仅全局 |
|
||||
| 配置里的 MCP · web 搜索 · `ask_choice` | ✓ | ✓ |
|
||||
| 编码导向系统提示词 | ✓ | 通用 |
|
||||
| Session 作用域 | 按目录 | 共享默认 |
|
||||
|
||||
`chat` 同样会从 `~/.reasonix/config.json` 读取 MCP 服务器,所以如果你只是想要一个聊天外壳挂一两个 MCP 集成,它是更轻量的入口。其他场景下,文档里所有截图、基准测试、slash 命令例子默认都是 `code`。
|
||||
**写第一个 Skill。** 暂无在线市场——自己写。编辑文件(`description:` frontmatter + 正文),然后 `/skill list` 就能看到。frontmatter 加 `runAs: subagent` 会以隔离 subagent 跑,而不是把正文内联进父 prompt。
|
||||
|
||||
**在其他目录工作:** Reasonix 把文件系统工具作用域绑定在启动目录。要在别的目录工作,传 `--dir`:
|
||||
~~~bash
|
||||
/skill new my-skill # <project>/.reasonix/skills/my-skill.md
|
||||
/skill new my-skill --global # ~/.reasonix/skills,跨项目共用
|
||||
~~~
|
||||
|
||||
```bash
|
||||
npx reasonix code --dir /path/to/project # 也可以用相对路径
|
||||
```
|
||||
</details>
|
||||
|
||||
中途切换工作区是有意不支持的(消息日志和 memory 路径会和旧的根目录混在一起,状态错乱)。退出后用新的 `--dir` 重新启动来切换。`/status` 始终显示当前锁定的工作区。
|
||||
<br/>
|
||||
|
||||
**写第一个 Skill:** Skills 是模型可以调用的 markdown 剧本(`/skill <name>`)。暂无在线市场 —— 自己写:
|
||||
## 配置
|
||||
|
||||
```bash
|
||||
/skill new my-skill # 在 <project>/.reasonix/skills/my-skill.md 生成模板
|
||||
/skill new my-skill --global # 或者放到 ~/.reasonix/skills,跨项目共用
|
||||
```
|
||||
一个全局 JSON 文件 `~/.reasonix/config.json`,加上项目级 `<project>/.reasonix/` 下的覆盖。完整的双语参考 —— 每一个 key、每一条斜杠命令、skills / memory / hooks 在磁盘上的形状 —— 都在这里:
|
||||
|
||||
编辑文件(`description:` frontmatter + 正文),然后 `/skill list` 就能看到。frontmatter 里加 `runAs: subagent` 会以独立 subagent 跑,而不是把正文内联进父 prompt。
|
||||
> 📘 **[配置指南](https://esengine.github.io/DeepSeek-Reasonix/configuration.html?lang=zh)** · [English](https://esengine.github.io/DeepSeek-Reasonix/configuration.html)
|
||||
|
||||
| 主题 | 速读 |
|
||||
|---|---|
|
||||
| [MCP 服务器](https://esengine.github.io/DeepSeek-Reasonix/configuration.html?lang=zh#mcp) | stdio · SSE · Streamable HTTP。`config.json` 和 `--mcp` 共用同一种 spec 格式。 |
|
||||
| [Skills](https://esengine.github.io/DeepSeek-Reasonix/configuration.html?lang=zh#skills) | 模型可以调用的 markdown 剧本。`inline` 或 `subagent` 两种模式。 |
|
||||
| [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。 |
|
||||
| [语义索引](https://esengine.github.io/DeepSeek-Reasonix/configuration.html?lang=zh#index) | `reasonix index` —— 本地 Ollama,或任何 OpenAI 兼容的 embedding 接口。 |
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -142,6 +193,8 @@ npx reasonix code --dir /path/to/project # 也可以用相对路径
|
||||
## 文档
|
||||
|
||||
- [**架构**](./docs/ARCHITECTURE.md) —— 四大支柱、缓存优先循环、思维提取、脚手架
|
||||
- [**CLI 参考**](./docs/CLI-REFERENCE.md) —— 每个 shell 子命令、每个 slash 命令、每个快捷键
|
||||
- [**QQ 连接指南**](./docs/qq-connect.zh-CN.md) —— 通道定位、命令、配置流程和 QQ 开放平台凭据
|
||||
- [**基准测试**](./benchmarks/) —— τ-bench-lite harness、transcript、成本方法论
|
||||
- [**官方网站**](https://esengine.github.io/DeepSeek-Reasonix/) —— 入门、Dashboard 设计稿、TUI 设计稿
|
||||
- [**贡献指南**](./CONTRIBUTING.md) —— 注释规则、错误处理、用现成库不手写
|
||||
@@ -174,7 +227,7 @@ npx reasonix code --dir /path/to/project # 也可以用相对路径
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/esengine/reasonix/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=esengine/reasonix&max=100&columns=12" alt="esengine/reasonix 贡献者" width="860"/>
|
||||
<img src="https://contrib.rocks/image?repo=esengine/DeepSeek-Reasonix&max=100&columns=12" alt="esengine/DeepSeek-Reasonix 贡献者" width="860"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -194,11 +247,11 @@ npx reasonix code --dir /path/to/project # 也可以用相对路径
|
||||
|
||||
## Star 趋势
|
||||
|
||||
<a href="https://www.star-history.com/?repos=esengine%2Freasonix&type=timeline&logscale=&legend=top-left">
|
||||
<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/reasonix&type=timeline&theme=dark&logscale&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=esengine/reasonix&type=timeline&logscale&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=esengine/reasonix&type=timeline&logscale&legend=top-left" />
|
||||
<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>
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/** tau-bench in-memory DB helpers — pure functions over a nested-table WorldState. */
|
||||
import type { WorldState } from "./types.js";
|
||||
|
||||
/** Deep-clone a WorldState — `structuredClone` is enough since the type is JSON-shaped by contract. */
|
||||
/** Deep-clone a WorldState — `structuredClone` is enough since the type is JSON-shaped by contract. Idempotent: calling cloneDb N times on the same input yields identical copies. */
|
||||
export function cloneDb(db: WorldState): WorldState {
|
||||
return structuredClone(db);
|
||||
}
|
||||
|
||||
/** Get a row by table name and id. Lookup is `db[table][id]` — two-level nested dictionary. Returns the row object or `undefined` if not found. */
|
||||
export function getRow(
|
||||
db: WorldState,
|
||||
table: string,
|
||||
@@ -13,6 +15,7 @@ export function getRow(
|
||||
return db[table]?.[id];
|
||||
}
|
||||
|
||||
/** Set a single field on a row. Mutates `db` in-place — caller should `cloneDb` first if isolation is needed. Returns `true` if the row was found and updated, `false` if not. */
|
||||
export function setField(
|
||||
db: WorldState,
|
||||
table: string,
|
||||
|
||||
@@ -59,7 +59,14 @@
|
||||
|
||||
/* Spacing / radius — tiny radius (2px) keeps web feel without going SaaS */
|
||||
--r: 2px;
|
||||
--r-sm: 4px;
|
||||
--r-md: 4px;
|
||||
|
||||
/* Inline elevation step above --bg-elev-2 for nested cards/dropdowns. */
|
||||
--bg-elev-3: #1d2230;
|
||||
|
||||
/* Brand sweep — used by .md hr and the mini status bar fill. */
|
||||
--gradient-rule: linear-gradient(90deg, var(--c-brand), var(--c-accent));
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
@@ -780,12 +787,12 @@ main { padding: 32px 40px 60px 32px; min-width: 0; }
|
||||
flex: 1; min-height: 0;
|
||||
}
|
||||
.editor-line {
|
||||
display: grid; grid-template-columns: 44px 1fr;
|
||||
display: grid; grid-template-columns: 62px 1fr;
|
||||
padding: 0; white-space: pre;
|
||||
}
|
||||
.editor-line:hover { background: rgba(121,192,255,.04); }
|
||||
.editor-line.cur { background: rgba(121,192,255,.06); }
|
||||
.editor-line .lineno { color: var(--fg-4); text-align: right; padding-right: 14px; user-select: none; font-variant-numeric: tabular-nums; }
|
||||
.editor-line .lineno { color: var(--fg-4); text-align: right; padding: 0 10px 0 20px; user-select: none; font-variant-numeric: tabular-nums; }
|
||||
.editor-line .ln-content { color: var(--fg-1); }
|
||||
.editor-line .ln-content .kw { color: var(--c-accent); }
|
||||
.editor-line .ln-content .str { color: var(--c-ok); }
|
||||
@@ -1157,7 +1164,7 @@ input:focus, textarea:focus, select:focus { border-color: var(--c-brand); }
|
||||
font-size: 16px;
|
||||
}
|
||||
.md h3 {
|
||||
background: var(--grad-8);
|
||||
background: var(--s2);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@@ -2064,7 +2071,7 @@ input:focus, textarea:focus, select:focus { border-color: var(--c-brand); }
|
||||
}
|
||||
|
||||
.modal-picker-row.selected {
|
||||
border-color: var(--accent, #fcd34d);
|
||||
border-color: var(--c-accent);
|
||||
background: var(--bg-elev-3, var(--bg-elev-2));
|
||||
}
|
||||
|
||||
@@ -2079,7 +2086,7 @@ input:focus, textarea:focus, select:focus { border-color: var(--c-brand); }
|
||||
color: var(--fg-2);
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-elev-1, var(--bg-elev-2));
|
||||
background: var(--bg-elev);
|
||||
}
|
||||
|
||||
.modal-picker-subtitle {
|
||||
@@ -2427,3 +2434,801 @@ input:focus, textarea:focus, select:focus { border-color: var(--c-brand); }
|
||||
color: var(--c-brand);
|
||||
}
|
||||
|
||||
.changes-layout {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-body.changes-active {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.changes-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 200px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: var(--r);
|
||||
}
|
||||
|
||||
.changes-panel-left {
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
.changes-panel-center {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.changes-panel-right {
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
.changes-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--bd);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
color: var(--fg-3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .1em;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-elev);
|
||||
}
|
||||
|
||||
.changes-panel-header .glyph {
|
||||
color: var(--c-brand);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.changes-panel-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 899px) {
|
||||
.changes-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
.changes-panel {
|
||||
min-width: 0;
|
||||
min-height: 180px;
|
||||
}
|
||||
.changes-panel-left,
|
||||
.changes-panel-right {
|
||||
width: 100%;
|
||||
max-height: 40vh;
|
||||
}
|
||||
.changes-panel-center {
|
||||
flex: 1;
|
||||
}
|
||||
.changes-layout .resize-handle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Line Comment System ──────────────────────────────────────────── */
|
||||
.line-comment-anchor {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.editor-line:hover .line-comment-anchor.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.line-comment-anchor .plus-icon,
|
||||
.line-comment-anchor .comment-count {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
color: var(--fg-3);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.line-comment-anchor:hover .plus-icon,
|
||||
.line-comment-anchor:hover .comment-count {
|
||||
color: var(--c-brand);
|
||||
}
|
||||
|
||||
.line-comment-anchor .comment-count {
|
||||
background: rgba(121, 192, 255, 0.12);
|
||||
border-radius: var(--r);
|
||||
padding: 0 3px;
|
||||
font-size: 10px;
|
||||
color: var(--c-brand);
|
||||
}
|
||||
|
||||
.line-comment-editor {
|
||||
grid-column: 2;
|
||||
justify-self: stretch;
|
||||
min-width: 0;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--bd-strong);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
margin: 2px 0 4px 0;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.line-comment-bubble {
|
||||
grid-column: 2;
|
||||
justify-self: stretch;
|
||||
min-width: 0;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--bd-strong);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
margin: 2px 0 4px 0;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.line-comment-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
color: var(--fg-3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .08em;
|
||||
}
|
||||
|
||||
.line-comment-textarea {
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: var(--r);
|
||||
color: var(--fg-0);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
padding: 8px 10px;
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
outline: none;
|
||||
line-height: 1.5;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.line-comment-textarea:focus {
|
||||
border-color: var(--c-brand);
|
||||
}
|
||||
|
||||
.line-comment-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.bubble-content {
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: var(--r);
|
||||
color: var(--fg-0);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
padding: 8px 10px;
|
||||
min-height: 36px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bubble-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
|
||||
.bubble-line {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bubble-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.bubble-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--fg-3);
|
||||
padding: 2px 6px;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
border-radius: var(--r);
|
||||
}
|
||||
|
||||
.bubble-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--fg-0);
|
||||
}
|
||||
|
||||
.bubble-btn.danger:hover {
|
||||
color: var(--c-err);
|
||||
}
|
||||
|
||||
.comment-card {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--fg-2);
|
||||
cursor: default;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.comment-card-icon {
|
||||
color: var(--c-brand);
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.comment-card-file {
|
||||
color: var(--c-brand);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.comment-card-content {
|
||||
color: var(--fg-1);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.comment-card-remove {
|
||||
color: var(--fg-3);
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
transition: color 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.comment-card-remove:hover {
|
||||
color: var(--c-err);
|
||||
}
|
||||
|
||||
/* ── File Tree Toggle ─────────────────────────────────────────────── */
|
||||
.file-tree-toggle {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--bd);
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: 1px solid var(--bd);
|
||||
color: var(--fg-3);
|
||||
padding: 4px 10px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.toggle-btn:first-child {
|
||||
border-radius: var(--r) 0 0 var(--r);
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.toggle-btn:last-child {
|
||||
border-radius: 0 var(--r) var(--r) 0;
|
||||
}
|
||||
|
||||
.toggle-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--fg-1);
|
||||
}
|
||||
|
||||
.toggle-btn.active {
|
||||
background: var(--bg-hover);
|
||||
color: var(--fg-0);
|
||||
border-color: var(--fg-4);
|
||||
}
|
||||
|
||||
/* ── Review Tab ───────────────────────────────────────────────────── */
|
||||
.review-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.review-tab .review-icon {
|
||||
font-size: 12px;
|
||||
color: var(--c-brand);
|
||||
}
|
||||
|
||||
.review-tab.active .review-icon {
|
||||
color: var(--c-brand);
|
||||
}
|
||||
|
||||
/* ── Modification Indicator ───────────────────────────────────────── */
|
||||
.tree-node .mod-indicator {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--c-ok);
|
||||
flex-shrink: 0;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* ── Review Diff View ─────────────────────────────────────────────── */
|
||||
.review-diff-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.review-diff-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--bd);
|
||||
background: var(--bg-elev);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.review-diff-title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--fg-2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .08em;
|
||||
}
|
||||
|
||||
.review-diff-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.review-file-item {
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: var(--r);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.review-file-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-elev);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--fg-1);
|
||||
}
|
||||
|
||||
.review-file-header:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.review-file-header .chev {
|
||||
color: var(--fg-3);
|
||||
font-size: 10px;
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
.review-file-header .filename {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.review-file-header .stat {
|
||||
font-size: 11px;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
|
||||
.review-file-header .stat .add {
|
||||
color: var(--c-ok);
|
||||
}
|
||||
|
||||
.review-file-header .stat .rem {
|
||||
color: var(--c-err);
|
||||
}
|
||||
|
||||
.review-file-body {
|
||||
padding: 8px;
|
||||
background: var(--bg-code);
|
||||
}
|
||||
|
||||
.review-empty {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: var(--fg-3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ── Split Diff View ──────────────────────────────────────────── */
|
||||
.split-diff {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.diff-hunk-header {
|
||||
padding: 4px 12px;
|
||||
background: var(--bg-elev-2);
|
||||
color: var(--fg-3);
|
||||
font-size: 11px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid var(--bd);
|
||||
}
|
||||
.diff-hdr-left,
|
||||
.diff-hdr-right {
|
||||
flex: 1;
|
||||
}
|
||||
.diff-line {
|
||||
display: flex;
|
||||
padding: 0 12px;
|
||||
min-height: 20px;
|
||||
}
|
||||
.diff-line.diff-add { background: rgba(126,231,135,.08); }
|
||||
.diff-line.diff-del { background: rgba(255,139,129,.08); }
|
||||
.diff-line {
|
||||
display: flex;
|
||||
padding-left: 8px;
|
||||
}
|
||||
.diff-ln-old,
|
||||
.diff-ln-new {
|
||||
display: inline-block;
|
||||
min-width: 32px;
|
||||
text-align: right;
|
||||
color: var(--fg-4);
|
||||
font-size: 11px;
|
||||
padding: 0 6px 0 12px;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.diff-prefix {
|
||||
width: 14px;
|
||||
flex-shrink: 0;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
.diff-content {
|
||||
flex: 1;
|
||||
white-space: pre;
|
||||
}
|
||||
.diff-add .diff-content { color: var(--c-ok); }
|
||||
.diff-del .diff-content { color: var(--c-err); }
|
||||
|
||||
/* Split rows */
|
||||
.diff-split-row {
|
||||
display: flex;
|
||||
}
|
||||
.diff-split-cell {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
padding: 0 8px;
|
||||
min-height: 20px;
|
||||
border-right: 1px solid var(--bd);
|
||||
overflow: hidden;
|
||||
}
|
||||
.diff-split-cell:last-child { border-right: none; }
|
||||
.diff-split-cell .diff-content {
|
||||
flex: 1;
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Diff source toggle */
|
||||
.diff-source-toggle {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
.diff-source-toggle .toggle-btn {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: var(--r);
|
||||
background: var(--bg-elev);
|
||||
color: var(--fg-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.diff-source-toggle .toggle-btn.active {
|
||||
background: var(--c-brand);
|
||||
color: var(--bg);
|
||||
border-color: var(--c-brand);
|
||||
}
|
||||
.diff-source-toggle .toggle-btn:hover:not(.active) {
|
||||
background: var(--bg-hover);
|
||||
color: var(--fg-0);
|
||||
}
|
||||
|
||||
/* Diff style toggle */
|
||||
.diff-style-toggle {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
.diff-style-toggle .toggle-btn {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: var(--r);
|
||||
background: var(--bg-elev);
|
||||
color: var(--fg-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.diff-style-toggle .toggle-btn.active {
|
||||
background: var(--c-brand);
|
||||
color: var(--bg);
|
||||
border-color: var(--c-brand);
|
||||
}
|
||||
.diff-style-toggle .toggle-btn:hover:not(.active) {
|
||||
background: var(--bg-hover);
|
||||
color: var(--fg-0);
|
||||
}
|
||||
|
||||
.diff-empty {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--fg-3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
Light theme — toggled via data-theme="light" on <html>
|
||||
|
||||
Palette inspired by Element Plus: clean white surfaces, dark body text,
|
||||
and slightly deepened accent colours so they stay legible on light
|
||||
backgrounds. Every variable from the :root dark set is overridden here;
|
||||
hardcoded-hex fixups follow in a second block.
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
[data-theme="light"] {
|
||||
/* Surfaces */
|
||||
--bg: #f5f7fa;
|
||||
--bg-elev: #ffffff;
|
||||
--bg-elev-2: #f0f2f5;
|
||||
--bg-input: #ffffff;
|
||||
--bg-code: #f5f7fa;
|
||||
--bg-hover: #ecf5ff;
|
||||
|
||||
/* Text */
|
||||
--fg-0: #303133; /* primary — headings, emphasis */
|
||||
--fg-1: #606266; /* body — paragraphs, default */
|
||||
--fg-2: #909399; /* secondary — labels, meta */
|
||||
--fg-3: #a8abb2; /* dim — placeholders, hints */
|
||||
--fg-4: #c0c4cc; /* very dim — separators, disabled */
|
||||
|
||||
/* Accents — deepened ~15% so they hit ≥4.5:1 on white */
|
||||
--c-brand: #409eff; /* primary blue (Element Plus) */
|
||||
--c-accent: #9b6bff; /* purple */
|
||||
--c-violet: #8b5cf6; /* violet */
|
||||
--c-ok: #67c23a; /* success green */
|
||||
--c-warn: #e6a23c; /* warning amber */
|
||||
--c-err: #f56c6c; /* error red */
|
||||
--c-info: #409eff;
|
||||
|
||||
/* Chart spectrum */
|
||||
--s1: #409eff;
|
||||
--s2: #36cfc9;
|
||||
--s3: #67c23a;
|
||||
--s4: #e6a23c;
|
||||
--s5: #f56c6c;
|
||||
--s6: #9b6bff;
|
||||
|
||||
/* Borders */
|
||||
--bd: #e4e7ed;
|
||||
--bd-strong: #dcdfe6;
|
||||
|
||||
/* Elevation step above --bg-elev-2 in light mode — matches the surface scale. */
|
||||
--bg-elev-3: #e4e7ed;
|
||||
}
|
||||
|
||||
/* ── Light-theme fixups for hardcoded hex / rgba values ────────────────
|
||||
These selectors use literal colours (not CSS variables) in the dark
|
||||
theme and therefore need explicit overrides to look correct on a
|
||||
white background. Keep them scoped under [data-theme="light"] so
|
||||
dark mode is never touched. ────────────────────────────────────────── */
|
||||
|
||||
/* Hardcoded #14171e borders (dark-theme inner dividers) → var(--bd) */
|
||||
[data-theme="light"] .section,
|
||||
[data-theme="light"] .ssl-row,
|
||||
[data-theme="light"] .sr-card {
|
||||
border-bottom-color: var(--bd);
|
||||
}
|
||||
[data-theme="light"] .overlay::before {
|
||||
border-color: var(--bd);
|
||||
}
|
||||
[data-theme="light"] .composer-foot {
|
||||
border-top-color: var(--bd);
|
||||
}
|
||||
[data-theme="light"] .scale-row {
|
||||
border-bottom-color: var(--bd);
|
||||
}
|
||||
|
||||
/* Primary button hover — lighten the brand colour instead of the
|
||||
dark-theme hardcoded #94cdff. */
|
||||
[data-theme="light"] .btn.primary:hover,
|
||||
[data-theme="light"] button.primary:hover {
|
||||
background: #66b1ff;
|
||||
border-color: #66b1ff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Markdown headings — dark theme puts black text on coloured bg;
|
||||
light theme uses white text for cleaner contrast. */
|
||||
[data-theme="light"] .md h1,
|
||||
[data-theme="light"] .md h2,
|
||||
[data-theme="light"] .md h3 {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Chat banner — rgba tint references the brand colour so it must
|
||||
switch to the light-mode brand hex. */
|
||||
[data-theme="light"] .chat-banner {
|
||||
background: rgba(64,158,255,.06);
|
||||
border-color: rgba(64,158,255,.18);
|
||||
}
|
||||
|
||||
/* Pills — semi-transparent accent backgrounds must shift hue. */
|
||||
[data-theme="light"] .pill.ok { background: rgba(103,194,58,.10); }
|
||||
[data-theme="light"] .pill.warn { background: rgba(230,162,60,.12); }
|
||||
[data-theme="light"] .pill.err { background: rgba(245,108,108,.10); }
|
||||
[data-theme="light"] .pill.info { background: rgba(64,158,255,.10); }
|
||||
[data-theme="light"] .pill.acc { background: rgba(155,107,255,.10); }
|
||||
[data-theme="light"] .pill-err { background: rgba(245,108,108,.10); }
|
||||
[data-theme="light"] .pill-active { background: rgba(64,158,255,.10); }
|
||||
|
||||
/* Step-dot glow — brand-colour box-shadow. */
|
||||
[data-theme="light"] .plan-step.active::before {
|
||||
box-shadow: 0 0 0 3px rgba(64,158,255,.18);
|
||||
}
|
||||
|
||||
/* Filter chips active state. */
|
||||
[data-theme="light"] .chip-f.active {
|
||||
background: rgba(64,158,255,.08);
|
||||
}
|
||||
[data-theme="light"] .chip-f.static.active:hover {
|
||||
background: rgba(64,158,255,.08);
|
||||
}
|
||||
|
||||
/* Matrix cell on-state. */
|
||||
[data-theme="light"] .matrix .cell.on {
|
||||
background: rgba(64,158,255,.05);
|
||||
}
|
||||
|
||||
/* Editor line hover. */
|
||||
[data-theme="light"] .editor-line:hover {
|
||||
background: rgba(64,158,255,.04);
|
||||
}
|
||||
[data-theme="light"] .editor-line.cur {
|
||||
background: rgba(64,158,255,.06);
|
||||
}
|
||||
|
||||
/* Markdown search-result highlight. */
|
||||
[data-theme="light"] .sr-card .sr-snip mark {
|
||||
background: rgba(230,162,60,.18);
|
||||
}
|
||||
|
||||
/* Why / notes callout. */
|
||||
[data-theme="light"] .why {
|
||||
background: rgba(155,107,255,.04);
|
||||
}
|
||||
|
||||
/* Diff-line insert / delete. */
|
||||
[data-theme="light"] .diff-line.ins {
|
||||
background: rgba(103,194,58,0.10);
|
||||
color: var(--c-ok);
|
||||
}
|
||||
[data-theme="light"] .diff-line.del {
|
||||
background: rgba(245,108,108,0.10);
|
||||
color: var(--c-err);
|
||||
}
|
||||
|
||||
/* Diff-row add / remove backgrounds. */
|
||||
[data-theme="light"] .diff-row.add { background: rgba(103,194,58,.06); }
|
||||
[data-theme="light"] .diff-row.rem { background: rgba(245,108,108,.05); }
|
||||
[data-theme="light"] .diff-row .word-add { background: rgba(103,194,58,.22); }
|
||||
[data-theme="light"] .diff-row .word-rem { background: rgba(245,108,108,.20); }
|
||||
|
||||
/* Edit-diff side-by-side markers and borders. */
|
||||
[data-theme="light"] .edit-diff-side-old .edit-diff-marker { color: var(--c-err); }
|
||||
[data-theme="light"] .edit-diff-side-new .edit-diff-marker { color: var(--c-ok); }
|
||||
[data-theme="light"] .edit-diff-row-del .edit-diff-cell-old,
|
||||
[data-theme="light"] .edit-diff-row-change .edit-diff-cell-old {
|
||||
background: rgba(245,108,108,0.10);
|
||||
border-left-color: var(--c-err);
|
||||
}
|
||||
[data-theme="light"] .edit-diff-row-ins .edit-diff-cell-new,
|
||||
[data-theme="light"] .edit-diff-row-change .edit-diff-cell-new {
|
||||
background: rgba(103,194,58,0.10);
|
||||
border-left-color: var(--c-ok);
|
||||
}
|
||||
|
||||
/* Plan-revision modal border. */
|
||||
[data-theme="light"] .modal-revise-reason {
|
||||
border-left-color: var(--c-accent);
|
||||
}
|
||||
|
||||
/* Picker selected row. */
|
||||
[data-theme="light"] .modal-picker-row.selected {
|
||||
border-color: var(--c-warn);
|
||||
}
|
||||
|
||||
/* Modal viewer step-done mark. */
|
||||
[data-theme="light"] .modal-viewer-step-done .modal-viewer-step-mark {
|
||||
color: var(--c-ok);
|
||||
}
|
||||
|
||||
/* Top-bar status dot glow. */
|
||||
[data-theme="light"] .tui-status.online .dot {
|
||||
box-shadow: 0 0 6px rgba(103,194,58,.5);
|
||||
}
|
||||
|
||||
/* Dialog box-shadow — dark mode uses a deep black shadow; light mode
|
||||
needs a softer grey one. */
|
||||
[data-theme="light"] .dialog {
|
||||
box-shadow: 0 18px 48px rgba(0,0,0,.08), 0 0 0 1px rgba(0,0,0,.04);
|
||||
}
|
||||
[data-theme="light"] .cmd-palette {
|
||||
box-shadow: 0 24px 64px rgba(0,0,0,.10);
|
||||
}
|
||||
[data-theme="light"] .popover {
|
||||
box-shadow: 0 12px 32px rgba(0,0,0,.08);
|
||||
}
|
||||
|
||||
/* Scrollbar — lighter thumb for light backgrounds. */
|
||||
[data-theme="light"] * {
|
||||
scrollbar-color: var(--bd-strong) transparent;
|
||||
}
|
||||
[data-theme="light"] *::-webkit-scrollbar-thumb {
|
||||
background: var(--bd);
|
||||
border-color: var(--bg);
|
||||
}
|
||||
[data-theme="light"] *::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--fg-4);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
import htm from "htm";
|
||||
import { h, render } from "preact";
|
||||
import { useCallback, useEffect, useState } from "preact/hooks";
|
||||
import { initLangFromServer, t, useLang } from "./src/i18n";
|
||||
import { MODE } from "./src/lib/api";
|
||||
import { ToastStack, appBus } from "./src/lib/bus";
|
||||
import { ErrorBoundary, ErrorOverlay } from "./src/lib/error-boundary";
|
||||
import { initLangFromServer, t, useLang } from "./src/i18n";
|
||||
import { ChangesPanel } from "./src/panels/changes";
|
||||
import { ChatPanel } from "./src/panels/chat";
|
||||
import { HooksPanel } from "./src/panels/hooks";
|
||||
import { McpPanel } from "./src/panels/mcp";
|
||||
@@ -24,6 +25,28 @@ import { UsagePanel } from "./src/panels/usage";
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
function useTheme() {
|
||||
const [theme, setTheme] = useState(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem("rx.theme");
|
||||
if (stored === "light" || stored === "dark") return stored;
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
if (window.matchMedia?.("(prefers-color-scheme: light)").matches) return "light";
|
||||
return "dark";
|
||||
});
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
try {
|
||||
localStorage.setItem("rx.theme", theme);
|
||||
} catch {
|
||||
/* private mode / disabled storage — ignore */
|
||||
}
|
||||
}, [theme]);
|
||||
return [theme, setTheme];
|
||||
}
|
||||
|
||||
function tabSections() {
|
||||
return [
|
||||
{
|
||||
@@ -31,28 +54,79 @@ function tabSections() {
|
||||
tabs: [
|
||||
{ id: "chat", name: t("app.tabChat"), glyph: "◆", panel: () => html`<${ChatPanel} />` },
|
||||
{ id: "plans", name: t("app.tabPlans"), glyph: "⊞", panel: () => html`<${PlansPanel} />` },
|
||||
{ id: "sessions", name: t("app.tabSessions"), glyph: "›", panel: () => html`<${SessionsPanel} />` },
|
||||
{
|
||||
id: "sessions",
|
||||
name: t("app.tabSessions"),
|
||||
glyph: "›",
|
||||
panel: () => html`<${SessionsPanel} />`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t("app.sectionChanges"),
|
||||
tabs: [
|
||||
{
|
||||
id: "changes",
|
||||
name: t("app.tabChanges"),
|
||||
glyph: "▨",
|
||||
panel: () => html`<${ChangesPanel} />`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t("app.sectionObserve"),
|
||||
tabs: [
|
||||
{ id: "overview", name: t("app.tabOverview"), glyph: "◈", panel: () => html`<${OverviewPanel} />` },
|
||||
{
|
||||
id: "overview",
|
||||
name: t("app.tabOverview"),
|
||||
glyph: "◈",
|
||||
panel: () => html`<${OverviewPanel} />`,
|
||||
},
|
||||
{ id: "usage", name: t("app.tabUsage"), glyph: "$", panel: () => html`<${UsagePanel} />` },
|
||||
{ id: "health", name: t("app.tabSystem"), glyph: "+", panel: () => html`<${SystemPanel} />` },
|
||||
{ id: "semantic", name: t("app.tabSemantic"), glyph: "≈", panel: () => html`<${SemanticPanel} />` },
|
||||
{
|
||||
id: "health",
|
||||
name: t("app.tabSystem"),
|
||||
glyph: "+",
|
||||
panel: () => html`<${SystemPanel} />`,
|
||||
},
|
||||
{
|
||||
id: "semantic",
|
||||
name: t("app.tabSemantic"),
|
||||
glyph: "≈",
|
||||
panel: () => html`<${SemanticPanel} />`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t("app.sectionConfigure"),
|
||||
tabs: [
|
||||
{ id: "tools", name: t("app.tabTools"), glyph: "▣", panel: () => html`<${ToolsPanel} />` },
|
||||
{ id: "permissions", name: t("app.tabPermissions"), glyph: "▎", panel: () => html`<${PermissionsPanel} />` },
|
||||
{
|
||||
id: "permissions",
|
||||
name: t("app.tabPermissions"),
|
||||
glyph: "▎",
|
||||
panel: () => html`<${PermissionsPanel} />`,
|
||||
},
|
||||
{ id: "mcp", name: t("app.tabMcp"), glyph: "M", panel: () => html`<${McpPanel} />` },
|
||||
{ id: "skills", name: t("app.tabSkills"), glyph: "S", panel: () => html`<${SkillsPanel} />` },
|
||||
{ id: "memory", name: t("app.tabMemory"), glyph: "·", panel: () => html`<${MemoryPanel} />` },
|
||||
{
|
||||
id: "skills",
|
||||
name: t("app.tabSkills"),
|
||||
glyph: "S",
|
||||
panel: () => html`<${SkillsPanel} />`,
|
||||
},
|
||||
{
|
||||
id: "memory",
|
||||
name: t("app.tabMemory"),
|
||||
glyph: "·",
|
||||
panel: () => html`<${MemoryPanel} />`,
|
||||
},
|
||||
{ id: "hooks", name: t("app.tabHooks"), glyph: "H", panel: () => html`<${HooksPanel} />` },
|
||||
{ id: "settings", name: t("app.tabSettings"), glyph: "⌘", panel: () => html`<${SettingsPanel} />` },
|
||||
{
|
||||
id: "settings",
|
||||
name: t("app.tabSettings"),
|
||||
glyph: "⌘",
|
||||
panel: () => html`<${SettingsPanel} />`,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -60,7 +134,9 @@ function tabSections() {
|
||||
|
||||
function App() {
|
||||
useLang();
|
||||
useEffect(() => { initLangFromServer(); }, []);
|
||||
useEffect(() => {
|
||||
initLangFromServer();
|
||||
}, []);
|
||||
const [activeId, setActiveId] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem("rx.activeTab") ?? "chat";
|
||||
@@ -68,6 +144,7 @@ function App() {
|
||||
return "chat";
|
||||
}
|
||||
});
|
||||
const [theme, setTheme] = useTheme();
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem("rx.sidebarCollapsed") === "1";
|
||||
@@ -136,6 +213,11 @@ function App() {
|
||||
</div>
|
||||
<div class="side-foot">
|
||||
<span class="label">127.0.0.1</span>
|
||||
<span
|
||||
class="toggle theme-toggle"
|
||||
title=${t("app.themeToggle") + (theme === "dark" ? ` (${t("app.themeLight")})` : ` (${t("app.themeDark")})`)}
|
||||
onClick=${() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
>${theme === "dark" ? "☀" : "☾"}</span>
|
||||
<span
|
||||
class="toggle"
|
||||
title=${sidebarCollapsed ? "expand" : "collapse"}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
@@ -300,7 +301,11 @@ export function ToolCard({ msg }: ToolCardProps) {
|
||||
`;
|
||||
}
|
||||
|
||||
export function ChatMessage({ msg, streaming }: ChatMessageProps) {
|
||||
// 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") {
|
||||
@@ -321,7 +326,7 @@ export function ChatMessage({ msg, streaming }: ChatMessageProps) {
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
//
|
||||
// Each component renders a card matching the TUI's ModalCard accent
|
||||
|
||||
@@ -17,7 +17,64 @@ export const en = {
|
||||
tabMemory: "Memory",
|
||||
tabHooks: "Hooks",
|
||||
tabSettings: "Settings",
|
||||
sectionChanges: "Changes",
|
||||
tabChanges: "Changes",
|
||||
footer: "127.0.0.1 only · token-gated",
|
||||
themeToggle: "Toggle theme",
|
||||
themeLight: "Light",
|
||||
themeDark: "Dark",
|
||||
},
|
||||
changes: {
|
||||
chatPlaceholder: "Ask about your code...",
|
||||
chatWelcome: "Changes — ask questions about your project files.",
|
||||
chatSend: "Send",
|
||||
viewerPlaceholder: "Select a file to view",
|
||||
treeEmpty: "(empty)",
|
||||
tabClose: "Close tab",
|
||||
newConversation: "New",
|
||||
clearConversation: "Clear",
|
||||
newTitle: "/new — wipe conversation context",
|
||||
clearTitle: "/clear — wipe visible scrollback",
|
||||
newConfirmBusy: "A turn is in flight. Abort and start a new conversation?",
|
||||
newConfirm: "Clear current conversation and start fresh?",
|
||||
newToast: "new conversation",
|
||||
clearToast: "scrollback cleared",
|
||||
newFailed: "/new failed: {error}",
|
||||
clearFailed: "/clear failed: {error}",
|
||||
chatSendBtn: "Send",
|
||||
fileTreeTitle: "Files",
|
||||
codeViewerTitle: "Code Viewer",
|
||||
chatPanelTitle: "Chat",
|
||||
loadingFiles: "Loading project files…",
|
||||
review: "Review",
|
||||
allFiles: "All Files",
|
||||
changes: "changes",
|
||||
commentLabel: "Commenting on line",
|
||||
commentPlaceholder: "Add a comment…",
|
||||
commentCancel: "Cancel",
|
||||
commentSubmit: "Comment",
|
||||
commentLine: "Line",
|
||||
commentEdit: "Edit",
|
||||
commentDelete: "Delete",
|
||||
diffSourceGit: "Git changes",
|
||||
diffSourceSession: "Previous session",
|
||||
diffSourceCheckpoint: "Checkpoint",
|
||||
checkpointEmpty: "No checkpoints in this workspace yet.",
|
||||
restoreBtn: "Restore",
|
||||
restoreConfirm: 'Restore "{name}"? This will overwrite current files.',
|
||||
deleteBtn: "Delete",
|
||||
deleteConfirm: 'Delete checkpoint "{name}"? Snapshot will be removed, files stay unchanged.',
|
||||
createBtn: "Snapshot",
|
||||
createPlaceholder: "name for snapshot…",
|
||||
backToList: "back to list",
|
||||
reviewEmpty: "No pending reviews.",
|
||||
diffStyleUnified: "Unified",
|
||||
diffStyleSplit: "Split",
|
||||
expandAll: "Expand all",
|
||||
collapseAll: "Collapse all",
|
||||
createFailed: "create failed",
|
||||
restoreFailed: "restore failed",
|
||||
deleteFailed: "delete failed",
|
||||
},
|
||||
common: {
|
||||
loading: "loading…",
|
||||
@@ -88,6 +145,10 @@ export const en = {
|
||||
loopRunning: "running",
|
||||
loopIter: "iter {iter}",
|
||||
loopFiresIn: "fires in {remaining}",
|
||||
sectionSkills: "Skills",
|
||||
skillPaths: "skill paths",
|
||||
skillPathsPlaceholder: "/path/to/skills-a, /path/to/skills-b",
|
||||
skillPathsNote: "comma-separated · next /new or new session",
|
||||
sectionRuntime: "Runtime",
|
||||
activeModel: "active model",
|
||||
modelPricingLine: "${hit} hit · ${miss} miss · ${out} out per 1M tok",
|
||||
@@ -255,6 +316,74 @@ export const en = {
|
||||
readOnly: "read-only",
|
||||
write: "write",
|
||||
flat: "flat",
|
||||
desc: {
|
||||
web_search:
|
||||
"Search the public web. Returns ranked results with title, url, and snippet. Call this when the answer depends on current state — events, prices, releases, real-world status.",
|
||||
web_fetch:
|
||||
"Download a URL and return its visible text content (scripts/styles/nav stripped). Use after web_search when a snippet isn't enough.",
|
||||
run_command:
|
||||
"Run a shell command in the project root; returns combined stdout+stderr. Allowlisted read-only commands run immediately; mutations are gated by user confirmation.",
|
||||
run_background:
|
||||
"Spawn a long-running process and detach. Returns a job id for tailing logs, waiting for completion, or killing. Use for dev servers, watchers, and one-shot long jobs.",
|
||||
job_output:
|
||||
"Read the latest output of a background job. Returns the tail of the buffer and tells you whether the job is still running.",
|
||||
wait_for_job:
|
||||
"Block server-side until a background job finishes, bounded by timeout. Use instead of polling job_output in a loop.",
|
||||
stop_job:
|
||||
"Stop a background job. SIGTERM first, SIGKILL after a grace period. Safe to call on an already-exited job.",
|
||||
list_jobs:
|
||||
"List every background job started this session — running and exited — with id, command, pid, and status.",
|
||||
remember:
|
||||
"Save a memory for future sessions. Use when the user states a preference, corrects your approach, shares a non-obvious fact, or asks you to remember something.",
|
||||
forget:
|
||||
"Delete a memory file and remove it from MEMORY.md. Use when the user asks to forget something or a remembered fact is now wrong.",
|
||||
recall_memory:
|
||||
"Read the full body of a memory file when its one-line summary isn't enough detail.",
|
||||
read_file:
|
||||
"Read a file under the sandbox root. Supports head/tail/range scoping to save context. Auto-returns a preview for files over 200 lines.",
|
||||
list_directory:
|
||||
"List entries in a directory. Returns one line per entry, marking directories with a trailing slash.",
|
||||
directory_tree:
|
||||
"Recursively list entries in a directory as an indented tree. Budget-aware with auto-collapse for large subtrees.",
|
||||
search_files:
|
||||
"Find files whose NAME matches a substring or regex. Case-insensitive. Skips dependency/build directories by default.",
|
||||
search_content:
|
||||
"Recursively grep file CONTENTS for a substring or regex. Returns matches in path:line:text format. The right tool for finding references.",
|
||||
glob: "List files matching a glob pattern, sorted by mtime. Default limit 200, max 1000. Skips node_modules/.git/dist by default.",
|
||||
get_file_info: "Stat a path under the sandbox root. Returns type, size in bytes, and mtime.",
|
||||
write_file:
|
||||
"Create or overwrite a file with the given content. Parent directories are created as needed.",
|
||||
edit_file:
|
||||
"Apply a SEARCH/REPLACE edit to an existing file. The search must match exactly and be unique in the file.",
|
||||
multi_edit:
|
||||
"Apply N SEARCH/REPLACE edits across one or more files atomically. If any edit fails, no files are written.",
|
||||
create_directory: "Create a directory (and any missing parents) under the sandbox root.",
|
||||
move_file: "Rename or move a file or directory under the sandbox root.",
|
||||
delete_file:
|
||||
"Delete one file under the sandbox root. Refuses directories — use delete_directory for those.",
|
||||
delete_directory:
|
||||
"Recursively delete a directory under the sandbox root. Pass recursive:false to refuse non-empty directories.",
|
||||
copy_file:
|
||||
"Copy a file or directory under the sandbox root. Refuses to overwrite an existing destination.",
|
||||
submit_plan:
|
||||
"Submit one concrete plan for a review gate. Use for multi-file refactors, architecture changes, or anything expensive to undo.",
|
||||
mark_step_complete:
|
||||
"Mark one step of the approved plan as done. Call exactly once after finishing each step.",
|
||||
revise_plan:
|
||||
"Surgically replace the remaining steps of an in-flight plan. Done steps are never touched.",
|
||||
run_skill:
|
||||
"Invoke a playbook from the Skills index. Pass the bare skill name. Subagent-tagged skills spawn an isolated subagent.",
|
||||
spawn_subagent:
|
||||
"Spawn an isolated subagent for a self-contained subtask. Use for parallel fan-out or when the work needs many file reads.",
|
||||
todo_write:
|
||||
"In-session task tracker for multi-step work. Replaces the entire list every call. No approval gate or file writes.",
|
||||
ask_choice:
|
||||
"Present 2-6 alternatives to the user. Use when the user asks for options or you need a preference decision.",
|
||||
create_skill:
|
||||
"Scaffold a new skill the user can invoke later via /skill. Supports inline and subagent run modes.",
|
||||
add_mcp_server:
|
||||
"Register a new MCP server in the user's config. Takes effect on the next session. Supports stdio, SSE, and streamable-http.",
|
||||
},
|
||||
},
|
||||
permissions: {
|
||||
loading: "loading permissions…",
|
||||
@@ -374,6 +503,7 @@ export const en = {
|
||||
loading: "loading skills…",
|
||||
filterPlaceholder: "filter skills",
|
||||
project: "project",
|
||||
custom: "custom",
|
||||
global: "global",
|
||||
builtin: "builtin",
|
||||
newSkill: "new skill",
|
||||
@@ -381,6 +511,7 @@ export const en = {
|
||||
runs7d: "runs · 7d",
|
||||
pickHint: "Pick a skill on the left, or create a new one above.",
|
||||
readOnlyBuiltin: "read-only · builtin",
|
||||
readOnlyCustom: "read-only · custom path",
|
||||
builtinDesc:
|
||||
"Built-in skills ship with Reasonix; the model picks them up automatically. To customize, create a project- or global-scoped skill with the same name.",
|
||||
saved: "saved {scope}/{name}",
|
||||
@@ -465,7 +596,8 @@ export const en = {
|
||||
lastBuild: "last build",
|
||||
builtWith: "built with",
|
||||
currentTarget: "current target",
|
||||
incompatibleHint: "This on-disk index was built for a different provider or model. Run Rebuild to replace it.",
|
||||
incompatibleHint:
|
||||
"This on-disk index was built for a different provider or model. Run Rebuild to replace it.",
|
||||
runIndexHint: "Run an index to enable semantic_search.",
|
||||
reIndex: "Re-index",
|
||||
build: "Build",
|
||||
@@ -594,4 +726,21 @@ export const en = {
|
||||
pickerNewPlaceholder: "Name (leave blank for default)",
|
||||
viewerClose: "Close",
|
||||
},
|
||||
error: {
|
||||
title: "Something broke in the dashboard",
|
||||
body: "The TUI is unaffected — only this browser tab tripped. You can dismiss and keep working, or report it so we can fix the underlying cause.",
|
||||
copyDetails: "Copy details",
|
||||
copied: "Copied ✓",
|
||||
reportOnGithub: "Report on GitHub",
|
||||
dismiss: "Dismiss (Esc)",
|
||||
crashing: "this panel keeps crashing — {name}",
|
||||
retry: "Try again",
|
||||
recovering: "recovering…",
|
||||
},
|
||||
fmt: {
|
||||
justNow: "just now",
|
||||
minutesAgo: "{n}m ago",
|
||||
hoursAgo: "{n}h ago",
|
||||
daysAgo: "{n}d ago",
|
||||
},
|
||||
};
|
||||
@@ -17,7 +17,64 @@ export const zhCN = {
|
||||
tabMemory: "记忆",
|
||||
tabHooks: "钩子",
|
||||
tabSettings: "设置",
|
||||
sectionChanges: "变更",
|
||||
tabChanges: "变更",
|
||||
footer: "仅 127.0.0.1 · Token 保护",
|
||||
themeToggle: "切换主题",
|
||||
themeLight: "浅色",
|
||||
themeDark: "深色",
|
||||
},
|
||||
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: "删除",
|
||||
diffSourceCheckpoint: "检查点",
|
||||
checkpointEmpty: "暂无检查点",
|
||||
restoreBtn: "恢复",
|
||||
restoreConfirm: '确定要恢复检查点"{name}"吗?当前更改将被覆盖。',
|
||||
deleteBtn: "删除",
|
||||
deleteConfirm: '确定要删除检查点"{name}"吗?',
|
||||
createBtn: "创建",
|
||||
createPlaceholder: "检查点名称…",
|
||||
backToList: "返回列表",
|
||||
reviewEmpty: "暂无可审查的更改",
|
||||
diffSourceGit: "Git 变更",
|
||||
diffSourceSession: "上一轮变更",
|
||||
diffStyleUnified: "统一视图",
|
||||
diffStyleSplit: "分栏视图",
|
||||
expandAll: "全部展开",
|
||||
collapseAll: "全部折叠",
|
||||
createFailed: "创建失败",
|
||||
restoreFailed: "恢复失败",
|
||||
deleteFailed: "删除失败",
|
||||
},
|
||||
common: {
|
||||
loading: "加载中…",
|
||||
@@ -88,6 +145,10 @@ export const zhCN = {
|
||||
loopRunning: "运行中",
|
||||
loopIter: "第 {iter} 次",
|
||||
loopFiresIn: "{remaining} 后触发",
|
||||
sectionSkills: "技能",
|
||||
skillPaths: "技能路径",
|
||||
skillPathsPlaceholder: "/path/to/skills-a, /path/to/skills-b",
|
||||
skillPathsNote: "英文逗号分隔 · 下次 /new 或新会话生效",
|
||||
sectionRuntime: "运行时",
|
||||
activeModel: "当前模型",
|
||||
modelPricingLine: "${hit} 命中 · ${miss} 未命中 · ${out} 输出 / 100 万 tok",
|
||||
@@ -251,6 +312,55 @@ export const zhCN = {
|
||||
readOnly: "只读",
|
||||
write: "写入",
|
||||
flat: "扁平",
|
||||
desc: {
|
||||
web_search:
|
||||
"搜索公共网络。返回带标题、URL 和摘要的排序结果。当答案的正确性依赖于当前状态时调用——事件、价格、发布、现实世界的状态。",
|
||||
web_fetch:
|
||||
"下载 URL 并返回其可见文本内容(已剥离脚本/样式/导航)。在 web_search 摘要不够时使用。",
|
||||
run_command:
|
||||
"在项目根目录执行 shell 命令,返回合并的标准输出和标准错误。白名单中的只读命令立即执行;可能修改状态的操作需用户确认。",
|
||||
run_background:
|
||||
"启动一个长时间运行的进程并分离。返回任务 ID 用于查看日志、等待完成或终止。用于开发服务器、文件监听器和一次性长时间任务。",
|
||||
job_output: "读取后台任务的最新输出。返回缓冲区末尾内容并告知任务是否仍在运行。",
|
||||
wait_for_job: "在服务端阻塞直到后台任务完成(有超时限制)。用于替代轮询 job_output。",
|
||||
stop_job: "停止后台任务。先发送 SIGTERM,宽限期后发送 SIGKILL。可安全调用已退出的任务。",
|
||||
list_jobs: "列出本次会话启动的所有后台任务——运行中和已退出的——包含 ID、命令、PID 和状态。",
|
||||
remember:
|
||||
"保存一条记忆供未来会话使用。当用户陈述偏好、纠正你的方法、分享非显而易见的事实或要求你记住某事时使用。",
|
||||
forget:
|
||||
"删除记忆文件并从 MEMORY.md 中移除。当用户要求忘记某事或之前记住的事实已不再正确时使用。",
|
||||
recall_memory: "当记忆文件的一行摘要不够详细时,读取其完整内容。",
|
||||
read_file:
|
||||
"读取沙箱根目录下的文件。支持 head/tail/range 范围读取以节省上下文。超过 200 行的文件自动返回预览。",
|
||||
list_directory: "列出目录中的条目。每行一个条目,目录以斜杠标记。",
|
||||
directory_tree: "递归列出目录中的条目,以缩进树形结构显示。对大子目录自动折叠以节省预算。",
|
||||
search_files: "根据名称匹配子串或正则表达式查找文件。不区分大小写。默认跳过依赖/构建目录。",
|
||||
search_content:
|
||||
"递归搜索文件内容中的子串或正则表达式。以 path:line:text 格式返回匹配结果。查找引用的正确工具。",
|
||||
glob: "按 glob 模式列出文件,按修改时间排序。默认限制 200,最大 1000。默认跳过 node_modules/.git/dist。",
|
||||
get_file_info: "获取沙箱根目录下路径的状态信息。返回类型、字节大小和修改时间。",
|
||||
write_file: "创建或覆盖文件,内容由参数指定。按需创建父目录。",
|
||||
edit_file: "对现有文件应用 SEARCH/REPLACE 编辑。搜索必须完全匹配且在文件中唯一。",
|
||||
multi_edit:
|
||||
"跨一个或多个文件原子性地应用 N 个 SEARCH/REPLACE 编辑。如果任何编辑失败,不写入任何文件。",
|
||||
create_directory: "在沙箱根目录下创建目录(以及任何缺失的父目录)。",
|
||||
move_file: "重命名或移动沙箱根目录下的文件或目录。",
|
||||
delete_file: "删除沙箱根目录下的一个文件。拒绝目录——请使用 delete_directory 删除目录。",
|
||||
delete_directory: "递归删除沙箱根目录下的目录。传入 recursive:false 可拒绝非空目录。",
|
||||
copy_file: "复制沙箱根目录下的文件或目录。拒绝覆盖已存在的目标。",
|
||||
submit_plan:
|
||||
"提交一个具体的计划以供审查审批。用于多文件重构、架构变更或任何撤销代价高昂的操作。",
|
||||
mark_step_complete: "将已批准计划的一个步骤标记为完成。完成每个步骤后恰好调用一次。",
|
||||
revise_plan: "外科手术式替换进行中计划的剩余步骤。已完成的步骤永远不会被触及。",
|
||||
run_skill:
|
||||
"从技能索引中调用一个 playbook。传入裸技能名称。标记为子代理的技能将启动独立的子代理。",
|
||||
spawn_subagent: "为一个独立子任务启动隔离的子代理。用于并行分发或需要大量文件读取的工作。",
|
||||
todo_write: "多步工作的会话内任务跟踪器。每次调用替换整个列表。无审批关卡,不写入文件。",
|
||||
ask_choice: "向用户展示 2-6 个选项。当用户要求选择或需要偏好决策时使用。",
|
||||
create_skill: "创建一个新技能,用户可通过 /skill 命令调用。支持内联和子代理两种运行模式。",
|
||||
add_mcp_server:
|
||||
"在用户配置中注册新的 MCP 服务器。下次会话生效。支持 stdio、SSE 和 streamable-http。",
|
||||
},
|
||||
},
|
||||
permissions: {
|
||||
loading: "加载权限…",
|
||||
@@ -368,6 +478,7 @@ export const zhCN = {
|
||||
loading: "加载技能…",
|
||||
filterPlaceholder: "筛选技能",
|
||||
project: "项目",
|
||||
custom: "自定义",
|
||||
global: "全局",
|
||||
builtin: "内置",
|
||||
newSkill: "新技能",
|
||||
@@ -375,6 +486,7 @@ export const zhCN = {
|
||||
runs7d: "次运行 · 7 天",
|
||||
pickHint: "选择左侧的技能,或在上方创建新技能。",
|
||||
readOnlyBuiltin: "只读 · 内置",
|
||||
readOnlyCustom: "只读 · 自定义路径",
|
||||
builtinDesc:
|
||||
"内置技能随 Reasonix 一起发布;模型会自动识别。如需自定义,请创建同名的项目或全局技能。",
|
||||
saved: "已保存 {scope}/{name}",
|
||||
@@ -457,7 +569,8 @@ export const zhCN = {
|
||||
lastBuild: "上次构建",
|
||||
builtWith: "构建来源",
|
||||
currentTarget: "当前目标",
|
||||
incompatibleHint: "磁盘上的这个索引是为不同的 provider 或 model 构建的。运行“完全重建”即可替换。",
|
||||
incompatibleHint:
|
||||
"磁盘上的这个索引是为不同的 provider 或 model 构建的。运行“完全重建”即可替换。",
|
||||
runIndexHint: "运行索引以启用 semantic_search。",
|
||||
reIndex: "重建索引",
|
||||
build: "构建",
|
||||
@@ -468,6 +581,7 @@ export const zhCN = {
|
||||
openaiCompat: "OpenAI-Compatible",
|
||||
apiUrl: "API URL",
|
||||
apiKey: "API Key",
|
||||
apiKeyStoredNote: "API Key 仅保存在本地,不会发送到第三方。",
|
||||
customRequestBody: "自定义请求体",
|
||||
invalidCustomRequestBody: "自定义请求体必须是合法 JSON:{error}",
|
||||
customRequestBodyMustBeObject: "自定义请求体必须是 JSON 对象。",
|
||||
@@ -584,4 +698,21 @@ export const zhCN = {
|
||||
pickerNewPlaceholder: "名称(留空使用默认)",
|
||||
viewerClose: "关闭",
|
||||
},
|
||||
error: {
|
||||
title: "仪表盘出错了",
|
||||
body: "TUI 不受影响——只有这个浏览器标签出了问题。通常可以直接关闭并继续使用,不会打断智能体。",
|
||||
copyDetails: "复制详情",
|
||||
copied: "已复制 ✓",
|
||||
reportOnGithub: "在 GitHub 上报告",
|
||||
dismiss: "关闭 (Esc)",
|
||||
crashing: "此面板持续崩溃 — {name}",
|
||||
retry: "重试",
|
||||
recovering: "恢复中…",
|
||||
},
|
||||
fmt: {
|
||||
justNow: "刚刚",
|
||||
minutesAgo: "{n} 分钟前",
|
||||
hoursAgo: "{n} 小时前",
|
||||
daysAgo: "{n} 天前",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
/** 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,6 +1,7 @@
|
||||
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);
|
||||
|
||||
@@ -29,10 +30,12 @@ export function reportAppError(error: unknown, source: string, info?: string): v
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
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,6 +1,7 @@
|
||||
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";
|
||||
|
||||
@@ -80,7 +81,7 @@ export function ErrorOverlay() {
|
||||
<div class="error-overlay-head">
|
||||
<span class="error-overlay-icon">✦</span>
|
||||
<div>
|
||||
<div class="error-overlay-title">Something broke in the dashboard</div>
|
||||
<div class="error-overlay-title">${t("error.title")}</div>
|
||||
<div class="error-overlay-subtitle">${err.source} error · ${errMsg}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,19 +95,17 @@ export function ErrorOverlay() {
|
||||
}
|
||||
|
||||
<div class="error-overlay-help">
|
||||
The TUI is unaffected — only this browser tab tripped. You can
|
||||
dismiss and keep working, or report it so we can fix the
|
||||
underlying cause.
|
||||
${t("error.body")}
|
||||
</div>
|
||||
|
||||
<div class="error-overlay-actions">
|
||||
<button class="primary" onClick=${copyDetails}>
|
||||
${copied ? "Copied ✓" : "Copy details"}
|
||||
${copied ? t("error.copied") : t("error.copyDetails")}
|
||||
</button>
|
||||
<a class="button" href=${issueUrl} target="_blank" rel="noopener noreferrer">
|
||||
Report on GitHub
|
||||
${t("error.reportOnGithub")}
|
||||
</a>
|
||||
<button onClick=${() => setErr(null)} style="margin-left: auto;">Dismiss (Esc)</button>
|
||||
<button onClick=${() => setErr(null)} style="margin-left: auto;">${t("error.dismiss")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -145,14 +144,14 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
||||
if ((this.state.attempts ?? 0) >= 3) {
|
||||
return html`
|
||||
<div class="boot" style="flex-direction: column; gap: 12px;">
|
||||
<div>this panel keeps crashing — the error overlay has the trace.</div>
|
||||
<div>${t("error.crashing", { name: "the error overlay has the trace" })}</div>
|
||||
<button onClick=${() => this.setState({ caught: false, attempts: 0 })}>
|
||||
Try again
|
||||
${t("error.retry")}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return html`<div class="boot">recovering…</div>`;
|
||||
return html`<div class="boot">${t("error.recovering")}</div>`;
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
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,3 +1,5 @@
|
||||
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";
|
||||
@@ -59,9 +61,9 @@ export function fmtRelativeTime(iso: string | number | null | undefined): string
|
||||
const ms = typeof iso === "number" ? iso : Date.parse(iso);
|
||||
if (!Number.isFinite(ms)) return "—";
|
||||
const dSec = (Date.now() - ms) / 1000;
|
||||
if (dSec < 60) return "just now";
|
||||
if (dSec < 3600) return `${Math.floor(dSec / 60)}m ago`;
|
||||
if (dSec < 86400) return `${Math.floor(dSec / 3600)}h ago`;
|
||||
if (dSec < 30 * 86400) return `${Math.floor(dSec / 86400)}d ago`;
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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 };
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { memo } from "preact/compat";
|
||||
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
||||
import {
|
||||
ChatMessage,
|
||||
@@ -108,7 +109,6 @@ export function ChatPanel() {
|
||||
const [streaming, setStreaming] = useState<StreamingState | null>(null);
|
||||
const [activeTool, setActiveTool] = useState<ActiveToolState | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [input, setInput] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [bootError, setBootError] = useState<string | null>(null);
|
||||
const [statusLine, setStatusLine] = useState<string | null>(null);
|
||||
@@ -122,9 +122,6 @@ export function ChatPanel() {
|
||||
const [activePlan, setActivePlan] = useState<RailPlan | null>(null);
|
||||
const [semanticIndex, setSemanticIndex] = useState<boolean | null>(null);
|
||||
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
||||
const [popoverKind, setPopoverKind] = useState<PopoverKind>(null);
|
||||
const [popoverItems, setPopoverItems] = useState<PopoverItem[]>([]);
|
||||
const [popoverSel, setPopoverSel] = useState(0);
|
||||
const [semanticBannerDismissed, setSemanticBannerDismissed] = useState<boolean>(() => {
|
||||
try {
|
||||
return localStorage.getItem("rx.semanticBannerDismissed") === "1";
|
||||
@@ -185,6 +182,26 @@ export function ChatPanel() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// rAF-coalesce assistant_delta events. A streaming turn fires ~20
|
||||
// deltas/sec — committing each to React state forces a parent
|
||||
// re-render per delta, which used to thrash the chat feed. Now the
|
||||
// accumulated text lives in a ref and we flush at most once per
|
||||
// frame, capping the streaming-bubble re-render rate at the display
|
||||
// refresh rate. assistant_final cancels the pending flush.
|
||||
const streamBufRef = useRef<StreamingState | null>(null);
|
||||
const streamRafRef = useRef<number | null>(null);
|
||||
const flushStreaming = useCallback(() => {
|
||||
streamRafRef.current = null;
|
||||
if (streamBufRef.current) setStreaming(streamBufRef.current);
|
||||
}, []);
|
||||
const cancelStreamingRaf = useCallback(() => {
|
||||
if (streamRafRef.current !== null) {
|
||||
cancelAnimationFrame(streamRafRef.current);
|
||||
streamRafRef.current = null;
|
||||
}
|
||||
streamBufRef.current = null;
|
||||
}, []);
|
||||
|
||||
// SSE reconnect drops missed deltas / finals / modals — server only
|
||||
// snapshots `busy-change` on (re)connect. Pull /messages + /modal to
|
||||
// recover canonical state, otherwise UI wedges on the last seen state (#521).
|
||||
@@ -193,6 +210,7 @@ export function ChatPanel() {
|
||||
const data = await api<MessagesResponse>("/messages");
|
||||
setMessages(data.messages ?? []);
|
||||
setBusy(Boolean(data.busy));
|
||||
cancelStreamingRaf();
|
||||
setStreaming(null);
|
||||
setActiveTool(null);
|
||||
} catch {
|
||||
@@ -204,7 +222,7 @@ export function ChatPanel() {
|
||||
} catch {
|
||||
/* modal endpoint optional in standalone */
|
||||
}
|
||||
}, []);
|
||||
}, [cancelStreamingRaf]);
|
||||
|
||||
useEffect(() => {
|
||||
const es = new EventSource(`/api/events?token=${TOKEN}`);
|
||||
@@ -233,14 +251,20 @@ export function ChatPanel() {
|
||||
return;
|
||||
}
|
||||
if (dash.kind === "assistant_delta") {
|
||||
setStreaming((cur) => {
|
||||
const text = (cur?.text ?? "") + (dash.contentDelta ?? "");
|
||||
const reasoning = (cur?.reasoning ?? "") + (dash.reasoningDelta ?? "");
|
||||
return { id: dash.id, text, reasoning };
|
||||
});
|
||||
const cur = streamBufRef.current;
|
||||
const baseId = cur?.id === dash.id ? cur : null;
|
||||
streamBufRef.current = {
|
||||
id: dash.id,
|
||||
text: (baseId?.text ?? "") + (dash.contentDelta ?? ""),
|
||||
reasoning: (baseId?.reasoning ?? "") + (dash.reasoningDelta ?? ""),
|
||||
};
|
||||
if (streamRafRef.current === null) {
|
||||
streamRafRef.current = requestAnimationFrame(flushStreaming);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (dash.kind === "assistant_final") {
|
||||
cancelStreamingRaf();
|
||||
setStreaming(null);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
@@ -300,27 +324,38 @@ export function ChatPanel() {
|
||||
setError(t("chat.eventStreamError"));
|
||||
setTimeout(() => setError(null), 3000);
|
||||
};
|
||||
return () => es.close();
|
||||
}, [refetchCanonicalState]);
|
||||
return () => {
|
||||
es.close();
|
||||
cancelStreamingRaf();
|
||||
};
|
||||
}, [refetchCanonicalState, cancelStreamingRaf]);
|
||||
|
||||
const send = useCallback(async () => {
|
||||
const text = input.trim();
|
||||
if (!text || busy) return;
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api<{ accepted: boolean; reason?: string }>("/submit", {
|
||||
method: "POST",
|
||||
body: { prompt: text },
|
||||
});
|
||||
if (!res.accepted) {
|
||||
setError(res.reason ?? "rejected");
|
||||
return;
|
||||
// Stable callbacks so the memo'd <ChatInput/> doesn't re-render on every
|
||||
// unrelated parent state change. Live values (busy, messages.length) flow
|
||||
// through refs instead of dep arrays.
|
||||
const busyRef = useRef(busy);
|
||||
busyRef.current = busy;
|
||||
const messagesRef = useRef(messages);
|
||||
messagesRef.current = messages;
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (text: string): Promise<{ accepted: boolean; reason?: string }> => {
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api<{ accepted: boolean; reason?: string }>("/submit", {
|
||||
method: "POST",
|
||||
body: { prompt: text },
|
||||
});
|
||||
if (!res.accepted) setError(res.reason ?? "rejected");
|
||||
return res;
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
setError(msg);
|
||||
return { accepted: false, reason: msg };
|
||||
}
|
||||
setInput("");
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
}, [input, busy]);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const abort = useCallback(async () => {
|
||||
try {
|
||||
@@ -331,9 +366,9 @@ export function ChatPanel() {
|
||||
}, []);
|
||||
|
||||
const newConversation = useCallback(async () => {
|
||||
if (busy) {
|
||||
if (busyRef.current) {
|
||||
if (!confirm(t("chat.newConfirmBusy"))) return;
|
||||
} else if (messages.length > 0 && !confirm(t("chat.newConfirm"))) {
|
||||
} else if (messagesRef.current.length > 0 && !confirm(t("chat.newConfirm"))) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -353,7 +388,7 @@ export function ChatPanel() {
|
||||
} catch (err) {
|
||||
setError(t("chat.newFailed", { error: (err as Error).message }));
|
||||
}
|
||||
}, [busy, messages.length]);
|
||||
}, []);
|
||||
|
||||
const clearScrollback = useCallback(async () => {
|
||||
try {
|
||||
@@ -375,107 +410,6 @@ export function ChatPanel() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updatePopover = useCallback(
|
||||
async (text: string) => {
|
||||
const slashMatch = /^\/([A-Za-z0-9_-]*)$/.exec(text);
|
||||
if (slashMatch) {
|
||||
const prefix = slashMatch[1]!.toLowerCase();
|
||||
const items: PopoverItem[] = slashCommands
|
||||
.filter((c) => c.cmd.startsWith(prefix))
|
||||
.slice(0, 12)
|
||||
.map((c) => ({
|
||||
label: `/${c.cmd}`,
|
||||
meta: c.summary,
|
||||
insert: `/${c.cmd}${c.argsHint ? " " : ""}`,
|
||||
}));
|
||||
setPopoverKind("slash");
|
||||
setPopoverItems(items);
|
||||
setPopoverSel(0);
|
||||
return;
|
||||
}
|
||||
const mentionMatch = /(?:^|\s)@([^\s@]*)$/.exec(text);
|
||||
if (mentionMatch && MODE === "attached") {
|
||||
const prefix = mentionMatch[1] ?? "";
|
||||
try {
|
||||
const r = await api<{ files: string[] }>("/files", {
|
||||
method: "POST",
|
||||
body: { prefix },
|
||||
});
|
||||
const items: PopoverItem[] = r.files.slice(0, 12).map((f) => ({
|
||||
label: f,
|
||||
insert: `@${f} `,
|
||||
}));
|
||||
setPopoverKind("mention");
|
||||
setPopoverItems(items);
|
||||
setPopoverSel(0);
|
||||
} catch {
|
||||
setPopoverKind(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setPopoverKind(null);
|
||||
},
|
||||
[slashCommands],
|
||||
);
|
||||
|
||||
const applyPopover = useCallback(() => {
|
||||
const item = popoverItems[popoverSel];
|
||||
if (!item) return false;
|
||||
if (popoverKind === "slash") {
|
||||
setInput(item.insert);
|
||||
} else if (popoverKind === "mention") {
|
||||
const m = /(?:^|\s)@([^\s@]*)$/.exec(input);
|
||||
if (!m) return false;
|
||||
const start = input.length - m[0].length + (m[0].startsWith(" ") ? 1 : 0);
|
||||
setInput(`${input.slice(0, start)}${item.insert}`);
|
||||
}
|
||||
setPopoverKind(null);
|
||||
return true;
|
||||
}, [popoverItems, popoverSel, popoverKind, input]);
|
||||
|
||||
const onInput = useCallback(
|
||||
(e: Event) => {
|
||||
const v = (e.target as HTMLTextAreaElement).value;
|
||||
setInput(v);
|
||||
updatePopover(v);
|
||||
},
|
||||
[updatePopover],
|
||||
);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (popoverKind && popoverItems.length > 0) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setPopoverSel((i) => (i + 1) % popoverItems.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setPopoverSel((i) => (i - 1 + popoverItems.length) % popoverItems.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) {
|
||||
e.preventDefault();
|
||||
if (applyPopover() && e.key === "Enter" && popoverKind === "slash") {
|
||||
send();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setPopoverKind(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
},
|
||||
[send, popoverKind, popoverItems, applyPopover],
|
||||
);
|
||||
|
||||
if (bootError) {
|
||||
return html`<div class="notice err">${t("common.loadingFailed", { name: "chat", error: bootError })}</div>`;
|
||||
}
|
||||
@@ -505,18 +439,6 @@ export function ChatPanel() {
|
||||
}, 0);
|
||||
}, [messages, streaming]);
|
||||
|
||||
const allMessages = streaming
|
||||
? [
|
||||
...messages,
|
||||
{
|
||||
id: streaming.id,
|
||||
role: "assistant",
|
||||
text: streaming.text,
|
||||
reasoning: streaming.reasoning,
|
||||
},
|
||||
]
|
||||
: messages;
|
||||
|
||||
const resolveModal = useCallback<OnResolve>(async (kind, choice, text) => {
|
||||
try {
|
||||
await api("/modal/resolve", {
|
||||
@@ -726,71 +648,15 @@ export function ChatPanel() {
|
||||
|
||||
<div class="chat-body">
|
||||
<div class="chat-main">
|
||||
<div class="chat-feed" ref=${feedRef}>
|
||||
${
|
||||
allMessages.length === 0
|
||||
? html`<div class="chat-empty">
|
||||
${t("chat.noConversation")}
|
||||
</div>`
|
||||
: allMessages.map(
|
||||
(m) => html`
|
||||
<${ChatMessage}
|
||||
key=${m.id}
|
||||
msg=${m}
|
||||
streaming=${streaming && streaming.id === m.id}
|
||||
/>
|
||||
`,
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<${ChatFeed} messages=${messages} streaming=${streaming} innerRef=${feedRef} />
|
||||
|
||||
<div class="chat-input-area" style="position:relative">
|
||||
${
|
||||
popoverKind && popoverItems.length > 0
|
||||
? html`
|
||||
<div class="popover" style="position:absolute;bottom:calc(100% + 6px);left:0;width:380px;max-height:280px;overflow-y:auto;z-index:10">
|
||||
<div class="popover-h">${popoverKind === "slash" ? t("chat.slashCommands") : t("chat.projectFiles")}</div>
|
||||
${popoverItems.map(
|
||||
(it, i) => html`
|
||||
<div
|
||||
class=${`popover-row ${i === popoverSel ? "sel" : ""}`}
|
||||
onMouseDown=${(e: Event) => {
|
||||
e.preventDefault();
|
||||
setPopoverSel(i);
|
||||
applyPopover();
|
||||
}}
|
||||
>
|
||||
<span class="g">${popoverKind === "slash" ? "/" : "@"}</span>
|
||||
<span class="name">${it.label}</span>
|
||||
${it.meta ? html`<span class="meta">${it.meta}</span>` : null}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: null
|
||||
}
|
||||
<textarea
|
||||
placeholder=${busy ? t("chat.placeholderBusy") : t("chat.placeholder")}
|
||||
value=${input}
|
||||
onInput=${onInput}
|
||||
onKeyDown=${onKeyDown}
|
||||
onBlur=${() => setTimeout(() => setPopoverKind(null), 150)}
|
||||
disabled=${busy}
|
||||
rows="2"
|
||||
></textarea>
|
||||
<div style="display: flex; flex-direction: column; gap: 6px; align-self: stretch; justify-content: flex-end;">
|
||||
<button
|
||||
class="primary"
|
||||
onClick=${send}
|
||||
disabled=${busy || !input.trim()}
|
||||
>${t("chat.send")}</button>
|
||||
<div style="display: flex; gap: 6px;">
|
||||
<button onClick=${newConversation} title=${t("chat.newTitle")}>${t("chat.new")}</button>
|
||||
<button onClick=${clearScrollback} title=${t("chat.clearTitle")}>${t("chat.clear")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<${ChatInput}
|
||||
busy=${busy}
|
||||
slashCommands=${slashCommands}
|
||||
onSubmit=${onSubmit}
|
||||
onNew=${newConversation}
|
||||
onClear=${clearScrollback}
|
||||
/>
|
||||
|
||||
${
|
||||
busy
|
||||
@@ -813,13 +679,253 @@ export function ChatPanel() {
|
||||
`;
|
||||
}
|
||||
|
||||
interface ChatInputProps {
|
||||
busy: boolean;
|
||||
slashCommands: SlashCommand[];
|
||||
onSubmit: (text: string) => Promise<{ accepted: boolean; reason?: string }>;
|
||||
onNew: () => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
/** Owns its own input + popover state so keystrokes never re-render the parent (status bar, rail, mode pickers, chat feed). Memo'd against stable parent callbacks. Fixes #1031 — Chinese / Japanese IME typing felt laggy because every input event triggered a full ChatPanel re-render plus a popover-update walk over long transcripts. */
|
||||
const ChatInput = memo(function ChatInput({
|
||||
busy,
|
||||
slashCommands,
|
||||
onSubmit,
|
||||
onNew,
|
||||
onClear,
|
||||
}: ChatInputProps) {
|
||||
useLang();
|
||||
const [input, setInput] = useState("");
|
||||
const [popoverKind, setPopoverKind] = useState<PopoverKind>(null);
|
||||
const [popoverItems, setPopoverItems] = useState<PopoverItem[]>([]);
|
||||
const [popoverSel, setPopoverSel] = useState(0);
|
||||
/** Suppress popover work and Enter-submission while an IME is mid-composition — input events fire for every intermediate code point on Chinese / Japanese / Korean typing and the regex / async fetch on each one is what made the textarea lag. */
|
||||
const composing = useRef(false);
|
||||
|
||||
const updatePopover = useCallback(
|
||||
async (text: string) => {
|
||||
const slashMatch = /^\/([A-Za-z0-9_-]*)$/.exec(text);
|
||||
if (slashMatch) {
|
||||
const prefix = slashMatch[1]!.toLowerCase();
|
||||
const items: PopoverItem[] = slashCommands
|
||||
.filter((c) => c.cmd.startsWith(prefix))
|
||||
.slice(0, 12)
|
||||
.map((c) => ({
|
||||
label: `/${c.cmd}`,
|
||||
meta: c.summary,
|
||||
insert: `/${c.cmd}${c.argsHint ? " " : ""}`,
|
||||
}));
|
||||
setPopoverKind("slash");
|
||||
setPopoverItems(items);
|
||||
setPopoverSel(0);
|
||||
return;
|
||||
}
|
||||
const mentionMatch = /(?:^|\s)@([^\s@]*)$/.exec(text);
|
||||
if (mentionMatch && MODE === "attached") {
|
||||
const prefix = mentionMatch[1] ?? "";
|
||||
try {
|
||||
const r = await api<{ files: string[] }>("/files", {
|
||||
method: "POST",
|
||||
body: { prefix },
|
||||
});
|
||||
const items: PopoverItem[] = r.files.slice(0, 12).map((f) => ({
|
||||
label: f,
|
||||
insert: `@${f} `,
|
||||
}));
|
||||
setPopoverKind("mention");
|
||||
setPopoverItems(items);
|
||||
setPopoverSel(0);
|
||||
} catch {
|
||||
setPopoverKind(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setPopoverKind(null);
|
||||
},
|
||||
[slashCommands],
|
||||
);
|
||||
|
||||
const applyPopover = useCallback(() => {
|
||||
const item = popoverItems[popoverSel];
|
||||
if (!item) return false;
|
||||
if (popoverKind === "slash") {
|
||||
setInput(item.insert);
|
||||
} else if (popoverKind === "mention") {
|
||||
const m = /(?:^|\s)@([^\s@]*)$/.exec(input);
|
||||
if (!m) return false;
|
||||
const start = input.length - m[0].length + (m[0].startsWith(" ") ? 1 : 0);
|
||||
setInput(`${input.slice(0, start)}${item.insert}`);
|
||||
}
|
||||
setPopoverKind(null);
|
||||
return true;
|
||||
}, [popoverItems, popoverSel, popoverKind, input]);
|
||||
|
||||
const send = useCallback(async () => {
|
||||
const text = input.trim();
|
||||
if (!text || busy) return;
|
||||
const res = await onSubmit(text);
|
||||
if (res.accepted) setInput("");
|
||||
}, [input, busy, onSubmit]);
|
||||
|
||||
const onInput = useCallback(
|
||||
(e: Event) => {
|
||||
const v = (e.target as HTMLTextAreaElement).value;
|
||||
setInput(v);
|
||||
if (composing.current) return;
|
||||
void updatePopover(v);
|
||||
},
|
||||
[updatePopover],
|
||||
);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
// Don't intercept Enter while an IME is mid-composition — both Chrome
|
||||
// and Firefox fire keyDown for the IME's "commit" Enter and short-
|
||||
// circuiting it would swallow the chosen candidate.
|
||||
if (composing.current) return;
|
||||
if (popoverKind && popoverItems.length > 0) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setPopoverSel((i) => (i + 1) % popoverItems.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setPopoverSel((i) => (i - 1 + popoverItems.length) % popoverItems.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) {
|
||||
e.preventDefault();
|
||||
if (applyPopover() && e.key === "Enter" && popoverKind === "slash") {
|
||||
send();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setPopoverKind(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
},
|
||||
[send, popoverKind, popoverItems, applyPopover],
|
||||
);
|
||||
|
||||
const onCompositionStart = useCallback(() => {
|
||||
composing.current = true;
|
||||
}, []);
|
||||
const onCompositionEnd = useCallback(
|
||||
(e: CompositionEvent) => {
|
||||
composing.current = false;
|
||||
void updatePopover((e.target as HTMLTextAreaElement).value);
|
||||
},
|
||||
[updatePopover],
|
||||
);
|
||||
|
||||
return html`
|
||||
<div class="chat-input-area" style="position:relative">
|
||||
${
|
||||
popoverKind && popoverItems.length > 0
|
||||
? html`
|
||||
<div class="popover" style="position:absolute;bottom:calc(100% + 6px);left:0;width:380px;max-height:280px;overflow-y:auto;z-index:10">
|
||||
<div class="popover-h">${popoverKind === "slash" ? t("chat.slashCommands") : t("chat.projectFiles")}</div>
|
||||
${popoverItems.map(
|
||||
(it, i) => html`
|
||||
<div
|
||||
class=${`popover-row ${i === popoverSel ? "sel" : ""}`}
|
||||
onMouseDown=${(e: Event) => {
|
||||
e.preventDefault();
|
||||
setPopoverSel(i);
|
||||
applyPopover();
|
||||
}}
|
||||
>
|
||||
<span class="g">${popoverKind === "slash" ? "/" : "@"}</span>
|
||||
<span class="name">${it.label}</span>
|
||||
${it.meta ? html`<span class="meta">${it.meta}</span>` : null}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: null
|
||||
}
|
||||
<textarea
|
||||
placeholder=${busy ? t("chat.placeholderBusy") : t("chat.placeholder")}
|
||||
value=${input}
|
||||
onInput=${onInput}
|
||||
onKeyDown=${onKeyDown}
|
||||
onCompositionStart=${onCompositionStart}
|
||||
onCompositionEnd=${onCompositionEnd}
|
||||
onBlur=${() => setTimeout(() => setPopoverKind(null), 150)}
|
||||
disabled=${busy}
|
||||
rows="2"
|
||||
></textarea>
|
||||
<div style="display: flex; flex-direction: column; gap: 6px; align-self: stretch; justify-content: flex-end;">
|
||||
<button
|
||||
class="primary"
|
||||
onClick=${send}
|
||||
disabled=${busy || !input.trim()}
|
||||
>${t("chat.send")}</button>
|
||||
<div style="display: flex; gap: 6px;">
|
||||
<button onClick=${onNew} title=${t("chat.newTitle")}>${t("chat.new")}</button>
|
||||
<button onClick=${onClear} title=${t("chat.clearTitle")}>${t("chat.clear")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
interface ChatFeedProps {
|
||||
messages: ChatMsg[];
|
||||
streaming: StreamingState | null;
|
||||
innerRef: { current: HTMLDivElement | null };
|
||||
}
|
||||
|
||||
/** Memoised so keystrokes in ChatPanel don't re-walk the message list. */
|
||||
const ChatFeed = memo(function ChatFeed({ messages, streaming, innerRef }: ChatFeedProps) {
|
||||
useLang();
|
||||
const allMessages = streaming
|
||||
? [
|
||||
...messages,
|
||||
{
|
||||
id: streaming.id,
|
||||
role: "assistant" as const,
|
||||
text: streaming.text,
|
||||
reasoning: streaming.reasoning,
|
||||
},
|
||||
]
|
||||
: messages;
|
||||
return html`
|
||||
<div class="chat-feed" ref=${innerRef}>
|
||||
${
|
||||
allMessages.length === 0
|
||||
? html`<div class="chat-empty">${t("chat.noConversation")}</div>`
|
||||
: allMessages.map(
|
||||
(m) => html`
|
||||
<${ChatMessage}
|
||||
key=${m.id}
|
||||
msg=${m}
|
||||
streaming=${Boolean(streaming && streaming.id === m.id)}
|
||||
/>
|
||||
`,
|
||||
)
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
interface SideRailProps {
|
||||
stats: ChatStats | null;
|
||||
budgetUsd: number | null;
|
||||
activePlan: RailPlan | null;
|
||||
}
|
||||
|
||||
function SideRail({ stats, budgetUsd, activePlan }: SideRailProps) {
|
||||
const SideRail = memo(function SideRail({ stats, budgetUsd, activePlan }: SideRailProps) {
|
||||
useLang();
|
||||
if (!stats && !activePlan) return html`<aside class="chat-rail"></aside>`;
|
||||
const cachePct = stats ? stats.cacheHitRatio * 100 : 0;
|
||||
@@ -864,7 +970,7 @@ function SideRail({ stats, budgetUsd, activePlan }: SideRailProps) {
|
||||
}
|
||||
</aside>
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
function ActivePlanCard({ plan }: { plan: RailPlan }) {
|
||||
useLang();
|
||||
@@ -993,7 +1099,7 @@ interface ChatStatusBarProps {
|
||||
model: string | null;
|
||||
}
|
||||
|
||||
function ChatStatusBar({ stats, model }: ChatStatusBarProps) {
|
||||
const ChatStatusBar = memo(function ChatStatusBar({ stats, model }: ChatStatusBarProps) {
|
||||
useLang();
|
||||
if (!stats) {
|
||||
return html`
|
||||
@@ -1047,4 +1153,4 @@ function ChatStatusBar({ stats, model }: ChatStatusBarProps) {
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
@@ -3,6 +3,11 @@ 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;
|
||||
@@ -63,6 +68,10 @@ function specForEntry(e: RegistryEntryDto): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function hideBrokenIcon(ev: Event): void {
|
||||
(ev.target as HTMLImageElement).style.display = "none";
|
||||
}
|
||||
|
||||
interface RegistryListResponse {
|
||||
source: "official" | "smithery" | "local";
|
||||
fromCache: boolean;
|
||||
@@ -74,16 +83,6 @@ interface RegistryListResponse {
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
function specLabel(spec: string): string {
|
||||
const eq = spec.indexOf("=");
|
||||
return eq > 0 ? spec.slice(0, eq) : spec;
|
||||
}
|
||||
|
||||
function specCommand(spec: string): string {
|
||||
const eq = spec.indexOf("=");
|
||||
return eq > 0 ? spec.slice(eq + 1) : spec;
|
||||
}
|
||||
|
||||
type McpFilter = "all" | "live" | "unbridged" | "marketplace";
|
||||
|
||||
export function McpPanel() {
|
||||
@@ -164,8 +163,19 @@ export function McpPanel() {
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setData(await api<McpData>("/mcp"));
|
||||
setSpecs((await api<{ specs: string[] }>("/mcp/specs")).specs);
|
||||
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[] }>("/mcp/specs");
|
||||
const normalized = (Array.isArray(specResponse.specs) ? specResponse.specs : [])
|
||||
.map(normalizeMcpSpec)
|
||||
.filter((spec): spec is string => spec !== null && spec.length > 0);
|
||||
setSpecs(normalized);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
@@ -551,7 +561,7 @@ function renderMarketplaceRows({
|
||||
? 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=${(ev: Event) => ((ev.target as HTMLImageElement).style.display = "none")} />`
|
||||
? 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)}>
|
||||
@@ -594,7 +604,7 @@ function renderRegistryDetail({
|
||||
}${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=${(ev: Event) => ((ev.target as HTMLImageElement).style.display = "none")} />`
|
||||
? 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">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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,
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
formatRemaining,
|
||||
parseCustomInterval,
|
||||
} from "../lib/loop-control.js";
|
||||
import { type DashboardLang, getLang, setLang, t, useLang } from "../i18n/index.js";
|
||||
|
||||
interface SettingsData {
|
||||
apiKey?: string | null;
|
||||
@@ -29,6 +29,7 @@ interface SettingsData {
|
||||
budgetUsd?: number | null;
|
||||
/** Cumulative session spend (USD); null when no session is attached. */
|
||||
sessionSpendUsd?: number | null;
|
||||
skillPaths?: string[];
|
||||
}
|
||||
|
||||
function fmtUsd2(n: number): string {
|
||||
@@ -498,6 +499,11 @@ export function SettingsPanel() {
|
||||
[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>`;
|
||||
@@ -507,11 +513,7 @@ export function SettingsPanel() {
|
||||
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`
|
||||
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>
|
||||
@@ -523,12 +525,8 @@ export function SettingsPanel() {
|
||||
|
||||
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
|
||||
}
|
||||
${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">
|
||||
@@ -635,6 +633,35 @@ export function SettingsPanel() {
|
||||
)}
|
||||
</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(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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";
|
||||
import { t, useLang } from "../i18n/index.js";
|
||||
|
||||
interface SkillEntry {
|
||||
name: string;
|
||||
@@ -9,14 +9,22 @@ interface SkillEntry {
|
||||
runs7d?: number;
|
||||
}
|
||||
|
||||
interface SkillPathInfo {
|
||||
dir: string;
|
||||
scope: "custom";
|
||||
status: string;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
interface SkillsData {
|
||||
paths: { project?: string };
|
||||
paths: { project?: string; custom?: SkillPathInfo[] };
|
||||
project: SkillEntry[];
|
||||
custom: SkillEntry[];
|
||||
global: SkillEntry[];
|
||||
builtin: SkillEntry[];
|
||||
}
|
||||
|
||||
type Scope = "project" | "global" | "builtin";
|
||||
type Scope = "project" | "custom" | "global" | "builtin";
|
||||
|
||||
export function SkillsPanel() {
|
||||
useLang();
|
||||
@@ -44,7 +52,7 @@ export function SkillsPanel() {
|
||||
|
||||
const openSkill = useCallback(async (scope: Scope, name: string) => {
|
||||
setOpen({ scope, name });
|
||||
if (scope === "builtin") {
|
||||
if (scope === "builtin" || scope === "custom") {
|
||||
setBody("");
|
||||
return;
|
||||
}
|
||||
@@ -118,6 +126,7 @@ export function SkillsPanel() {
|
||||
|
||||
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 })),
|
||||
];
|
||||
@@ -152,6 +161,10 @@ export function SkillsPanel() {
|
||||
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")}
|
||||
@@ -192,6 +205,7 @@ export function SkillsPanel() {
|
||||
<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">
|
||||
@@ -233,7 +247,26 @@ export function SkillsPanel() {
|
||||
</div>
|
||||
`;
|
||||
})()
|
||||
: html`
|
||||
: 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>
|
||||
|
||||
@@ -21,6 +21,12 @@ interface ToolsError {
|
||||
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);
|
||||
@@ -65,7 +71,7 @@ export function ToolsPanel() {
|
||||
: html`<span class="pill acc">${t("tools.write")}</span>`}
|
||||
${tool.flattened ? html` <span class="pill">${t("tools.flat")}</span>` : null}
|
||||
</td>
|
||||
<td class="dim">${tool.description ?? ""}</td>
|
||||
<td class="dim">${toolDesc(tool.name, tool.description ?? "")}</td>
|
||||
</tr>
|
||||
`,
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
# Reasonix Desktop — code signing & notarization
|
||||
|
||||
Walkthrough for shipping a signed Reasonix Desktop bundle.
|
||||
|
||||
The release workflow at `.github/workflows/release.yml` reads everything
|
||||
below from repository **Secrets** — nothing in this repo holds keys.
|
||||
Without these secrets set the workflow still builds, but installers come
|
||||
out unsigned (Windows shows SmartScreen warnings; macOS marks the app
|
||||
"damaged" until the user right-clicks → Open).
|
||||
|
||||
## Tauri updater signing (all platforms)
|
||||
|
||||
Tauri's updater verifies bundle artifacts against a public key embedded
|
||||
in the app. Generate once and commit the **public** half to
|
||||
`tauri.conf.json`.
|
||||
|
||||
```bash
|
||||
cd desktop
|
||||
npx @tauri-apps/cli signer generate -w ~/.tauri/reasonix.key
|
||||
```
|
||||
|
||||
Outputs:
|
||||
- `~/.tauri/reasonix.key` — the **private** key. Never commit. Add a
|
||||
passphrase when prompted.
|
||||
- `~/.tauri/reasonix.key.pub` — paste into `tauri.conf.json` under
|
||||
`plugins.updater.pubkey`, replacing `REPLACE_ME_RUN_tauri_signer_generate`.
|
||||
|
||||
Set repo secrets:
|
||||
- `TAURI_SIGNING_PRIVATE_KEY` — full contents of `~/.tauri/reasonix.key`
|
||||
- `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` — the passphrase
|
||||
|
||||
The workflow exports both as env vars; `tauri-action` picks them up and
|
||||
signs the per-platform update bundle (`*.tar.gz.sig`, `*.zip.sig`,
|
||||
`*.msi.zip.sig`, …) automatically.
|
||||
|
||||
## Windows — Authenticode
|
||||
|
||||
You need a **code signing certificate** from a CA Microsoft trusts
|
||||
(DigiCert, Sectigo, SSL.com, …). EV certs avoid SmartScreen reputation
|
||||
ramp; OV certs work but warn until enough installs build trust.
|
||||
|
||||
### One-time: export the cert as PFX
|
||||
|
||||
The CA either ships a `.pfx` directly or a `.cer` + private key. If you
|
||||
get the latter, combine with `openssl`:
|
||||
|
||||
```bash
|
||||
openssl pkcs12 -export \
|
||||
-inkey reasonix.key \
|
||||
-in reasonix.cer \
|
||||
-out reasonix.pfx \
|
||||
-name "Reasonix Code Signing"
|
||||
```
|
||||
|
||||
Set a strong export password — needed below.
|
||||
|
||||
### Wire into the release workflow
|
||||
|
||||
Tauri v2 reads three env vars on Windows:
|
||||
|
||||
| Secret | What it is |
|
||||
|---|---|
|
||||
| `WINDOWS_CERTIFICATE` | base64-encoded contents of `reasonix.pfx` |
|
||||
| `WINDOWS_CERTIFICATE_PASSWORD` | the PFX export password |
|
||||
|
||||
Encode the cert before adding the secret:
|
||||
|
||||
```bash
|
||||
base64 -w0 reasonix.pfx > reasonix.pfx.b64
|
||||
```
|
||||
|
||||
Then add a step to the matrix' Windows job that imports the cert and
|
||||
points Tauri at it. The simplest approach uses the [Azure trusted
|
||||
signing action] or the older `@tauri-apps/action`'s built-in
|
||||
Authenticode path — set the secrets and `tauri-action` will pick them
|
||||
up via `tauri-plugin-windows-installer`.
|
||||
|
||||
For the workflow already in this repo, add to the env block of the
|
||||
`Build Tauri bundle` step:
|
||||
|
||||
```yaml
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
```
|
||||
|
||||
`tauri-action` v0 detects these and signs both the `.msi` and the
|
||||
`.exe` produced by NSIS.
|
||||
|
||||
### Verify locally before pushing the tag
|
||||
|
||||
```powershell
|
||||
signtool verify /pa /v Reasonix_0.40.0_x64-setup.exe
|
||||
```
|
||||
|
||||
Output should include `Successfully verified` and the certificate's
|
||||
common name.
|
||||
|
||||
[Azure trusted signing action]: https://github.com/Azure/trusted-signing-action
|
||||
|
||||
## macOS — Developer ID + notarization
|
||||
|
||||
Two separate signatures are required for a non-warning install:
|
||||
|
||||
1. **Code signing** with a Developer ID Application certificate — proves
|
||||
the bundle came from a paid Apple Developer account.
|
||||
2. **Notarization** — Apple's automated malware scan, returns a ticket
|
||||
that gets stapled into the `.dmg` / `.app`.
|
||||
|
||||
### One-time: certificate
|
||||
|
||||
Enroll in the [Apple Developer Program] ($99/yr). In Xcode → Settings →
|
||||
Accounts → Manage Certificates, create a `Developer ID Application`
|
||||
cert. Export from Keychain as a `.p12` with a passphrase.
|
||||
|
||||
```bash
|
||||
base64 -i ReasonixDeveloperID.p12 -o cert.p12.b64
|
||||
```
|
||||
|
||||
### One-time: app-specific password for notarytool
|
||||
|
||||
Notarization uses the Apple ID, not the cert. At
|
||||
<https://appleid.apple.com> → Sign-In and Security → App-Specific
|
||||
Passwords, generate one labelled "reasonix notarytool". Save it — Apple
|
||||
only shows it once.
|
||||
|
||||
### Repository secrets
|
||||
|
||||
| Secret | What it is |
|
||||
|---|---|
|
||||
| `APPLE_CERTIFICATE` | base64 of the `.p12` |
|
||||
| `APPLE_CERTIFICATE_PASSWORD` | the `.p12` passphrase |
|
||||
| `APPLE_SIGNING_IDENTITY` | the Common Name, e.g. `Developer ID Application: Your Name (TEAMID)` |
|
||||
| `APPLE_ID` | your Apple ID email |
|
||||
| `APPLE_PASSWORD` | the app-specific password from the previous step |
|
||||
| `APPLE_TEAM_ID` | 10-character team identifier, visible in Apple Developer → Membership |
|
||||
|
||||
The release workflow already passes all six to `tauri-action`. Once
|
||||
they're set, builds for both `macos-13` (Intel) and `macos-14` (Apple
|
||||
Silicon) produce a signed + notarized `.dmg`.
|
||||
|
||||
### Verify locally
|
||||
|
||||
After downloading the artifact:
|
||||
|
||||
```bash
|
||||
spctl -a -t open --context context:primary-signature -vvv Reasonix_0.40.0_aarch64.dmg
|
||||
# expected: source=Notarized Developer ID
|
||||
```
|
||||
|
||||
```bash
|
||||
codesign --verify --deep --strict --verbose=2 /Applications/Reasonix.app
|
||||
# expected: valid on disk + satisfies its Designated Requirement
|
||||
```
|
||||
|
||||
[Apple Developer Program]: https://developer.apple.com/programs/
|
||||
|
||||
## Linux
|
||||
|
||||
No signing required — `.deb` and `.AppImage` ship plain. If the project
|
||||
ever publishes a Flatpak or Snap, the respective store handles signing
|
||||
on upload.
|
||||
|
||||
## Updater pubkey rotation
|
||||
|
||||
Rotating the updater key invalidates every previously installed
|
||||
client's ability to verify updates. Avoid unless the private key
|
||||
leaked. If it must happen:
|
||||
|
||||
1. Generate a new key pair (`tauri signer generate`).
|
||||
2. Ship one transitional release signed with the **old** key whose
|
||||
notes tell users to download fresh installers manually.
|
||||
3. Replace `tauri.conf.json#plugins.updater.pubkey` in the next release.
|
||||
4. Update `TAURI_SIGNING_PRIVATE_KEY` in repo secrets.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"errSecInternalComponent" on macOS runners** — Apple's `notarytool`
|
||||
needs the keychain unlocked. `tauri-action` handles this when the six
|
||||
Apple secrets are present; if you've inlined custom steps, add a
|
||||
keychain-unlock step before signing.
|
||||
- **"The signature of the application is invalid"** on Windows after
|
||||
an update — almost always means the updater's `pubkey` in
|
||||
`tauri.conf.json` doesn't match the private key used by the workflow.
|
||||
Confirm both halves come from the same `signer generate` run.
|
||||
- **`xcrun: error: unable to find utility "altool"`** — runner is on
|
||||
Xcode older than 13. Pin `xcode-select` or upgrade the runner image;
|
||||
`notarytool` (the replacement) is what Tauri v2 uses.
|
||||
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#8b7cff"/>
|
||||
<stop offset="100%" stop-color="#5b3df0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="sheen" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.22"/>
|
||||
<stop offset="38%" stop-color="#ffffff" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<filter id="innerShadow" x="-10%" y="-10%" width="120%" height="120%">
|
||||
<feGaussianBlur stdDeviation="6" result="blur"/>
|
||||
<feOffset dy="-12" result="off"/>
|
||||
<feComposite in="off" in2="SourceAlpha" operator="arithmetic" k2="-1" k3="1" result="inner"/>
|
||||
<feFlood flood-color="#1a0e7a" flood-opacity="0.35"/>
|
||||
<feComposite in2="inner" operator="in"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect x="64" y="64" width="896" height="896" rx="220" ry="220" fill="url(#g)"/>
|
||||
<rect x="64" y="64" width="896" height="896" rx="220" ry="220" fill="url(#sheen)"/>
|
||||
<g fill="#ffffff" font-family="JetBrains Mono, ui-monospace, SFMono-Regular, Menlo, monospace" font-weight="800" text-anchor="middle">
|
||||
<text x="512" y="700" font-size="640" letter-spacing="-12">R</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Reasonix</title>
|
||||
<link rel="stylesheet" href="/src/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "reasonix-desktop",
|
||||
"private": true,
|
||||
"version": "0.43.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"bundle:node": "node scripts/bundle-node.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/geist": "^5.2.8",
|
||||
"@fontsource/geist-mono": "^5.2.7",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@tauri-apps/api": "^2.1.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-opener": "^2.0.0",
|
||||
"@tauri-apps/plugin-process": "^2.0.0",
|
||||
"@tauri-apps/plugin-updater": "^2.0.0",
|
||||
"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",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.1.0",
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Cross-platform Node 22 downloader. Detects host platform + arch and pulls
|
||||
* the matching official Node distribution from nodejs.org, then extracts the
|
||||
* node binary into desktop/src-tauri/binaries/. Tauri's per-platform conf
|
||||
* overlay (tauri.<platform>.conf.json) picks it up at bundle time.
|
||||
*
|
||||
* Usage: `npm run bundle:node` from desktop/.
|
||||
* Re-run when bumping NODE_VERSION. The download is gitignored.
|
||||
*/
|
||||
import { execSync } from "node:child_process";
|
||||
import { chmodSync, createWriteStream, existsSync, mkdirSync, renameSync, rmSync, statSync } from "node:fs";
|
||||
import https from "node:https";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const NODE_VERSION = "22.13.0";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const binDir = join(here, "..", "src-tauri", "binaries");
|
||||
|
||||
const PLAT = process.platform;
|
||||
const ARCH_RAW = process.arch;
|
||||
const ARCH = ARCH_RAW === "arm64" ? "arm64" : ARCH_RAW === "x64" ? "x64" : null;
|
||||
|
||||
if (!ARCH || !["win32", "darwin", "linux"].includes(PLAT)) {
|
||||
console.error(`Unsupported host: ${PLAT}/${ARCH_RAW}. Supported: win32 / darwin / linux × x64 / arm64.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const isWin = PLAT === "win32";
|
||||
const triple =
|
||||
PLAT === "win32" ? `win-${ARCH}` : PLAT === "darwin" ? `darwin-${ARCH}` : `linux-${ARCH}`;
|
||||
const archiveExt = PLAT === "win32" ? "zip" : PLAT === "darwin" ? "tar.gz" : "tar.xz";
|
||||
const archiveBase = `node-v${NODE_VERSION}-${triple}`;
|
||||
const archiveFile = `${archiveBase}.${archiveExt}`;
|
||||
const url = `https://nodejs.org/dist/v${NODE_VERSION}/${archiveFile}`;
|
||||
|
||||
const targetExe = join(binDir, isWin ? "node.exe" : "node");
|
||||
const archivePath = join(binDir, archiveFile);
|
||||
const extractDir = join(binDir, "_extract");
|
||||
|
||||
if (existsSync(targetExe)) {
|
||||
const size = statSync(targetExe).size;
|
||||
if (size > 1024 * 1024) {
|
||||
console.log(`${targetExe} already present (${(size / 1024 / 1024).toFixed(1)} MB) — delete to refetch`);
|
||||
process.exit(0);
|
||||
}
|
||||
// < 1 MB = placeholder, refetch.
|
||||
}
|
||||
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
|
||||
function follow(url, dest, redirects = 5) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.get(url, (res) => {
|
||||
const status = res.statusCode ?? 0;
|
||||
if ((status === 301 || status === 302 || status === 307 || status === 308) && res.headers.location && redirects > 0) {
|
||||
res.resume();
|
||||
follow(new URL(res.headers.location, url).toString(), dest, redirects - 1).then(resolve, reject);
|
||||
return;
|
||||
}
|
||||
if (status !== 200) {
|
||||
reject(new Error(`HTTP ${status} fetching ${url}`));
|
||||
return;
|
||||
}
|
||||
const file = createWriteStream(dest);
|
||||
const total = Number.parseInt(res.headers["content-length"] ?? "0", 10);
|
||||
let got = 0;
|
||||
let last = 0;
|
||||
res.on("data", (chunk) => {
|
||||
got += chunk.length;
|
||||
if (total && Date.now() - last > 250) {
|
||||
process.stdout.write(`\r ${(got / 1024 / 1024).toFixed(1)} / ${(total / 1024 / 1024).toFixed(1)} MB`);
|
||||
last = Date.now();
|
||||
}
|
||||
});
|
||||
res.pipe(file);
|
||||
file.on("finish", () => file.close((err) => (err ? reject(err) : resolve())));
|
||||
file.on("error", reject);
|
||||
});
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Downloading ${archiveFile} (Node v${NODE_VERSION} ${triple}) ...`);
|
||||
await follow(url, archivePath);
|
||||
process.stdout.write("\n");
|
||||
|
||||
rmSync(extractDir, { recursive: true, force: true });
|
||||
mkdirSync(extractDir, { recursive: true });
|
||||
|
||||
console.log("Extracting ...");
|
||||
if (isWin) {
|
||||
execSync(
|
||||
`powershell -NoProfile -Command "Expand-Archive -Force -Path '${archivePath}' -DestinationPath '${extractDir}'"`,
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
} else {
|
||||
// tar handles both .tar.gz (with -z auto) and .tar.xz (needs xz-utils on host).
|
||||
execSync(`tar -xf "${archivePath}" -C "${extractDir}"`, { stdio: "inherit" });
|
||||
}
|
||||
|
||||
const inner = isWin
|
||||
? join(extractDir, archiveBase, "node.exe")
|
||||
: join(extractDir, archiveBase, "bin", "node");
|
||||
if (!existsSync(inner)) {
|
||||
console.error(`Extracted binary not found at expected path: ${inner}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (existsSync(targetExe)) rmSync(targetExe);
|
||||
renameSync(inner, targetExe);
|
||||
if (!isWin) {
|
||||
try {
|
||||
chmodSync(targetExe, 0o755);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
rmSync(extractDir, { recursive: true, force: true });
|
||||
rmSync(archivePath);
|
||||
|
||||
const mb = (statSync(targetExe).size / 1024 / 1024).toFixed(1);
|
||||
console.log(`Done: ${targetExe} (${mb} MB)`);
|
||||
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "reasonix-desktop"
|
||||
version = "0.43.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["devtools", "macos-private-api"] }
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
parking_lot = "0.12"
|
||||
anyhow = "1"
|
||||
which = "7"
|
||||
|
||||
[features]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build();
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "default app capability",
|
||||
"windows": ["main", "Reasonix"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:event:default",
|
||||
"core:window:default",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-maximize",
|
||||
"core:window:allow-unmaximize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-start-dragging",
|
||||
"updater:default",
|
||||
"process:allow-restart",
|
||||
"dialog:allow-open",
|
||||
{
|
||||
"identifier": "opener:allow-open-url",
|
||||
"allow": [{ "url": "https://*" }, { "url": "http://*" }]
|
||||
},
|
||||
{
|
||||
"identifier": "opener:allow-open-path",
|
||||
"allow": [{ "path": "**" }]
|
||||
},
|
||||
{
|
||||
"identifier": "opener:allow-reveal-item-in-dir",
|
||||
"allow": [{ "path": "**" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 907 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 964 B |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 644 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 941 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1,33 @@
|
||||
; Override of Tauri's default English NSIS strings (#928).
|
||||
; The upstream template stores `{{product_name}}` and substitutes it at
|
||||
; installer runtime via `nsis_tauri_utils::StrReplace`. When that DLL
|
||||
; fails to load (e.g. AV quarantine), the raw placeholder leaks to the
|
||||
; user. Hardcoding the product name removes the runtime dependency.
|
||||
|
||||
LangString addOrReinstall ${LANG_ENGLISH} "Add/Reinstall components"
|
||||
LangString alreadyInstalled ${LANG_ENGLISH} "Already Installed"
|
||||
LangString alreadyInstalledLong ${LANG_ENGLISH} "${PRODUCTNAME} ${VERSION} is already installed. Select the operation you want to perform and click Next to continue."
|
||||
LangString appRunning ${LANG_ENGLISH} "Reasonix is running! Please close it first then try again."
|
||||
LangString appRunningOkKill ${LANG_ENGLISH} "Reasonix is running!$\nClick OK to kill it"
|
||||
LangString chooseMaintenanceOption ${LANG_ENGLISH} "Choose the maintenance option to perform."
|
||||
LangString choowHowToInstall ${LANG_ENGLISH} "Choose how you want to install ${PRODUCTNAME}."
|
||||
LangString createDesktop ${LANG_ENGLISH} "Create desktop shortcut"
|
||||
LangString dontUninstall ${LANG_ENGLISH} "Do not uninstall"
|
||||
LangString dontUninstallDowngrade ${LANG_ENGLISH} "Do not uninstall (Downgrading without uninstall is disabled for this installer)"
|
||||
LangString failedToKillApp ${LANG_ENGLISH} "Failed to kill Reasonix. Please close it first then try again"
|
||||
LangString installingWebview2 ${LANG_ENGLISH} "Installing WebView2..."
|
||||
LangString newerVersionInstalled ${LANG_ENGLISH} "A newer version of ${PRODUCTNAME} is already installed! It is not recommended that you install an older version. If you really want to install this older version, it's better to uninstall the current version first. Select the operation you want to perform and click Next to continue."
|
||||
LangString older ${LANG_ENGLISH} "older"
|
||||
LangString olderOrUnknownVersionInstalled ${LANG_ENGLISH} "An $R4 version of ${PRODUCTNAME} is installed on your system. It's recommended that you uninstall the current version before installing. Select the operation you want to perform and click Next to continue."
|
||||
LangString silentDowngrades ${LANG_ENGLISH} "Downgrades are disabled for this installer, can't proceed with the silent installer, please use the graphical interface installer instead.$\n"
|
||||
LangString unableToUninstall ${LANG_ENGLISH} "Unable to uninstall!"
|
||||
LangString uninstallApp ${LANG_ENGLISH} "Uninstall ${PRODUCTNAME}"
|
||||
LangString uninstallBeforeInstalling ${LANG_ENGLISH} "Uninstall before installing"
|
||||
LangString unknown ${LANG_ENGLISH} "unknown"
|
||||
LangString webview2AbortError ${LANG_ENGLISH} "Failed to install WebView2! The app can't run without it. Try restarting the installer."
|
||||
LangString webview2DownloadError ${LANG_ENGLISH} "Error: Downloading WebView2 Failed - $0"
|
||||
LangString webview2DownloadSuccess ${LANG_ENGLISH} "WebView2 bootstrapper downloaded successfully"
|
||||
LangString webview2Downloading ${LANG_ENGLISH} "Downloading WebView2 bootstrapper..."
|
||||
LangString webview2InstallError ${LANG_ENGLISH} "Error: Installing WebView2 failed with exit code $1"
|
||||
LangString webview2InstallSuccess ${LANG_ENGLISH} "WebView2 installed successfully"
|
||||
LangString deleteAppData ${LANG_ENGLISH} "Delete the application data"
|
||||
@@ -0,0 +1,31 @@
|
||||
; Simplified-Chinese NSIS strings (#928). See English.nsh for why
|
||||
; the running-app messages hardcode the product name instead of using
|
||||
; the upstream `{{product_name}}` runtime placeholder.
|
||||
|
||||
LangString addOrReinstall ${LANG_SIMPCHINESE} "添加 / 重新安装组件"
|
||||
LangString alreadyInstalled ${LANG_SIMPCHINESE} "已安装"
|
||||
LangString alreadyInstalledLong ${LANG_SIMPCHINESE} "${PRODUCTNAME} ${VERSION} 已经安装。选择您要执行的操作,然后单击下一步继续。"
|
||||
LangString appRunning ${LANG_SIMPCHINESE} "Reasonix 正在运行!请先关闭它,然后重试。"
|
||||
LangString appRunningOkKill ${LANG_SIMPCHINESE} "Reasonix 正在运行!$\n单击「确定」结束它"
|
||||
LangString chooseMaintenanceOption ${LANG_SIMPCHINESE} "选择要执行的维护操作。"
|
||||
LangString choowHowToInstall ${LANG_SIMPCHINESE} "选择如何安装 ${PRODUCTNAME}。"
|
||||
LangString createDesktop ${LANG_SIMPCHINESE} "创建桌面快捷方式"
|
||||
LangString dontUninstall ${LANG_SIMPCHINESE} "不卸载"
|
||||
LangString dontUninstallDowngrade ${LANG_SIMPCHINESE} "不卸载(此安装程序禁用了未先卸载的降级)"
|
||||
LangString failedToKillApp ${LANG_SIMPCHINESE} "无法结束 Reasonix。请先关闭它然后重试。"
|
||||
LangString installingWebview2 ${LANG_SIMPCHINESE} "正在安装 WebView2…"
|
||||
LangString newerVersionInstalled ${LANG_SIMPCHINESE} "系统中已安装了更新的 ${PRODUCTNAME}。不建议安装旧版本。如确实要安装此旧版本,请先卸载当前版本。选择要执行的操作,然后单击下一步继续。"
|
||||
LangString older ${LANG_SIMPCHINESE} "较旧的"
|
||||
LangString olderOrUnknownVersionInstalled ${LANG_SIMPCHINESE} "系统上已安装了 $R4 版本的 ${PRODUCTNAME}。建议先卸载当前版本后再安装。选择要执行的操作,然后单击下一步继续。"
|
||||
LangString silentDowngrades ${LANG_SIMPCHINESE} "此安装程序禁用了降级,无法通过静默模式继续,请改用图形界面安装程序。$\n"
|
||||
LangString unableToUninstall ${LANG_SIMPCHINESE} "无法卸载!"
|
||||
LangString uninstallApp ${LANG_SIMPCHINESE} "卸载 ${PRODUCTNAME}"
|
||||
LangString uninstallBeforeInstalling ${LANG_SIMPCHINESE} "安装前卸载"
|
||||
LangString unknown ${LANG_SIMPCHINESE} "未知"
|
||||
LangString webview2AbortError ${LANG_SIMPCHINESE} "WebView2 安装失败!没有它应用无法运行。请尝试重新运行安装程序。"
|
||||
LangString webview2DownloadError ${LANG_SIMPCHINESE} "错误:WebView2 下载失败 - $0"
|
||||
LangString webview2DownloadSuccess ${LANG_SIMPCHINESE} "WebView2 引导程序下载成功"
|
||||
LangString webview2Downloading ${LANG_SIMPCHINESE} "正在下载 WebView2 引导程序…"
|
||||
LangString webview2InstallError ${LANG_SIMPCHINESE} "错误:WebView2 安装失败,退出代码 $1"
|
||||
LangString webview2InstallSuccess ${LANG_SIMPCHINESE} "WebView2 安装成功"
|
||||
LangString deleteAppData ${LANG_SIMPCHINESE} "删除应用程序数据"
|
||||
@@ -0,0 +1,267 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod rpc;
|
||||
|
||||
use rpc::{RpcState, rpc_kill, rpc_send, rpc_spawn};
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
|
||||
/// #892: bundled libwayland in AppImage can ABI-mismatch the host Wayland
|
||||
/// compositor → WebKitWebProcess `abort()`s on EGL display creation. Redirect
|
||||
/// the child to the host's libwayland via LD_PRELOAD before WebKit forks.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_webkit_compat() {
|
||||
fn set_default(key: &str, value: &str) {
|
||||
if std::env::var_os(key).is_none() {
|
||||
std::env::set_var(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Always-on: DMABUF renderer breaks on a wider set of Mesa stacks than
|
||||
// libwayland bundling does. Cheap to disable, slow path is still fine.
|
||||
set_default("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||
|
||||
let in_appimage = std::env::var_os("APPDIR").is_some();
|
||||
let on_wayland = std::env::var_os("WAYLAND_DISPLAY").is_some();
|
||||
if !(in_appimage && on_wayland) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable accelerated compositing as well — same EGL init path.
|
||||
set_default("WEBKIT_DISABLE_COMPOSITING_MODE", "1");
|
||||
|
||||
// Skip /usr/lib/libwayland-client.so.0 — on 64-bit Fedora that path can
|
||||
// resolve to a 32-bit library and the loader prints a wrong-ELF-class
|
||||
// warning instead of preloading.
|
||||
const CANDIDATES: &[&str] = &[
|
||||
"/usr/lib64/libwayland-client.so.0",
|
||||
"/usr/lib/x86_64-linux-gnu/libwayland-client.so.0",
|
||||
"/lib/x86_64-linux-gnu/libwayland-client.so.0",
|
||||
];
|
||||
let Some(lib) = CANDIDATES.iter().find(|p| Path::new(p).exists()) else {
|
||||
return;
|
||||
};
|
||||
let existing = std::env::var("LD_PRELOAD").unwrap_or_default();
|
||||
let merged = if existing.is_empty() {
|
||||
(*lib).to_string()
|
||||
} else {
|
||||
format!("{lib}:{existing}")
|
||||
};
|
||||
std::env::set_var("LD_PRELOAD", merged);
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct FileEntry {
|
||||
path: String,
|
||||
depth: u32,
|
||||
kind: &'static str,
|
||||
name: String,
|
||||
}
|
||||
|
||||
const SKIP_DIRS: &[&str] = &["node_modules", "target", "dist", "build", "out"];
|
||||
const MAX_ENTRIES: usize = 800;
|
||||
|
||||
fn walk_dir(dir: &Path, depth: u32, max_depth: u32, out: &mut Vec<FileEntry>) {
|
||||
if depth > max_depth || out.len() >= MAX_ENTRIES {
|
||||
return;
|
||||
}
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return,
|
||||
};
|
||||
let mut items: Vec<_> = entries.flatten().collect();
|
||||
items.sort_by_key(|e| {
|
||||
let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
|
||||
(!is_dir, e.file_name())
|
||||
});
|
||||
for entry in items {
|
||||
if out.len() >= MAX_ENTRIES {
|
||||
break;
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
// Hidden files (.git, .next, .env) and well-known noise dirs.
|
||||
if name.starts_with('.') || SKIP_DIRS.contains(&name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let Ok(file_type) = entry.file_type() else { continue };
|
||||
let path = entry.path().to_string_lossy().into_owned();
|
||||
if file_type.is_dir() {
|
||||
out.push(FileEntry {
|
||||
path: path.clone(),
|
||||
depth,
|
||||
kind: "dir",
|
||||
name,
|
||||
});
|
||||
walk_dir(&entry.path(), depth + 1, max_depth, out);
|
||||
} else if file_type.is_file() {
|
||||
out.push(FileEntry {
|
||||
path,
|
||||
depth,
|
||||
kind: "file",
|
||||
name,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn list_workspace_tree(root: String, max_depth: u32) -> Result<Vec<FileEntry>, String> {
|
||||
let root_path = Path::new(&root);
|
||||
if !root_path.is_dir() {
|
||||
return Err(format!("not a directory: {root}"));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
walk_dir(root_path, 0, max_depth.min(4), &mut out);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct GitStatusEntry {
|
||||
path: String,
|
||||
kind: &'static str,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn git_status(root: String) -> Result<Vec<GitStatusEntry>, String> {
|
||||
use std::process::Command;
|
||||
let root_path = Path::new(&root);
|
||||
if !root_path.is_dir() {
|
||||
return Err(format!("not a directory: {root}"));
|
||||
}
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.arg("status").arg("--porcelain").arg("-z").current_dir(root_path);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
let output = match cmd.output() {
|
||||
Ok(o) => o,
|
||||
Err(_) => return Ok(Vec::new()), // not a git repo / no git on PATH — silent
|
||||
};
|
||||
if !output.status.success() {
|
||||
return Ok(Vec::new()); // not a git repo — silent
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for rec in output.stdout.split(|&b| b == 0) {
|
||||
if rec.len() < 4 {
|
||||
continue;
|
||||
}
|
||||
// `git status --porcelain -z` format: `XY ` + path, where X / Y are
|
||||
// index / worktree statuses. Map both to a coarse `kind`.
|
||||
let x = rec[0];
|
||||
let y = rec[1];
|
||||
let kind = match (x, y) {
|
||||
(b'?', b'?') => "untracked",
|
||||
(b'A', _) | (_, b'A') => "added",
|
||||
(b'D', _) | (_, b'D') => "deleted",
|
||||
(b'M', _) | (_, b'M') => "modified",
|
||||
(b'R', _) | (_, b'R') => "renamed",
|
||||
_ => continue,
|
||||
};
|
||||
let path = String::from_utf8_lossy(&rec[3..]).into_owned();
|
||||
out.push(GitStatusEntry { path, kind });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_in_editor(command: String, path: String, line: Option<u32>) -> Result<(), String> {
|
||||
use std::process::{Command, Stdio};
|
||||
let trimmed = command.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("editor command is empty".into());
|
||||
}
|
||||
// VS Code / Cursor / Windsurf understand `-g path:line`; harmless for others if `line` is None.
|
||||
let mut cmd;
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// Spawn through cmd.exe so `.cmd` shims (code.cmd, cursor.cmd) resolve via PATH.
|
||||
cmd = Command::new("cmd");
|
||||
cmd.arg("/c").arg(trimmed);
|
||||
if let Some(l) = line {
|
||||
cmd.arg("-g").arg(format!("{}:{}", path, l));
|
||||
} else {
|
||||
cmd.arg(&path);
|
||||
}
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
cmd = Command::new(trimmed);
|
||||
if let Some(l) = line {
|
||||
cmd.arg("-g").arg(format!("{}:{}", path, l));
|
||||
} else {
|
||||
cmd.arg(&path);
|
||||
}
|
||||
}
|
||||
cmd.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null());
|
||||
cmd.spawn().map_err(|e| format!("spawn {trimmed}: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
#[cfg(target_os = "linux")]
|
||||
linux_webkit_compat();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.manage(RpcState::default())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
rpc_spawn,
|
||||
rpc_send,
|
||||
rpc_kill,
|
||||
open_in_editor,
|
||||
list_workspace_tree,
|
||||
git_status
|
||||
])
|
||||
.setup(|app| {
|
||||
use tauri::Manager;
|
||||
if let Some(w) = app.get_webview_window("main") {
|
||||
// HiDPI fit: the JSON config asks for 1024x720 logical px.
|
||||
// On Windows laptops at 200% scale (1920x1080 → 960x540
|
||||
// effective logical px) that overflows the screen and the
|
||||
// window opens partially off-canvas. Clamp to 90% of the
|
||||
// monitor's available logical size whenever the configured
|
||||
// size doesn't fit, then recenter.
|
||||
if let Ok(Some(monitor)) = w.current_monitor() {
|
||||
let scale = monitor.scale_factor();
|
||||
let phys = monitor.size();
|
||||
let avail_w = phys.width as f64 / scale;
|
||||
let avail_h = phys.height as f64 / scale;
|
||||
let want_w = 1024_f64.min(avail_w * 0.9);
|
||||
let want_h = 720_f64.min(avail_h * 0.9);
|
||||
if want_w < 1024.0 || want_h < 720.0 {
|
||||
let _ = w.set_size(tauri::Size::Logical(tauri::LogicalSize {
|
||||
width: want_w,
|
||||
height: want_h,
|
||||
}));
|
||||
let _ = w.center();
|
||||
}
|
||||
}
|
||||
if std::env::var("REASONIX_DEVTOOLS").is_ok() {
|
||||
#[cfg(debug_assertions)]
|
||||
w.open_devtools();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.build(tauri::generate_context!())
|
||||
.expect("tauri build failed")
|
||||
.run(|app, event| {
|
||||
// Tauri 2 normally exits the process via Exit; managed-state drops
|
||||
// don't always run. ExitRequested fires before that, so we kill the
|
||||
// Node child here too — belt-and-braces vs the Drop on RpcHandle.
|
||||
if let tauri::RunEvent::ExitRequested { .. } = event {
|
||||
use tauri::Manager;
|
||||
let state = app.state::<RpcState>();
|
||||
let _ = rpc::rpc_kill(state);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
use std::env;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use parking_lot::Mutex;
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
#[cfg(not(debug_assertions))]
|
||||
use tauri::Manager;
|
||||
use which::which_all;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct RpcState {
|
||||
inner: Arc<Mutex<Option<RpcHandle>>>,
|
||||
}
|
||||
|
||||
struct RpcHandle {
|
||||
stdin: ChildStdin,
|
||||
/// Shared with the natural-exit watcher thread. `rpc_kill` takes the
|
||||
/// Child on app shutdown so it can poll try_wait synchronously and then
|
||||
/// tree-kill on timeout — Tauri's stock `child.kill()` only hits the
|
||||
/// direct Node child and leaves vite / http-server descendants orphaned
|
||||
/// past app shutdown (#907).
|
||||
child: Arc<parking_lot::Mutex<Option<Child>>>,
|
||||
child_pid: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
struct LineEvent {
|
||||
data: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
struct ExitEvent {
|
||||
code: Option<i32>,
|
||||
}
|
||||
|
||||
fn resolve_cli(app: &AppHandle) -> Result<(String, Vec<String>)> {
|
||||
if let Ok(custom) = env::var("REASONIX_CLI") {
|
||||
let mut parts = custom.split_whitespace().map(String::from);
|
||||
let program = parts
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("REASONIX_CLI is empty"))?;
|
||||
return Ok((program, parts.collect()));
|
||||
}
|
||||
|
||||
// Production path: bundled Node + bundled CLI inside resource_dir.
|
||||
// tauri.conf.json bundle.resources maps:
|
||||
// binaries/node.exe → <resource_dir>/node.exe
|
||||
// ../../dist → <resource_dir>/dist
|
||||
//
|
||||
// Dev (debug_assertions) skips this entirely — the on-disk node.exe is a
|
||||
// 0-byte placeholder kept just so tauri-build's resource validator passes;
|
||||
// spawning it is what produced error 193 ("not a valid Win32 application").
|
||||
#[cfg(not(debug_assertions))]
|
||||
if let Ok(res_dir) = app.path().resource_dir() {
|
||||
let node_name = if cfg!(windows) { "node.exe" } else { "node" };
|
||||
let node_path = res_dir.join(node_name);
|
||||
let cli_path = res_dir.join("dist").join("cli").join("index.js");
|
||||
let is_real_node = node_path
|
||||
.metadata()
|
||||
.map(|m| m.len() > 1_000_000)
|
||||
.unwrap_or(false);
|
||||
if is_real_node && cli_path.exists() {
|
||||
return Ok((
|
||||
node_path.to_string_lossy().into_owned(),
|
||||
vec![cli_path.to_string_lossy().into_owned(), "desktop".to_string()],
|
||||
));
|
||||
}
|
||||
}
|
||||
let _ = app;
|
||||
|
||||
// Dev path: system Node + repo dist (cargo run / cargo tauri dev).
|
||||
let cwd = env::current_dir().context("cwd")?;
|
||||
let candidates = [
|
||||
cwd.join("../../dist/cli/index.js"),
|
||||
cwd.join("../dist/cli/index.js"),
|
||||
cwd.join("dist/cli/index.js"),
|
||||
];
|
||||
let entry = candidates
|
||||
.into_iter()
|
||||
.find(|p| p.exists())
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| anyhow!("dist/cli/index.js not found — run `npm run build` at repo root"))?;
|
||||
|
||||
let node_path = find_real_node().context("node not found")?;
|
||||
eprintln!("[reasonix] resolved node: {}", node_path.display());
|
||||
|
||||
Ok((
|
||||
node_path.to_string_lossy().into_owned(),
|
||||
vec![entry.to_string_lossy().to_string(), "desktop".to_string()],
|
||||
))
|
||||
}
|
||||
|
||||
/// Walk every PATH match for `node` and return the first one that is
|
||||
/// (a) a real file > 100 KB and (b) NOT inside Windows' App Execution
|
||||
/// Alias directory — those Microsoft Store stubs are 0-byte and triggered
|
||||
/// the original "%1 is not a valid Win32 application" (error 193).
|
||||
fn find_real_node() -> Result<PathBuf> {
|
||||
let names: &[&str] = if cfg!(windows) {
|
||||
&["node.exe", "node"]
|
||||
} else {
|
||||
&["node"]
|
||||
};
|
||||
let mut tried: Vec<String> = Vec::new();
|
||||
for name in names {
|
||||
if let Ok(iter) = which_all(*name) {
|
||||
for p in iter {
|
||||
let size = std::fs::metadata(&p).map(|m| m.len()).unwrap_or(0);
|
||||
let lower = p.to_string_lossy().to_lowercase();
|
||||
let is_ms_store_shim = lower.contains("windowsapps");
|
||||
let too_small = size < 100_000;
|
||||
if !too_small && !is_ms_store_shim {
|
||||
return Ok(p);
|
||||
}
|
||||
tried.push(format!(
|
||||
"{} ({} bytes{})",
|
||||
p.display(),
|
||||
size,
|
||||
if is_ms_store_shim { ", MS Store shim" } else { "" },
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(anyhow!(
|
||||
"node not found in PATH or only stub binaries present.{}\nInstall Node 22 from nodejs.org and reopen Reasonix.",
|
||||
if tried.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" Skipped: {}.", tried.join("; "))
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn rpc_spawn(app: AppHandle, state: State<'_, RpcState>) -> Result<(), String> {
|
||||
let mut guard = state.inner.lock();
|
||||
if guard.is_some() {
|
||||
return Err("rpc already spawned".into());
|
||||
}
|
||||
|
||||
let (program, args) = resolve_cli(&app).map_err(|e| e.to_string())?;
|
||||
let mut cmd = Command::new(program);
|
||||
cmd.args(args);
|
||||
cmd.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
|
||||
if let Ok(cwd) = env::current_dir() {
|
||||
let repo_root = cwd
|
||||
.ancestors()
|
||||
.find(|p| p.join("package.json").exists() && p.join("src/cli").exists())
|
||||
.unwrap_or(&cwd)
|
||||
.to_path_buf();
|
||||
cmd.current_dir(repo_root);
|
||||
}
|
||||
|
||||
let mut child = cmd.spawn().map_err(|e| format!("spawn: {e}"))?;
|
||||
let stdin = child.stdin.take().ok_or("no stdin")?;
|
||||
let stdout = child.stdout.take().ok_or("no stdout")?;
|
||||
let stderr = child.stderr.take().ok_or("no stderr")?;
|
||||
let child_pid = child.id();
|
||||
|
||||
let child_shared = Arc::new(parking_lot::Mutex::new(Some(child)));
|
||||
*guard = Some(RpcHandle {
|
||||
stdin,
|
||||
child: child_shared.clone(),
|
||||
child_pid,
|
||||
});
|
||||
drop(guard);
|
||||
|
||||
let app_for_stdout = app.clone();
|
||||
thread::spawn(move || {
|
||||
let reader = BufReader::new(stdout);
|
||||
for line in reader.lines().map_while(Result::ok) {
|
||||
let _ = app_for_stdout.emit("rpc:event", LineEvent { data: line });
|
||||
}
|
||||
});
|
||||
|
||||
let app_for_stderr = app.clone();
|
||||
thread::spawn(move || {
|
||||
let reader = BufReader::new(stderr);
|
||||
for line in reader.lines().map_while(Result::ok) {
|
||||
let _ = app_for_stderr.emit("rpc:stderr", LineEvent { data: line });
|
||||
}
|
||||
});
|
||||
|
||||
// Natural-exit watcher: emits rpc:exit if Node dies on its own (crash,
|
||||
// explicit process.exit). rpc_kill owns the active-shutdown path; this
|
||||
// thread bails the moment that path takes the Child.
|
||||
let app_for_exit = app.clone();
|
||||
let inner_for_exit = state.inner.clone();
|
||||
let child_for_wait = child_shared;
|
||||
thread::spawn(move || loop {
|
||||
let status = {
|
||||
let mut guard = child_for_wait.lock();
|
||||
match guard.as_mut() {
|
||||
Some(c) => c.try_wait(),
|
||||
None => return,
|
||||
}
|
||||
};
|
||||
match status {
|
||||
Ok(Some(s)) => {
|
||||
child_for_wait.lock().take();
|
||||
let _ = inner_for_exit.lock().take();
|
||||
let _ = app_for_exit.emit("rpc:exit", ExitEvent { code: s.code() });
|
||||
return;
|
||||
}
|
||||
Ok(None) => thread::sleep(Duration::from_millis(500)),
|
||||
Err(_) => {
|
||||
let _ = inner_for_exit.lock().take();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn kill_process_tree(pid: u32) {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
let _ = Command::new("taskkill")
|
||||
.args(["/F", "/T", "/PID", &pid.to_string()])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let _ = Command::new("kill")
|
||||
.args(["-KILL", &pid.to_string()])
|
||||
.status();
|
||||
let _ = Command::new("pkill")
|
||||
.args(["-KILL", "-P", &pid.to_string()])
|
||||
.status();
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the spawned Node child cleanly. Used by Tauri-side teardown.
|
||||
/// Closes Node's stdin (which trips its readline `close` → graceful job
|
||||
/// shutdown), polls the child for up to 3 s, then tree-kills any survivors
|
||||
/// so dev-server grandchildren don't outlive the desktop (#907).
|
||||
#[tauri::command]
|
||||
pub fn rpc_kill(state: State<'_, RpcState>) -> Result<(), String> {
|
||||
let handle_opt = state.inner.lock().take();
|
||||
let Some(handle) = handle_opt else { return Ok(()) };
|
||||
|
||||
drop(handle.stdin);
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(3);
|
||||
let exited = loop {
|
||||
let done = {
|
||||
let mut guard = handle.child.lock();
|
||||
match guard.as_mut() {
|
||||
Some(c) => match c.try_wait() {
|
||||
Ok(Some(_)) => {
|
||||
guard.take();
|
||||
true
|
||||
}
|
||||
Ok(None) => false,
|
||||
Err(_) => {
|
||||
guard.take();
|
||||
true
|
||||
}
|
||||
},
|
||||
None => true,
|
||||
}
|
||||
};
|
||||
if done {
|
||||
break true;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
break false;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
};
|
||||
|
||||
if !exited {
|
||||
kill_process_tree(handle.child_pid);
|
||||
if let Some(mut c) = handle.child.lock().take() {
|
||||
let _ = c.wait();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn rpc_send(state: State<'_, RpcState>, line: String) -> Result<(), String> {
|
||||
let mut guard = state.inner.lock();
|
||||
let handle = guard.as_mut().ok_or("rpc not spawned")?;
|
||||
writeln!(handle.stdin, "{line}").map_err(|e| format!("write: {e}"))?;
|
||||
handle.stdin.flush().map_err(|e| format!("flush: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||