Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
614236eb03 | ||
|
|
b1efcfe091 | ||
|
|
c2966f5c0e | ||
|
|
4dbb03ae36 | ||
|
|
05491f16c6 | ||
|
|
e2b3de7246 | ||
|
|
d9cfeffd50 | ||
|
|
67a1d6194b | ||
|
|
0a1c65d9db | ||
|
|
ef2b50d873 | ||
|
|
6cde945a01 | ||
|
|
faf59cee4a | ||
|
|
1e7be37a55 | ||
|
|
7f54ae0678 | ||
|
|
bdf8faeb4e | ||
|
|
a9c407853a | ||
|
|
959e9ffefd | ||
|
|
012757567e | ||
|
|
453a3bc9d3 | ||
|
|
dbafd1632c | ||
|
|
1aca86b424 | ||
|
|
5ce642aeeb | ||
|
|
c888ab33d7 | ||
|
|
88514595ac | ||
|
|
29020327fe | ||
|
|
7642ad6ba2 | ||
|
|
e205c59775 | ||
|
|
874f0d1cc1 | ||
|
|
2462b1c278 | ||
|
|
d421970a27 | ||
|
|
d1f491b29f | ||
|
|
3f6fb51b08 | ||
|
|
c63239ddb8 | ||
|
|
ec5bfabde2 | ||
|
|
a525182aff | ||
|
|
f795583cea | ||
|
|
bb971bd403 | ||
|
|
06204c760e | ||
|
|
c504707af8 | ||
|
|
f0a552bed5 | ||
|
|
bb5b124817 | ||
|
|
636ad7e551 | ||
|
|
1c360ac81b | ||
|
|
c120ed3a39 | ||
|
|
42f75c7478 | ||
|
|
34b8275f20 |
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,7 @@ 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"
|
||||
@@ -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,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,153 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Tag to publish (e.g. v0.40.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
|
||||
@@ -9,20 +9,28 @@
|
||||
·
|
||||
<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>
|
||||
</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>
|
||||
</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,88 +54,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).
|
||||
|
||||
**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/>
|
||||
|
||||
@@ -163,7 +210,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.
|
||||
@@ -173,6 +220,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
|
||||
@@ -205,7 +254,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>
|
||||
|
||||
@@ -225,11 +274,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>
|
||||
|
||||
@@ -241,4 +290,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,28 @@
|
||||
·
|
||||
<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>
|
||||
</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>
|
||||
</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,43 +53,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)。
|
||||
|
||||
**在其他目录工作:** Reasonix 把文件系统工具作用域绑定在启动目录。要在别的目录工作,传 `--dir`:
|
||||
### QQ 通道
|
||||
|
||||
```bash
|
||||
npx reasonix code --dir /path/to/project # 也可以用相对路径
|
||||
```
|
||||
Reasonix 可以把 QQ 挂到现有的 `chat` / `code` 会话上,作为远程通信通道使用。它不是一个独立的新模式。
|
||||
|
||||
中途切换工作区是有意不支持的(消息日志和 memory 路径会和旧的根目录混在一起,状态错乱)。退出后用新的 `--dir` 重新启动来切换。`/status` 始终显示当前锁定的工作区。
|
||||
先启动一个会话:
|
||||
|
||||
**写第一个 Skill:** Skills 是模型可以调用的 markdown 剧本(`/skill <name>`)。暂无在线市场 —— 自己写:
|
||||
~~~bash
|
||||
reasonix code
|
||||
# 或
|
||||
reasonix chat
|
||||
~~~
|
||||
|
||||
```bash
|
||||
/skill new my-skill # 在 <project>/.reasonix/skills/my-skill.md 生成模板
|
||||
/skill new my-skill --global # 或者放到 ~/.reasonix/skills,跨项目共用
|
||||
```
|
||||
然后在会话里连接 QQ:
|
||||
|
||||
编辑文件(`description:` frontmatter + 正文),然后 `/skill list` 就能看到。frontmatter 里加 `runAs: subagent` 会以独立 subagent 跑,而不是把正文内联进父 prompt。
|
||||
~~~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` |
|
||||
|---|---|---|
|
||||
| 文件系统工具 + `edit_file` | ✓ | — |
|
||||
| SEARCH/REPLACE → `/apply` 审阅 | ✓ | — |
|
||||
| Shell 工具(带 gate) | ✓ | — |
|
||||
| Plan 模式 · `/todo` · `/skill new` · `/mcp add` | ✓ | — |
|
||||
| Memory(`remember` / `recall_memory`) | 项目 + 全局 | 仅全局 |
|
||||
| 配置里的 MCP · web 搜索 · `ask_choice` | ✓ | ✓ |
|
||||
| 编码导向系统提示词 | ✓ | 通用 |
|
||||
| Session 作用域 | 按目录 | 共享默认 |
|
||||
|
||||
**写第一个 Skill。** 暂无在线市场——自己写。编辑文件(`description:` frontmatter + 正文),然后 `/skill list` 就能看到。frontmatter 加 `runAs: subagent` 会以隔离 subagent 跑,而不是把正文内联进父 prompt。
|
||||
|
||||
~~~bash
|
||||
/skill new my-skill # <project>/.reasonix/skills/my-skill.md
|
||||
/skill new my-skill --global # ~/.reasonix/skills,跨项目共用
|
||||
~~~
|
||||
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
|
||||
## 配置
|
||||
|
||||
一个全局 JSON 文件 `~/.reasonix/config.json`,加上项目级 `<project>/.reasonix/` 下的覆盖。完整的双语参考 —— 每一个 key、每一条斜杠命令、skills / memory / hooks 在磁盘上的形状 —— 都在这里:
|
||||
|
||||
> 📘 **[配置指南](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/>
|
||||
|
||||
@@ -125,6 +190,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) —— 注释规则、错误处理、用现成库不手写
|
||||
@@ -157,7 +224,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>
|
||||
|
||||
@@ -177,11 +244,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; }
|
||||
@@ -85,7 +92,7 @@ a:hover { text-decoration: underline; }
|
||||
/* ── Doc chrome ─────────────────────────────────────────────────────────── */
|
||||
.page {
|
||||
display: grid;
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
grid-template-columns: 260px minmax(0, 1fr);
|
||||
max-width: 1320px;
|
||||
margin: 0 auto;
|
||||
min-height: 100vh;
|
||||
@@ -94,18 +101,19 @@ a:hover { text-decoration: underline; }
|
||||
position: sticky; top: 0; align-self: start;
|
||||
height: 100vh; overflow-y: auto;
|
||||
border-right: 1px solid var(--bd);
|
||||
padding: 28px 18px;
|
||||
padding: 28px 16px;
|
||||
background: var(--bg);
|
||||
}
|
||||
.toc h1 { font-size: 14px; font-weight: 700; margin: 0 0 4px; color: var(--fg-0); letter-spacing: .03em; font-family: var(--font-mono); }
|
||||
.toc h1 { font-size: 15px; font-weight: 700; margin: 0 0 4px; color: var(--fg-0); letter-spacing: .03em; font-family: var(--font-mono); }
|
||||
.toc h1 .dot { color: var(--c-brand); margin-right: 8px; }
|
||||
.toc .sub { font-size: 11px; color: var(--fg-3); margin: 0 0 18px; letter-spacing: .04em; }
|
||||
.toc-section { font-size: 10px; text-transform: uppercase; letter-spacing: .14em; color: var(--fg-4); margin: 22px 0 6px; font-weight: 700; }
|
||||
.toc .sub { font-size: 12px; color: var(--fg-3); margin: 0 0 18px; letter-spacing: .04em; }
|
||||
.toc-section { font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: var(--fg-4); margin: 22px 0 6px; font-weight: 700; }
|
||||
.toc-section:first-of-type { margin-top: 0; }
|
||||
.toc ul { list-style: none; padding: 0; margin: 0; }
|
||||
.toc li a {
|
||||
display: block; padding: 3px 8px; margin: 1px 0;
|
||||
color: var(--fg-2); font-size: 12.5px; border-radius: var(--r);
|
||||
display: block; padding: 4px 10px; margin: 1px 0;
|
||||
color: var(--fg-2); font-size: 14px; line-height: 1.4;
|
||||
border-radius: var(--r); overflow-wrap: anywhere;
|
||||
}
|
||||
.toc li a:hover { color: var(--fg-0); background: var(--bg-elev); text-decoration: none; }
|
||||
|
||||
@@ -118,7 +126,7 @@ main { padding: 32px 40px 60px 32px; min-width: 0; }
|
||||
}
|
||||
.section > h2 .num { color: var(--fg-4); margin-right: 10px; font-weight: 500; }
|
||||
.section > .lede {
|
||||
color: var(--fg-2); margin: 0 0 22px; font-size: 13px; max-width: 700px; line-height: 1.6;
|
||||
color: var(--fg-2); margin: 0 0 22px; font-size: 15px; max-width: 720px; line-height: 1.6;
|
||||
}
|
||||
.subsec { margin-bottom: 22px; }
|
||||
.subsec > h3 {
|
||||
@@ -126,8 +134,8 @@ main { padding: 32px 40px 60px 32px; min-width: 0; }
|
||||
margin: 24px 0 4px; letter-spacing: .04em; text-transform: uppercase;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.subsec > h3 .desc { color: var(--fg-3); font-weight: 400; margin-left: 10px; font-size: 12px; text-transform: none; letter-spacing: 0; }
|
||||
.subsec > p { color: var(--fg-3); font-size: 12.5px; margin: 0 0 12px; max-width: 720px; line-height: 1.6; }
|
||||
.subsec > h3 .desc { color: var(--fg-3); font-weight: 400; margin-left: 10px; font-size: 13px; text-transform: none; letter-spacing: 0; }
|
||||
.subsec > p { color: var(--fg-3); font-size: 15px; margin: 0 0 12px; max-width: 720px; line-height: 1.6; }
|
||||
|
||||
/* "Mock" — a faux-window frame to display dashboard pieces inside the design doc */
|
||||
.mock {
|
||||
@@ -155,11 +163,11 @@ main { padding: 32px 40px 60px 32px; min-width: 0; }
|
||||
.swatch .chip { width: 22px; height: 22px; border-radius: var(--r); flex-shrink: 0; border: 1px solid rgba(255,255,255,.04); }
|
||||
.swatch .meta { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
||||
.swatch .name { color: var(--fg-1); font-size: 11.5px; }
|
||||
.swatch .hex { color: var(--fg-3); font-size: 10.5px; }
|
||||
.swatch .hex { color: var(--fg-3); font-size: 11.5px; }
|
||||
|
||||
.scale-row { display: flex; align-items: baseline; gap: 16px; padding: 6px 0; border-bottom: 1px dashed #181b22; }
|
||||
.scale-row:last-child { border-bottom: none; }
|
||||
.scale-row .lbl { font-family: var(--font-mono); font-size: 10.5px; color: var(--fg-3); width: 70px; flex-shrink: 0; }
|
||||
.scale-row .lbl { font-family: var(--font-mono); font-size: 11.5px; color: var(--fg-3); width: 76px; flex-shrink: 0; }
|
||||
.scale-row .ex { color: var(--fg-1); }
|
||||
|
||||
.glyph-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 6px; }
|
||||
@@ -311,12 +319,13 @@ main { padding: 32px 40px 60px 32px; min-width: 0; }
|
||||
.pill.acc { color: var(--c-accent); background: rgba(210,168,255,.10); }
|
||||
|
||||
/* Table */
|
||||
.tbl { width: 100%; border-collapse: collapse; font-size: 12.5px; }
|
||||
.tbl { width: 100%; border-collapse: collapse; font-size: 12.5px; table-layout: auto; }
|
||||
.tbl th, .tbl td { padding: 8px 10px; text-align: left; border-bottom: 1px solid var(--bd); }
|
||||
.tbl th { font-family: var(--font-mono); font-size: 10.5px; font-weight: 600; color: var(--fg-3); text-transform: uppercase; letter-spacing: .08em; background: var(--bg-elev); }
|
||||
.tbl th.num, .tbl td.num { font-family: var(--font-mono); text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.tbl td { color: var(--fg-1); }
|
||||
.tbl tbody tr:hover { background: var(--bg-hover); }
|
||||
.tbl td.num { font-family: var(--font-mono); text-align: right; color: var(--fg-0); font-variant-numeric: tabular-nums; }
|
||||
.tbl td.num { color: var(--fg-0); }
|
||||
.tbl td.dim { color: var(--fg-3); }
|
||||
.tbl td.path { font-family: var(--font-mono); font-size: 11.5px; color: var(--fg-2); }
|
||||
|
||||
@@ -778,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); }
|
||||
@@ -1155,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;
|
||||
}
|
||||
|
||||
@@ -2062,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));
|
||||
}
|
||||
|
||||
@@ -2077,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 {
|
||||
@@ -2425,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",
|
||||
@@ -214,12 +275,19 @@ export const en = {
|
||||
dailyMeta: "cost · cache saved · turns",
|
||||
noData: "No usage data yet — run a turn in reasonix chat / code / run and refresh.",
|
||||
windows: "Rolling windows",
|
||||
colWindow: "window",
|
||||
colTurns: "turns",
|
||||
colCacheHit: "cache hit",
|
||||
colCost: "cost (USD)",
|
||||
colCacheSaved: "cache saved",
|
||||
colVsClaude: "vs Claude",
|
||||
colSaved: "saved",
|
||||
axisTime: "time",
|
||||
axisUsd: "USD",
|
||||
axisTurns: "turns",
|
||||
seriesCost: "cost",
|
||||
seriesCacheSaved: "cache saved",
|
||||
seriesTurns: "turns",
|
||||
mostUsed: "Most used models",
|
||||
colModel: "model",
|
||||
},
|
||||
@@ -248,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…",
|
||||
@@ -367,6 +503,7 @@ export const en = {
|
||||
loading: "loading skills…",
|
||||
filterPlaceholder: "filter skills",
|
||||
project: "project",
|
||||
custom: "custom",
|
||||
global: "global",
|
||||
builtin: "builtin",
|
||||
newSkill: "new skill",
|
||||
@@ -374,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}",
|
||||
@@ -458,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",
|
||||
@@ -587,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",
|
||||
@@ -211,12 +272,19 @@ export const zhCN = {
|
||||
dailyMeta: "费用 · 缓存节省 · 轮次",
|
||||
noData: "暂无用量数据 — 在 reasonix chat / code / run 中执行一轮,然后刷新。",
|
||||
windows: "滚动窗口",
|
||||
colWindow: "时间范围",
|
||||
colTurns: "轮次",
|
||||
colCacheHit: "缓存命中",
|
||||
colCost: "费用 (USD)",
|
||||
colCacheSaved: "缓存节省",
|
||||
colVsClaude: "对比 Claude",
|
||||
colSaved: "节省",
|
||||
axisTime: "时间",
|
||||
axisUsd: "美元",
|
||||
axisTurns: "轮次",
|
||||
seriesCost: "费用",
|
||||
seriesCacheSaved: "缓存节省",
|
||||
seriesTurns: "轮次",
|
||||
mostUsed: "最常用模型",
|
||||
colModel: "模型",
|
||||
},
|
||||
@@ -244,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: "加载权限…",
|
||||
@@ -361,6 +478,7 @@ export const zhCN = {
|
||||
loading: "加载技能…",
|
||||
filterPlaceholder: "筛选技能",
|
||||
project: "项目",
|
||||
custom: "自定义",
|
||||
global: "全局",
|
||||
builtin: "内置",
|
||||
newSkill: "新技能",
|
||||
@@ -368,6 +486,7 @@ export const zhCN = {
|
||||
runs7d: "次运行 · 7 天",
|
||||
pickHint: "选择左侧的技能,或在上方创建新技能。",
|
||||
readOnlyBuiltin: "只读 · 内置",
|
||||
readOnlyCustom: "只读 · 自定义路径",
|
||||
builtinDesc:
|
||||
"内置技能随 Reasonix 一起发布;模型会自动识别。如需自定义,请创建同名的项目或全局技能。",
|
||||
saved: "已保存 {scope}/{name}",
|
||||
@@ -450,7 +569,8 @@ export const zhCN = {
|
||||
lastBuild: "上次构建",
|
||||
builtWith: "构建来源",
|
||||
currentTarget: "当前目标",
|
||||
incompatibleHint: "磁盘上的这个索引是为不同的 provider 或 model 构建的。运行“完全重建”即可替换。",
|
||||
incompatibleHint:
|
||||
"磁盘上的这个索引是为不同的 provider 或 model 构建的。运行“完全重建”即可替换。",
|
||||
runIndexHint: "运行索引以启用 semantic_search。",
|
||||
reIndex: "重建索引",
|
||||
build: "构建",
|
||||
@@ -461,6 +581,7 @@ export const zhCN = {
|
||||
openaiCompat: "OpenAI-Compatible",
|
||||
apiUrl: "API URL",
|
||||
apiKey: "API Key",
|
||||
apiKeyStoredNote: "API Key 仅保存在本地,不会发送到第三方。",
|
||||
customRequestBody: "自定义请求体",
|
||||
invalidCustomRequestBody: "自定义请求体必须是合法 JSON:{error}",
|
||||
customRequestBodyMustBeObject: "自定义请求体必须是 JSON 对象。",
|
||||
@@ -577,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);
|
||||
}
|
||||
@@ -16,7 +16,6 @@ const TO_BACKEND = new Map(LANG_REGISTRY);
|
||||
const FROM_BACKEND = new Map(LANG_REGISTRY.map(([d, b]) => [b, d]));
|
||||
|
||||
const STORAGE_KEY = "rx.lang";
|
||||
const EXPLICIT_KEY = "rx.langExplicit";
|
||||
const listeners: Listener[] = [];
|
||||
let currentLang: DashboardLang = loadFromStorage() ?? "en";
|
||||
|
||||
@@ -30,22 +29,6 @@ function loadFromStorage(): DashboardLang | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function isExplicit(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(EXPLICIT_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function markExplicit(): void {
|
||||
try {
|
||||
localStorage.setItem(EXPLICIT_KEY, "1");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function toBackendLang(lang: DashboardLang): string {
|
||||
return TO_BACKEND.get(lang) ?? lang;
|
||||
}
|
||||
@@ -54,23 +37,12 @@ function fromBackendLang(raw: string): DashboardLang {
|
||||
return (FROM_BACKEND.get(raw) as DashboardLang) ?? "en";
|
||||
}
|
||||
|
||||
/** Fetch lang from backend on startup. */
|
||||
/** Adopt server lang on startup; localStorage is render-cache only, never pushed back. */
|
||||
export async function initLangFromServer(): Promise<void> {
|
||||
try {
|
||||
const stored = loadFromStorage();
|
||||
const res = await api<{ lang?: string }>("/settings");
|
||||
const serverLang = res.lang ? fromBackendLang(res.lang) : null;
|
||||
|
||||
if (!serverLang || stored === serverLang) return;
|
||||
|
||||
if (isExplicit() && stored) {
|
||||
// User explicitly chose a language — push to server.
|
||||
api("/settings", { method: "POST", body: { lang: toBackendLang(stored) } })
|
||||
.catch((err) => console.error("[reasonix dashboard] lang sync:", err));
|
||||
return;
|
||||
}
|
||||
|
||||
// No explicit choice yet (first visit or cleared storage) — adopt server value.
|
||||
if (!serverLang || serverLang === currentLang) return;
|
||||
currentLang = serverLang;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, serverLang);
|
||||
@@ -79,7 +51,7 @@ export async function initLangFromServer(): Promise<void> {
|
||||
}
|
||||
for (const cb of listeners) cb();
|
||||
} catch {
|
||||
/* offline — keep localStorage value */
|
||||
/* offline — keep last-known value rendering */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +62,6 @@ export function getLang(): DashboardLang {
|
||||
export function setLang(lang: DashboardLang): void {
|
||||
if (!SUPPORTED.has(lang)) return;
|
||||
currentLang = lang;
|
||||
markExplicit();
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, lang);
|
||||
} catch {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Pre-release with same core sorts BELOW the bare version — matches npm `latest` dist-tag semantics. */
|
||||
export function compareVersions(a: string, b: string): number {
|
||||
const [aCore = "0", aPre = ""] = a.split("-", 2);
|
||||
const [bCore = "0", bPre = ""] = b.split("-", 2);
|
||||
const aParts = aCore.split(".").map((p) => Number.parseInt(p, 10) || 0);
|
||||
const bParts = bCore.split(".").map((p) => Number.parseInt(p, 10) || 0);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const diff = (aParts[i] ?? 0) - (bParts[i] ?? 0);
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
if (!aPre && !bPre) return 0;
|
||||
if (!aPre) return 1;
|
||||
if (!bPre) return -1;
|
||||
return aPre < bPre ? -1 : aPre > bPre ? 1 : 0;
|
||||
}
|
||||
@@ -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,8 +182,58 @@ 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).
|
||||
const refetchCanonicalState = useCallback(async () => {
|
||||
try {
|
||||
const data = await api<MessagesResponse>("/messages");
|
||||
setMessages(data.messages ?? []);
|
||||
setBusy(Boolean(data.busy));
|
||||
cancelStreamingRaf();
|
||||
setStreaming(null);
|
||||
setActiveTool(null);
|
||||
} catch {
|
||||
/* keep current state — next event or next reconnect will retry */
|
||||
}
|
||||
try {
|
||||
const m = await api<ModalEnvelope>("/modal");
|
||||
setModal(m.modal ?? null);
|
||||
} catch {
|
||||
/* modal endpoint optional in standalone */
|
||||
}
|
||||
}, [cancelStreamingRaf]);
|
||||
|
||||
useEffect(() => {
|
||||
const es = new EventSource(`/api/events?token=${TOKEN}`);
|
||||
let firstOpen = true;
|
||||
es.onopen = () => {
|
||||
if (firstOpen) {
|
||||
firstOpen = false;
|
||||
return;
|
||||
}
|
||||
void refetchCanonicalState();
|
||||
};
|
||||
es.onmessage = (ev) => {
|
||||
let dash;
|
||||
try {
|
||||
@@ -204,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,
|
||||
@@ -266,31 +319,43 @@ export function ChatPanel() {
|
||||
};
|
||||
es.onerror = () => {
|
||||
// Auto-reconnect by default; surface a brief banner on persistent
|
||||
// failure but don't tear down — EventSource retries in the background.
|
||||
// failure but don't tear down — EventSource retries in the
|
||||
// background. The next `onopen` will resync canonical state.
|
||||
setError(t("chat.eventStreamError"));
|
||||
setTimeout(() => setError(null), 3000);
|
||||
};
|
||||
return () => es.close();
|
||||
}, []);
|
||||
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 {
|
||||
@@ -301,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 {
|
||||
@@ -323,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 {
|
||||
@@ -345,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>`;
|
||||
}
|
||||
@@ -475,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", {
|
||||
@@ -696,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
|
||||
@@ -783,16 +679,256 @@ 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 ? Math.round(stats.cacheHitRatio * 100) : 0;
|
||||
const cachePct = stats ? stats.cacheHitRatio * 100 : 0;
|
||||
const cacheTone = cachePct >= 80 ? "ok" : cachePct >= 50 ? "" : "warn";
|
||||
const showBudget = stats != null && typeof budgetUsd === "number" && budgetUsd > 0;
|
||||
const budgetPct = showBudget ? Math.min(120, (stats.totalCostUsd / budgetUsd) * 100) : 0;
|
||||
@@ -812,7 +948,7 @@ function SideRail({ stats, budgetUsd, activePlan }: SideRailProps) {
|
||||
<div class="progress-row" style="margin-top:8px">
|
||||
<span class="lbl">${t("chat.railCacheHit")}</span>
|
||||
<div class=${`progress ${cacheTone}`}><div class="progress-fill" style=${`width:${cachePct}%`}></div></div>
|
||||
<span class="v">${cachePct}%</span>
|
||||
<span class="v">${cachePct.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
@@ -834,7 +970,7 @@ function SideRail({ stats, budgetUsd, activePlan }: SideRailProps) {
|
||||
}
|
||||
</aside>
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
function ActivePlanCard({ plan }: { plan: RailPlan }) {
|
||||
useLang();
|
||||
@@ -963,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`
|
||||
@@ -1017,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">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { budgetTone, deriveBudgetState } from "../lib/budget.js";
|
||||
import { fmtCompactNum, fmtCost, fmtNum, fmtRelativeTime, fmtUsd } from "../lib/format.js";
|
||||
import { html } from "../lib/html.js";
|
||||
import { usePoll } from "../lib/use-poll.js";
|
||||
import { compareVersions } from "../lib/version.js";
|
||||
import { t, useLang } from "../i18n/index.js";
|
||||
|
||||
interface CockpitKpi {
|
||||
@@ -280,7 +281,8 @@ export function OverviewPanel() {
|
||||
recentPlans: null,
|
||||
toolActivity: null,
|
||||
};
|
||||
const upToDate = o.latestVersion ? o.latestVersion === o.version : null;
|
||||
const upToDate =
|
||||
o.latestVersion && o.version ? compareVersions(o.version, o.latestVersion) >= 0 : null;
|
||||
const versionDelta =
|
||||
upToDate === null ? t("overview.checking") : upToDate ? t("overview.latest") : `latest: ${o.latestVersion}`;
|
||||
const versionTone: "up" | "down" | "flat" = upToDate === false ? "down" : "flat";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { fmtBytes, fmtNum } from "../lib/format.js";
|
||||
import { html } from "../lib/html.js";
|
||||
import { usePoll } from "../lib/use-poll.js";
|
||||
import { compareVersions } from "../lib/version.js";
|
||||
import { t, useLang } from "../i18n/index.js";
|
||||
|
||||
interface HealthData {
|
||||
@@ -22,7 +23,7 @@ export function SystemPanel() {
|
||||
if (error) return html`<div class="card accent-err">${t("common.loadingFailed", { name: "health", error: error.message })}</div>`;
|
||||
if (!data) return null;
|
||||
const h = data;
|
||||
const upToDate = h.latestVersion ? h.latestVersion === h.version : null;
|
||||
const upToDate = h.latestVersion ? compareVersions(h.version, h.latestVersion) >= 0 : null;
|
||||
|
||||
return html`
|
||||
<div style="display:flex;flex-direction:column;gap:14px">
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
)}
|
||||
|
||||
@@ -36,6 +36,7 @@ interface UsageDay {
|
||||
function UsageChart({ days }: { days: UsageDay[] }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const plotRef = useRef<UPlotInstance | null>(null);
|
||||
useLang();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -64,7 +65,7 @@ function UsageChart({ days }: { days: UsageDay[] }) {
|
||||
{ stroke: "#94a3b8", grid: { stroke: "rgba(148, 163, 184, 0.08)" } },
|
||||
{
|
||||
scale: "y",
|
||||
label: "USD",
|
||||
label: t("usage.axisUsd"),
|
||||
stroke: "#94a3b8",
|
||||
grid: { stroke: "rgba(148, 163, 184, 0.08)" },
|
||||
values: (_u: unknown, v: number[]) => v.map((n) => `$${n.toFixed(4)}`),
|
||||
@@ -72,17 +73,22 @@ function UsageChart({ days }: { days: UsageDay[] }) {
|
||||
{
|
||||
scale: "turns",
|
||||
side: 1,
|
||||
label: "turns",
|
||||
label: t("usage.axisTurns"),
|
||||
stroke: "#94a3b8",
|
||||
grid: { show: false },
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{},
|
||||
{ label: "cost", stroke: "#67e8f9", width: 2, fill: "rgba(103, 232, 249, 0.10)" },
|
||||
{ label: "cache saved", stroke: "#5eead4", width: 2, dash: [4, 4] },
|
||||
{ label: t("usage.axisTime") },
|
||||
{
|
||||
label: "turns",
|
||||
label: t("usage.seriesCost"),
|
||||
stroke: "#67e8f9",
|
||||
width: 2,
|
||||
fill: "rgba(103, 232, 249, 0.10)",
|
||||
},
|
||||
{ label: t("usage.seriesCacheSaved"), stroke: "#5eead4", width: 2, dash: [4, 4] },
|
||||
{
|
||||
label: t("usage.seriesTurns"),
|
||||
stroke: "#c4b5fd",
|
||||
scale: "turns",
|
||||
width: 1.5,
|
||||
@@ -203,13 +209,13 @@ export function UsagePanel() {
|
||||
<table class="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>${t("usage.colTurns")}</th>
|
||||
<th>${t("usage.colCacheHit")}</th>
|
||||
<th>${t("usage.colCost")}</th>
|
||||
<th>${t("usage.colCacheSaved")}</th>
|
||||
<th>${t("usage.colVsClaude")}</th>
|
||||
<th>${t("usage.colSaved")}</th>
|
||||
<th>${t("usage.colWindow")}</th>
|
||||
<th class="num">${t("usage.colTurns")}</th>
|
||||
<th class="num">${t("usage.colCacheHit")}</th>
|
||||
<th class="num">${t("usage.colCost")}</th>
|
||||
<th class="num">${t("usage.colCacheSaved")}</th>
|
||||
<th class="num">${t("usage.colVsClaude")}</th>
|
||||
<th class="num">${t("usage.colSaved")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -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 |