mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ece9b77191 | ||
|
|
ccbdc79998 | ||
|
|
b49b2d121e | ||
|
|
82dc750d28 | ||
|
|
d5dc8df1a5 | ||
|
|
bd2b29bdd5 | ||
|
|
272bc9d116 | ||
|
|
368dad0e47 | ||
|
|
c8007a6b6c | ||
|
|
823fdce746 | ||
|
|
f311a475d4 | ||
|
|
3141dddae5 | ||
|
|
b30215a0d1 | ||
|
|
7c7285d4f1 | ||
|
|
b73ce718a8 | ||
|
|
58f444a692 | ||
|
|
acc0aebda3 | ||
|
|
4441e6b5fb | ||
|
|
bbf76100e6 | ||
|
|
e2568bd07c | ||
|
|
d4422b651d | ||
|
|
10fecbaa92 | ||
|
|
8b2028e824 | ||
|
|
ba094b9f5e | ||
|
|
f5363dd741 | ||
|
|
2a8b2446bb | ||
|
|
f6e6673d25 | ||
|
|
c5372f53db | ||
|
|
a63fecaf91 | ||
|
|
91eac0a110 | ||
|
|
55e683dabe | ||
|
|
cf7e483201 | ||
|
|
9328c48fb0 | ||
|
|
98280862c6 | ||
|
|
8feb6b3f17 | ||
|
|
0d015fcbf7 | ||
|
|
5cf486f985 | ||
|
|
f9a7c08cc0 | ||
|
|
9373980a80 | ||
|
|
7f5fa3df77 | ||
|
|
6969e96f23 | ||
|
|
d5c5d3bd54 | ||
|
|
7fffa2f94a | ||
|
|
c0bb1796c5 | ||
|
|
0d248f7d21 | ||
|
|
d43d0e7d44 | ||
|
|
d0ce7651e4 | ||
|
|
78b19bf105 | ||
|
|
c816d2dc8f | ||
|
|
f50604412a | ||
|
|
afb1466640 | ||
|
|
6cc7396122 | ||
|
|
fbc80b8352 | ||
|
|
7cef20389a | ||
|
|
f8bd98c462 | ||
|
|
44486ff91c | ||
|
|
cd313db1ff | ||
|
|
2532584975 | ||
|
|
60f98f698e | ||
|
|
3f75e4e22c | ||
|
|
82a820baea | ||
|
|
19f9ccfd52 | ||
|
|
9331bc054e | ||
|
|
7ca6f1b835 | ||
|
|
bc0885de9d | ||
|
|
46af735c89 | ||
|
|
7efa8ad160 |
No files matched your search
@@ -9,14 +9,29 @@ name: Release desktop
|
||||
# runner per platform. Artifacts are minisign-signed (MINISIGN_* secrets), a
|
||||
# latest.json manifest is generated, and everything is published to a GitHub
|
||||
# release and mirrored to R2 (the updater reads R2 first, GitHub as fallback).
|
||||
#
|
||||
# The same build/sign/manifest pipeline serves two channels. A `desktop-v*` tag
|
||||
# (or manual stable dispatch) publishes a GitHub release and moves R2's latest/
|
||||
# pointer. A manual `channel: canary` dispatch builds with -X main.channel=canary
|
||||
# and uploads only to R2's canary/ pointer — no GitHub release, so canary never
|
||||
# shows on the releases page and never touches latest/.
|
||||
on:
|
||||
push:
|
||||
tags: ["desktop-v*"]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
channel:
|
||||
description: "Release channel"
|
||||
type: choice
|
||||
options: [stable, canary]
|
||||
default: stable
|
||||
tag:
|
||||
description: "Tag to publish (e.g. desktop-v1.1.0). Required when run manually."
|
||||
required: true
|
||||
description: "stable: tag to publish (e.g. desktop-v1.1.0)"
|
||||
required: false
|
||||
type: string
|
||||
base_version:
|
||||
description: "canary: intended next stable version, e.g. 1.5.0"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
@@ -89,18 +104,57 @@ jobs:
|
||||
if: runner.os == 'macOS'
|
||||
run: brew install create-dmg
|
||||
|
||||
# macOS signing: import the Developer ID cert into a throwaway keychain and
|
||||
# stage the notarization key. No-ops (and the build ad-hoc signs) when the
|
||||
# APPLE_* secrets aren't set, so forks still build.
|
||||
- name: Import Apple signing certificate
|
||||
if: runner.os == 'macOS'
|
||||
env:
|
||||
APPLE_CERT_P12: ${{ secrets.APPLE_CERT_P12 }}
|
||||
APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }}
|
||||
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
|
||||
run: |
|
||||
if [ -z "$APPLE_CERT_P12" ]; then
|
||||
echo "APPLE_CERT_P12 unset — desktop build will ad-hoc sign (un-notarized)"
|
||||
exit 0
|
||||
fi
|
||||
KEYCHAIN="$RUNNER_TEMP/signing.keychain-db"
|
||||
KEYCHAIN_PASS="$(uuidgen)"
|
||||
security create-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN"
|
||||
security set-keychain-settings -lut 21600 "$KEYCHAIN"
|
||||
security unlock-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN"
|
||||
echo "$APPLE_CERT_P12" | base64 --decode > "$RUNNER_TEMP/cert.p12"
|
||||
security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" -P "$APPLE_CERT_PASSWORD" -T /usr/bin/codesign
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASS" "$KEYCHAIN" >/dev/null
|
||||
# Prepend the signing keychain to the search list so codesign / find-identity see it.
|
||||
security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"')
|
||||
echo "$APPLE_API_KEY_P8" | base64 --decode > "$RUNNER_TEMP/notary.p8"
|
||||
rm -f "$RUNNER_TEMP/cert.p12"
|
||||
|
||||
- name: Install Wails CLI
|
||||
run: go install github.com/wailsapp/wails/v2/cmd/[email protected]
|
||||
|
||||
- name: Resolve version
|
||||
id: ver
|
||||
run: |
|
||||
TAG="${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${TAG#desktop-}" >> "$GITHUB_OUTPUT"
|
||||
run: bash scripts/resolve-desktop-release.sh
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
IN_CHANNEL: ${{ inputs.channel }}
|
||||
IN_TAG: ${{ inputs.tag }}
|
||||
IN_BASE_VERSION: ${{ inputs.base_version }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
|
||||
- name: Build and package
|
||||
run: scripts/desktop-build.sh "${{ matrix.platform }}" "${{ steps.ver.outputs.version }}"
|
||||
env:
|
||||
# macOS Developer ID + notarization path turns on only when all five
|
||||
# APPLE_* secrets are present; otherwise desktop-build.sh ad-hoc signs.
|
||||
# Harmless on Windows/Linux runners (only the darwin branch reads these).
|
||||
HAS_APPLE_CERT: ${{ secrets.APPLE_CERT_P12 != '' && secrets.APPLE_CERT_PASSWORD != '' && secrets.APPLE_API_KEY_P8 != '' && secrets.APPLE_API_KEY_ID != '' && secrets.APPLE_API_ISSUER_ID != '' }}
|
||||
APPLE_API_KEY_PATH: ${{ runner.temp }}/notary.p8
|
||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||
APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }}
|
||||
run: scripts/desktop-build.sh "${{ matrix.platform }}" "${{ steps.ver.outputs.version }}" "${{ steps.ver.outputs.channel }}"
|
||||
|
||||
- name: Sign artifacts (minisign)
|
||||
working-directory: desktop
|
||||
@@ -119,6 +173,9 @@ jobs:
|
||||
name: publish release
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
# Stable publishes go through the `release` environment (esengine must
|
||||
# approve); canary uses the open `canary` environment so maintainers self-serve.
|
||||
environment: ${{ (github.event_name == 'workflow_dispatch' && inputs.channel == 'canary') && 'canary' || 'release' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -136,13 +193,14 @@ jobs:
|
||||
|
||||
- name: Resolve version
|
||||
id: ver
|
||||
run: |
|
||||
TAG="${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}"
|
||||
VERSION="${TAG#desktop-}"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
# A version with a suffix (v1.0.0-rc1) is a prerelease.
|
||||
case "$VERSION" in *-*) echo "prerelease=true" >> "$GITHUB_OUTPUT";; *) echo "prerelease=false" >> "$GITHUB_OUTPUT";; esac
|
||||
run: bash scripts/resolve-desktop-release.sh
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
IN_CHANNEL: ${{ inputs.channel }}
|
||||
IN_TAG: ${{ inputs.tag }}
|
||||
IN_BASE_VERSION: ${{ inputs.base_version }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
|
||||
# Generate latest.json with GitHub release download URLs; the mirror step
|
||||
# rewrites them to R2 afterwards. GITHUB_REPOSITORY is provided by the runner.
|
||||
@@ -150,7 +208,11 @@ jobs:
|
||||
working-directory: desktop
|
||||
run: go run ./cmd/sign manifest ../dist "${{ steps.ver.outputs.version }}" "${{ steps.ver.outputs.tag }}"
|
||||
|
||||
# Canary is R2-only and never appears on the GitHub releases page; the mirror
|
||||
# job picks up the signed dist via the canary-dist artifact below. Stable
|
||||
# publishes a GitHub release as usual.
|
||||
- name: Publish GitHub release
|
||||
if: steps.ver.outputs.channel != 'canary'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
@@ -158,6 +220,14 @@ jobs:
|
||||
[ "${{ steps.ver.outputs.prerelease }}" = "true" ] && args+=(--prerelease)
|
||||
gh release create "${{ steps.ver.outputs.tag }}" dist/* "${args[@]}"
|
||||
|
||||
- name: Upload canary dist for mirror
|
||||
if: steps.ver.outputs.channel == 'canary'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: canary-dist
|
||||
path: dist/*
|
||||
if-no-files-found: error
|
||||
|
||||
mirror:
|
||||
name: mirror to R2
|
||||
needs: publish
|
||||
@@ -167,15 +237,30 @@ jobs:
|
||||
env:
|
||||
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
|
||||
steps:
|
||||
- name: Resolve tag
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve version
|
||||
id: ver
|
||||
run: |
|
||||
TAG="${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
case "${TAG#desktop-}" in *-*) echo "prerelease=true" >> "$GITHUB_OUTPUT";; *) echo "prerelease=false" >> "$GITHUB_OUTPUT";; esac
|
||||
run: bash scripts/resolve-desktop-release.sh
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
IN_CHANNEL: ${{ inputs.channel }}
|
||||
IN_TAG: ${{ inputs.tag }}
|
||||
IN_BASE_VERSION: ${{ inputs.base_version }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
|
||||
# Canary has no GitHub release — pull the signed dist from the workflow
|
||||
# artifact. Stable pulls from the published release.
|
||||
- name: Download canary dist
|
||||
if: env.HAS_R2 == 'true' && steps.ver.outputs.channel == 'canary'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: canary-dist
|
||||
path: assets
|
||||
|
||||
- name: Download release assets
|
||||
if: env.HAS_R2 == 'true'
|
||||
if: env.HAS_R2 == 'true' && steps.ver.outputs.channel != 'canary'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
@@ -216,9 +301,11 @@ jobs:
|
||||
run: |
|
||||
ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
|
||||
aws s3 cp assets/ "s3://${R2_BUCKET}/${TAG}/" --recursive --endpoint-url "$ENDPOINT"
|
||||
# Prereleases publish to their own tag prefix but must not move the
|
||||
# updater's latest/ pointer; stable releases do.
|
||||
if [ "${{ steps.ver.outputs.prerelease }}" = "true" ]; then
|
||||
# The pointer a release moves is its channel's alone: canary writes
|
||||
# canary/, stable writes latest/. A stable prerelease (rc) moves neither.
|
||||
if [ "${{ steps.ver.outputs.channel }}" = "canary" ]; then
|
||||
aws s3 cp assets/ "s3://${R2_BUCKET}/canary/" --recursive --endpoint-url "$ENDPOINT"
|
||||
elif [ "${{ steps.ver.outputs.prerelease }}" = "true" ]; then
|
||||
echo "prerelease — leaving R2 latest/ untouched"
|
||||
else
|
||||
aws s3 cp assets/ "s3://${R2_BUCKET}/latest/" --recursive --endpoint-url "$ENDPOINT"
|
||||
|
||||
@@ -10,12 +10,20 @@ name: Release npm
|
||||
# git tag npm-vX.Y.Z-rc.N # prerelease -> publishes under `next`
|
||||
# git push origin <tag>
|
||||
#
|
||||
# build.mjs decides the dist-tag: only the 0.x stable line is `latest`; the 1.x
|
||||
# line and every prerelease ship under `next`. Promote a 1.x stable to the default
|
||||
# install with a manual `npm dist-tag add [email protected] latest`.
|
||||
# A manual workflow_dispatch with base_version publishes an opt-in canary to the
|
||||
# `canary` dist-tag (npm i reasonix@canary) from the current ref; it never moves
|
||||
# `next`/`latest`. build.mjs decides the dist-tag: 0.x stable is `latest`; a
|
||||
# `-canary.` build is `canary`; the 1.x line and rc prereleases are `next`. Promote
|
||||
# a 1.x stable to the default install with `npm dist-tag add [email protected] latest`.
|
||||
on:
|
||||
push:
|
||||
tags: ['npm-v*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
base_version:
|
||||
description: "canary: intended next stable version, e.g. 1.5.0"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -36,6 +44,9 @@ jobs:
|
||||
name: publish npm packages
|
||||
needs: cache-guard
|
||||
runs-on: ubuntu-latest
|
||||
# A tag push (npm-v*) publishes a stable/next release and goes through the
|
||||
# `release` environment (esengine approves); a canary dispatch runs free.
|
||||
environment: ${{ github.event_name == 'workflow_dispatch' && 'canary' || 'release' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
@@ -46,8 +57,17 @@ jobs:
|
||||
with:
|
||||
node-version: '22'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
# build.mjs strips the leading `npm-`/`v`, so it derives the bare semver from
|
||||
# the `npm-v*` tag (e.g. npm-v1.3.0-rc.1 -> 1.3.0-rc.1).
|
||||
- run: node npm/build.mjs "${GITHUB_REF_NAME}" --publish
|
||||
# Tag push uses the npm-v* tag; a canary dispatch synthesizes a
|
||||
# <base>-canary.<run_number> version. build.mjs strips the leading `npm-`/`v`.
|
||||
- name: Resolve version
|
||||
id: ver
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
base="${{ inputs.base_version }}"; base="${base#v}"
|
||||
echo "arg=v${base}-canary.${{ github.run_number }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "arg=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- run: node npm/build.mjs "${{ steps.ver.outputs.arg }}" --publish
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
@@ -30,6 +30,9 @@ jobs:
|
||||
name: archives + checksums + homebrew tap
|
||||
needs: cache-guard
|
||||
runs-on: ubuntu-latest
|
||||
# CLI stable release (archives + GitHub release + Homebrew cask). Gated on the
|
||||
# `release` environment so only esengine can approve it going public.
|
||||
environment: release
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -45,3 +48,52 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
|
||||
mirror-codegraph:
|
||||
name: mirror CodeGraph runtime to R2
|
||||
needs: goreleaser
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository_owner == 'esengine' }}
|
||||
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: Download CodeGraph release assets
|
||||
if: env.HAS_R2 == 'true'
|
||||
run: |
|
||||
version="$(awk '/^CODEGRAPH_VERSION[[:space:]]*:=/ { print $3; exit }' Makefile)"
|
||||
[ -n "$version" ] || { echo "CODEGRAPH_VERSION not found" >&2; exit 1; }
|
||||
base="https://github.com/colbymchenry/codegraph/releases/download/${version}"
|
||||
mkdir -p codegraph-assets
|
||||
curl -fsSL "$base/SHA256SUMS" -o codegraph-assets/SHA256SUMS
|
||||
for asset in \
|
||||
codegraph-darwin-arm64.tar.gz \
|
||||
codegraph-darwin-x64.tar.gz \
|
||||
codegraph-linux-arm64.tar.gz \
|
||||
codegraph-linux-x64.tar.gz \
|
||||
codegraph-win32-arm64.zip \
|
||||
codegraph-win32-x64.zip
|
||||
do
|
||||
curl -fsSL "$base/$asset" -o "codegraph-assets/$asset"
|
||||
want="$(awk -v name="$asset" '$2 == name { print $1 }' codegraph-assets/SHA256SUMS)"
|
||||
got="$(shasum -a 256 "codegraph-assets/$asset" | awk '{ print $1 }')"
|
||||
[ "$got" = "$want" ] || { echo "checksum mismatch for $asset" >&2; exit 1; }
|
||||
done
|
||||
echo "CODEGRAPH_VERSION=$version" >> "$GITHUB_ENV"
|
||||
|
||||
- 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 CodeGraph to R2
|
||||
if: env.HAS_R2 == 'true'
|
||||
env:
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
run: |
|
||||
ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
|
||||
aws s3 cp codegraph-assets/ "s3://${R2_BUCKET}/codegraph/${CODEGRAPH_VERSION}/" --recursive --endpoint-url "$ENDPOINT"
|
||||
@@ -71,6 +71,12 @@ brew install esengine/reasonix/reasonix # macOS
|
||||
Prebuilt archives (`darwin|linux|windows × amd64|arm64`) and `SHA256SUMS` are on
|
||||
every [GitHub release](https://github.com/esengine/DeepSeek-Reasonix/releases).
|
||||
|
||||
### Code signing
|
||||
|
||||
Windows builds are code-signed with a free certificate provided by the
|
||||
[SignPath Foundation](https://signpath.org/), with signing through
|
||||
[SignPath.io](https://signpath.io/).
|
||||
|
||||
### Build from source
|
||||
|
||||
```sh
|
||||
@@ -100,6 +106,8 @@ default_model = "deepseek-flash" # executor; set [agent].planner_model to add
|
||||
# language = "zh" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG
|
||||
|
||||
[agent]
|
||||
max_steps = 0 # executor tool-call rounds; 0 = no limit
|
||||
planner_max_steps = 12 # planner read-only tool-call rounds; 0 = no limit
|
||||
# planner_model = "mimo-pro" # optional low-frequency planner
|
||||
# subagent_model = "deepseek-pro" # optional default for runAs=subagent skills
|
||||
# subagent_models = { review = "deepseek-pro", security_review = "deepseek-pro" }
|
||||
@@ -125,8 +133,8 @@ bash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local ca
|
||||
|
||||
[permissions]
|
||||
mode = "ask" # writer fallback when no rule matches: ask|allow|deny
|
||||
deny = ["bash(rm -rf*)", "bash(git push*)"] # hard-blocked in every mode
|
||||
allow = ["bash(go test*)"] # never prompted
|
||||
deny = ["Bash(rm -rf*)", "Bash(git push*)"] # hard-blocked in every mode
|
||||
allow = ["Bash(go test:*)"] # never prompted
|
||||
|
||||
[sandbox]
|
||||
# workspace_root = "" # file-writers confined here; empty = current dir
|
||||
@@ -137,10 +145,15 @@ name = "example"
|
||||
command = "reasonix-plugin-example"
|
||||
```
|
||||
|
||||
Permissions gate each tool call: `deny` > `ask` > `allow` > fallback (readers
|
||||
always allow; writers fall back to `mode`). `reasonix chat` prompts before writers
|
||||
(`y` once · `a` this session · `n` no); `reasonix run` stays autonomous but still
|
||||
honours `deny`. See [`docs/SPEC.md`](docs/SPEC.md) for the full schema and contract.
|
||||
Permissions gate each tool call: `deny` > `ask` > `allow` > fallback. Bash and
|
||||
file mutation tools require approval by default; read-only tools generally do
|
||||
not. Approvals are stored and matched as permission rules, not button labels:
|
||||
for example `Bash(npm run build)`, `Bash(npm run test:*)`, and `Edit(docs/**)`.
|
||||
`reasonix chat` can grant Bash as an exact command or as a conservative command
|
||||
prefix (for example `Bash(go test:*)`), while file-editing tools share session
|
||||
edit grants and persist path-scoped rules such as `Edit(src/app.go)`.
|
||||
`reasonix run` stays autonomous but still honours `deny`. See
|
||||
[`docs/SPEC.md`](docs/SPEC.md) for the full schema and contract.
|
||||
|
||||
Permissions are *policy* (which calls to allow / prompt). The **sandbox** is
|
||||
*enforcement*: the file-writers (`write_file` / `edit_file` / `multi_edit`)
|
||||
@@ -207,8 +220,10 @@ convenient.
|
||||
|
||||
### Slash commands
|
||||
|
||||
In `reasonix chat`, built-in commands (`/compact`, `/new`, `/rewind`, `/tree`,
|
||||
In `reasonix chat`, built-in commands (`/compact`, `/new`/`/clear`, `/rewind`, `/tree`,
|
||||
`/branch`, `/switch`, `/todo`, `/model`, `/effort`, `/mcp`, `/memory`, `/help`) run locally.
|
||||
`/new` starts a fresh model context while saving the previous transcript for
|
||||
history/resume; `/clear` is the Claude Code-compatible alias.
|
||||
`/tree` shows saved conversation branches, `/branch [name]` forks the current
|
||||
conversation tip, `/branch <turn> [name]` forks from an earlier checkpointed turn,
|
||||
and `/switch <id|name>` loads another branch. **Custom commands** are Markdown files under
|
||||
@@ -248,11 +263,18 @@ separate cache-stable sessions) is a one-line edit afterwards — set
|
||||
```toml
|
||||
[agent]
|
||||
planner_model = "deepseek-pro" # used as the low-frequency planner
|
||||
planner_max_steps = 12 # read-only tool-call rounds before pausing
|
||||
```
|
||||
|
||||
The planner sees loaded `REASONIX.md` / `AGENTS.md` memory and a small read-only
|
||||
research tool set, so it can inspect relevant files before handing a plan to the
|
||||
executor. Writer and workflow tools remain executor-only.
|
||||
executor. Writer and workflow tools remain executor-only. `max_steps` limits the
|
||||
executor; `planner_max_steps` limits only the planner, and either can be set to
|
||||
`0` for no round limit.
|
||||
|
||||
Keep personal step-limit preferences in the user config. Add them to a project's
|
||||
`./reasonix.toml` only when that repository needs a shared override, such as a
|
||||
larger planner limit for a very large codebase.
|
||||
|
||||
Subagent skills inherit the executor model by default. Set `subagent_model` to
|
||||
run them on another configured model, or use `subagent_models` to override only
|
||||
|
||||
+21
-5
@@ -68,6 +68,11 @@ brew install esengine/reasonix/reasonix # macOS
|
||||
预编译归档(`darwin|linux|windows × amd64|arm64`)和 `SHA256SUMS` 见每个
|
||||
[GitHub release](https://github.com/esengine/DeepSeek-Reasonix/releases)。
|
||||
|
||||
### 代码签名
|
||||
|
||||
Windows 构建使用 [SignPath 基金会](https://signpath.org/) 提供的免费代码签名证书,
|
||||
通过 [SignPath.io](https://signpath.io/) 完成签名。
|
||||
|
||||
### 从源码构建
|
||||
|
||||
```sh
|
||||
@@ -96,6 +101,8 @@ default_model = "deepseek-flash" # 执行器;设 [agent].planner_model 可
|
||||
# language = "zh" # 界面语言;为空则按 $LANG / $REASONIX_LANG 自动检测
|
||||
|
||||
[agent]
|
||||
max_steps = 0 # 执行器工具调用轮数;0 表示不限
|
||||
planner_max_steps = 12 # 规划器只读工具调用轮数;0 表示不限
|
||||
# planner_model = "mimo-pro" # 可选的低频规划器
|
||||
# subagent_model = "deepseek-pro" # runAs=subagent skill 的默认模型
|
||||
# subagent_models = { review = "deepseek-pro", security_review = "deepseek-pro" }
|
||||
@@ -121,8 +128,8 @@ bash_timeout_seconds = 120 # 前台安全上限;设为 0 表示不设工具
|
||||
|
||||
[permissions]
|
||||
mode = "ask" # 无规则命中时 writer 的兜底:ask|allow|deny
|
||||
deny = ["bash(rm -rf*)", "bash(git push*)"] # 任何模式下都硬阻断
|
||||
allow = ["bash(go test*)"] # 从不询问
|
||||
deny = ["Bash(rm -rf*)", "Bash(git push*)"] # 任何模式下都硬阻断
|
||||
allow = ["Bash(go test:*)"] # 从不询问
|
||||
|
||||
[sandbox]
|
||||
# workspace_root = "" # 文件写工具被限制在此目录;留空 = 当前目录
|
||||
@@ -133,8 +140,11 @@ name = "example"
|
||||
command = "reasonix-plugin-example"
|
||||
```
|
||||
|
||||
权限逐次调用把关:`deny` > `ask` > `allow` > 兜底(只读工具永远 allow,writer 落到
|
||||
`mode`)。`reasonix chat` 会在 writer 调用前征求同意(`1` 本次 · `2` 本会话 · `3` 拒绝,兼容 `y/a/n`);
|
||||
权限逐次调用把关:`deny` > `ask` > `allow` > 兜底。Bash 和文件修改都要审核;
|
||||
只读工具一般不需要。审核规则不是按“按钮文案”存,而是按权限规则匹配,比如
|
||||
`Bash(npm run build)`、`Bash(npm run test:*)`、`Edit(docs/**)` 这种形式。
|
||||
`reasonix chat` 会在 writer 调用前征求同意(普通工具为 `1` 本次 · `2` 本会话允许此范围 · `3` 总是允许此范围(保存) · `4` 拒绝;Bash 可额外选择命令前缀授权);
|
||||
其中 Bash 默认按具体命令记,也可按安全推导出的命令前缀记(如 `Bash(go test:*)`);文件编辑类工具的本会话授权按编辑能力记,持久授权则写入 `Edit(<path>)` 文件路径规则;
|
||||
`reasonix run` 保持自主运行但仍然遵守 `deny`。完整 schema 与契约见
|
||||
[`docs/SPEC.md`](docs/SPEC.md)。
|
||||
|
||||
@@ -190,7 +200,8 @@ headers = { Authorization = "Bearer ${STRIPE_KEY}" }
|
||||
|
||||
### 斜杠命令
|
||||
|
||||
`reasonix chat` 里,内置命令(`/compact`、`/new`、`/rewind`、`/tree`、`/branch`、`/switch`、`/todo`、`/model`、`/effort`、`/mcp`、`/help`)在本地执行。
|
||||
`reasonix chat` 里,内置命令(`/compact`、`/new`(`/clear`)、`/rewind`、`/tree`、`/branch`、`/switch`、`/todo`、`/model`、`/effort`、`/mcp`、`/help`)在本地执行。
|
||||
`/new` 会开启干净的模型上下文,同时保存之前的 transcript 供历史记录和恢复使用;`/clear` 是兼容 Claude Code 习惯的同义命令。
|
||||
`/tree` 查看已保存的对话分支,`/branch [name]` 从当前对话末端分支,`/branch <turn> [name]`
|
||||
从较早的 checkpoint 轮次分支,`/switch <id|name>` 切换到另一个分支。**自定义命令**
|
||||
是放在 `.reasonix/commands/`(项目)或 `~/.config/reasonix/commands/`(用户)下的 Markdown 文件——
|
||||
@@ -224,10 +235,15 @@ session),向导后手动在 `reasonix.toml` 加一行即可:
|
||||
```toml
|
||||
[agent]
|
||||
planner_model = "deepseek-pro" # 作为低频规划器
|
||||
planner_max_steps = 12 # 暂停前允许的只读工具调用轮数
|
||||
```
|
||||
|
||||
Planner 会看到已加载的 `REASONIX.md` / `AGENTS.md` 记忆,并拿到一小组只读研究工具,
|
||||
因此可以先检查相关文件再把计划交给执行器。写入类和流程类工具仍只给执行器使用。
|
||||
`max_steps` 限制执行器;`planner_max_steps` 只限制规划器,两者都可设为 `0` 表示不限。
|
||||
|
||||
个人偏好的轮数上限建议放在用户级配置。只有当某个仓库确实需要团队共享覆盖时,
|
||||
再写进项目的 `./reasonix.toml`,例如超大代码库需要更高的 planner 上限。
|
||||
|
||||
Subagent skills 默认继承执行器模型。设置 `subagent_model` 可让它们统一走另一个已配置
|
||||
模型;设置 `subagent_models` 则只覆盖 `review`、`security_review` 等指定 skill。
|
||||
|
||||
@@ -12,6 +12,13 @@
|
||||
/build/windows/installer/*
|
||||
!/build/windows/installer/project.nsi
|
||||
|
||||
# Commit our hand-written hardened-runtime entitlements (scripts/desktop-build.sh
|
||||
# passes it to codesign for Developer ID signing). Unlike Info.plist it is NOT
|
||||
# regenerated by `wails build`, so it must live in git. Parent dir un-ignored first.
|
||||
!/build/darwin
|
||||
/build/darwin/*
|
||||
!/build/darwin/entitlements.plist
|
||||
|
||||
# Go test binaries
|
||||
*.test
|
||||
|
||||
|
||||
+116
-32
@@ -69,9 +69,10 @@ type App struct {
|
||||
activeTabID string
|
||||
readyHook func()
|
||||
|
||||
forceQuit atomic.Bool
|
||||
trayReady bool
|
||||
tray *desktopTray
|
||||
forceQuit atomic.Bool
|
||||
backgroundMaximised atomic.Bool
|
||||
trayReady bool
|
||||
tray *desktopTray
|
||||
|
||||
mediaTokens *mediaTokenStore
|
||||
}
|
||||
@@ -278,6 +279,7 @@ func (a *App) beforeClose(ctx context.Context) bool {
|
||||
cfg = config.LoadForEdit(config.UserConfigPath())
|
||||
}
|
||||
if cfg.DesktopCloseBehavior() == "background" {
|
||||
a.backgroundMaximised.Store(runtime.WindowIsMaximised(ctx))
|
||||
a.saveWindowStateSync()
|
||||
a.snapshotAllTabs()
|
||||
hideForBackground(ctx)
|
||||
@@ -288,7 +290,7 @@ func (a *App) beforeClose(ctx context.Context) bool {
|
||||
|
||||
func (a *App) showMainWindow() {
|
||||
if a.ctx != nil {
|
||||
showFromBackground(a.ctx)
|
||||
showFromBackground(a.ctx, a.backgroundMaximised.Swap(false))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,18 +314,40 @@ func hideForBackground(ctx context.Context) {
|
||||
runtime.WindowHide(ctx)
|
||||
}
|
||||
|
||||
func showFromBackground(ctx context.Context) {
|
||||
func showFromBackground(ctx context.Context, wasMaximised bool) {
|
||||
if backgroundCloseUsesApplicationHide(goruntime.GOOS) {
|
||||
runtime.Show(ctx)
|
||||
}
|
||||
plan := backgroundRestorePlanFor(goruntime.GOOS, wasMaximised)
|
||||
if plan.maximiseBeforeShow {
|
||||
runtime.WindowMaximise(ctx)
|
||||
}
|
||||
runtime.WindowShow(ctx)
|
||||
runtime.WindowUnminimise(ctx)
|
||||
if plan.unminimiseAfterShow {
|
||||
runtime.WindowUnminimise(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func backgroundCloseUsesApplicationHide(goos string) bool {
|
||||
return goos == "darwin"
|
||||
}
|
||||
|
||||
type backgroundRestorePlan struct {
|
||||
maximiseBeforeShow bool
|
||||
unminimiseAfterShow bool
|
||||
}
|
||||
|
||||
func backgroundRestorePlanFor(goos string, wasMaximised bool) backgroundRestorePlan {
|
||||
if backgroundRestoreShouldMaximise(goos, wasMaximised) {
|
||||
return backgroundRestorePlan{maximiseBeforeShow: true}
|
||||
}
|
||||
return backgroundRestorePlan{unminimiseAfterShow: true}
|
||||
}
|
||||
|
||||
func backgroundRestoreShouldMaximise(goos string, wasMaximised bool) bool {
|
||||
return wasMaximised && !backgroundCloseUsesApplicationHide(goos)
|
||||
}
|
||||
|
||||
// restoreOrBuildTabs restores the tabs from the last session, or creates a
|
||||
// default Global tab on first launch.
|
||||
func (a *App) restoreOrBuildTabs() {
|
||||
@@ -559,13 +583,21 @@ func (a *App) CancelTab(tabID string) {
|
||||
// Approve answers a pending approval_request by ID: allow runs the call, session
|
||||
// also remembers the grant for the rest of the session.
|
||||
func (a *App) Approve(id string, allow, session, persist bool) {
|
||||
a.ApproveTab("", id, allow, session, persist)
|
||||
a.ApproveWithScope(id, allow, session, persist, "")
|
||||
}
|
||||
|
||||
func (a *App) ApproveWithScope(id string, allow, session, persist bool, scope string) {
|
||||
a.ApproveTabWithScope("", id, allow, session, persist, scope)
|
||||
}
|
||||
|
||||
func (a *App) ApproveTab(tabID, id string, allow, session, persist bool) {
|
||||
a.ApproveTabWithScope(tabID, id, allow, session, persist, "")
|
||||
}
|
||||
|
||||
func (a *App) ApproveTabWithScope(tabID, id string, allow, session, persist bool, scope string) {
|
||||
ctrl := a.ctrlByTabID(tabID)
|
||||
if ctrl != nil {
|
||||
ctrl.Approve(id, allow, session, persist)
|
||||
ctrl.ApproveWithScope(id, allow, session, persist, scope)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1127,6 +1159,17 @@ func (a *App) RemoveWorkspace(dir string) error {
|
||||
if err := removeProject(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
// If the removed workspace was the active one, clear the pointer
|
||||
// so we don't leave a stale reference to a deleted project.
|
||||
if loadWorkspace() == dir {
|
||||
if remaining := loadProjectsFile(); len(remaining.Projects) > 0 {
|
||||
// Fall back to the first remaining project
|
||||
saveWorkspace(remaining.Projects[0].Root)
|
||||
} else {
|
||||
// No projects left; clear the active pointer entirely
|
||||
clearWorkspace()
|
||||
}
|
||||
}
|
||||
a.emitProjectTreeChanged()
|
||||
return nil
|
||||
}
|
||||
@@ -1806,12 +1849,7 @@ func (a *App) Capabilities() CapabilitiesView {
|
||||
if loadedCfg != nil && !seen["codegraph"] {
|
||||
status := "disabled"
|
||||
if loadedCfg.Codegraph.Enabled {
|
||||
switch loadedCfg.Codegraph.ResolvedTier() {
|
||||
case "background", "eager":
|
||||
status = "initializing"
|
||||
default:
|
||||
status = "deferred"
|
||||
}
|
||||
status = "initializing"
|
||||
}
|
||||
if s, ok := disabled["codegraph"]; ok {
|
||||
s.Status = "disabled"
|
||||
@@ -2318,8 +2356,8 @@ func (a *App) connectConfiguredMCPServerForTab(tab *WorkspaceTab, name string) (
|
||||
}
|
||||
|
||||
// SetMCPServerTier is kept for old desktop bindings. New config writes drop the
|
||||
// retired tier field, so this only affects the active session before the next
|
||||
// config reload.
|
||||
// retired tier field; for CodeGraph this now means "enable and start in the
|
||||
// background".
|
||||
func (a *App) SetMCPServerTier(name, tier string) error {
|
||||
if name == "codegraph" {
|
||||
return a.setCodegraphTier(tier)
|
||||
@@ -2394,14 +2432,13 @@ func (a *App) setCodegraphEnabled(enabled bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) setCodegraphTier(tier string) error {
|
||||
tier = normalizeMCPTier(tier)
|
||||
func (a *App) setCodegraphTier(_ string) error {
|
||||
cfg, path, err := a.loadDesktopUserConfigForEdit()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Codegraph.Enabled = true
|
||||
cfg.Codegraph.Tier = tier
|
||||
cfg.Codegraph.Tier = ""
|
||||
if err := cfg.SaveTo(path); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -2415,7 +2452,7 @@ func (a *App) setCodegraphTier(tier string) error {
|
||||
a.mu.Lock()
|
||||
delete(tab.disabledMCP, "codegraph")
|
||||
a.mu.Unlock()
|
||||
if tier != "lazy" && !mcpConnected(tab.Ctrl, "codegraph") {
|
||||
if !mcpConnected(tab.Ctrl, "codegraph") {
|
||||
if _, err := tab.Ctrl.ConnectCodegraphMCPServer(cfg); err != nil {
|
||||
recordCodegraphFailure(tab.Ctrl, cfg.Codegraph, err)
|
||||
return nil
|
||||
@@ -2737,7 +2774,7 @@ func (a *App) ModelsForTab(tabID string) []ModelInfo {
|
||||
if !modelProviderAccessAllowed(access, p.Name) || !p.Configured() {
|
||||
continue
|
||||
}
|
||||
for _, m := range p.ModelList() {
|
||||
for _, m := range p.ChatModelList() {
|
||||
ref := p.Name + "/" + m
|
||||
out = append(out, ModelInfo{Ref: ref, Provider: p.Name, Model: m, Current: ref == curModel})
|
||||
}
|
||||
@@ -2938,18 +2975,48 @@ func (a *App) applyProviderEffortConfig(entry *config.ProviderEntry, effort stri
|
||||
return err
|
||||
}
|
||||
}
|
||||
canonicalName := config.CanonicalDesktopOfficialProviderName(entry.Name)
|
||||
if canonicalName != entry.Name {
|
||||
if _, ok := cfg.Provider(canonicalName); ok {
|
||||
if err := cfg.SetProviderEffort(canonicalName, effort); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, name := range providerEffortTargetNames(cfg, entry) {
|
||||
if err := cfg.SetProviderEffort(name, effort); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return cfg.SetProviderEffort(entry.Name, effort)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func providerEffortTargetNames(cfg *config.Config, entry *config.ProviderEntry) []string {
|
||||
if cfg == nil || entry == nil {
|
||||
return nil
|
||||
}
|
||||
out := []string{entry.Name}
|
||||
seen := map[string]bool{entry.Name: true}
|
||||
kind := officialProviderKindFromEntry(*entry)
|
||||
if kind == "" {
|
||||
return out
|
||||
}
|
||||
var family []string
|
||||
switch kind {
|
||||
case "deepseek":
|
||||
family = []string{"deepseek", "deepseek-flash", "deepseek-pro"}
|
||||
case "mimo-token-plan":
|
||||
family = []string{"mimo-token-plan", "mimo-pro", "mimo-flash"}
|
||||
case "mimo-api":
|
||||
family = []string{"mimo-api"}
|
||||
}
|
||||
for _, name := range family {
|
||||
if seen[name] {
|
||||
continue
|
||||
}
|
||||
p, ok := cfg.Provider(name)
|
||||
if !ok || officialProviderKindFromEntry(*p) != kind {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
out = append(out, name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DirEntry is one entry in the "@" file-reference menu.
|
||||
type DirEntry struct {
|
||||
Name string `json:"name"`
|
||||
@@ -2983,6 +3050,7 @@ type WorkspaceChangesView struct {
|
||||
Files []WorkspaceChangeView `json:"files"`
|
||||
GitAvailable bool `json:"gitAvailable"`
|
||||
GitErr string `json:"gitErr,omitempty"`
|
||||
GitBranch string `json:"gitBranch,omitempty"`
|
||||
}
|
||||
|
||||
// workspaceNoiseNames are local cache/vendor entries hidden from the file tree
|
||||
@@ -3390,16 +3458,32 @@ func (a *App) currentProviderEntryForTab(tabID string) (*config.ProviderEntry, e
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// SavePastedImage stores a browser clipboard image data URL under
|
||||
// .reasonix/attachments and returns the relative @-reference path.
|
||||
// SavePastedImage stores a browser clipboard image data URL under the active
|
||||
// tab's workspace .reasonix/attachments and returns the relative @-reference path.
|
||||
func (a *App) SavePastedImage(dataURL string) (string, error) {
|
||||
root := a.activeWorkspaceRoot()
|
||||
if root != "" && root != "." {
|
||||
if prev, err := os.Getwd(); err == nil {
|
||||
if err := os.Chdir(root); err == nil {
|
||||
defer func() { _ = os.Chdir(prev) }()
|
||||
}
|
||||
}
|
||||
}
|
||||
return control.SaveImageDataURL(dataURL)
|
||||
}
|
||||
|
||||
// SavePastedFile stores a dropped non-image file (the browser exposes its bytes
|
||||
// as a data URL but not a real path) under .reasonix/attachments and returns the
|
||||
// relative @-reference path.
|
||||
// as a data URL but not a real path) under the active tab's workspace
|
||||
// .reasonix/attachments and returns the relative @-reference path.
|
||||
func (a *App) SavePastedFile(name, dataURL string) (string, error) {
|
||||
root := a.activeWorkspaceRoot()
|
||||
if root != "" && root != "." {
|
||||
if prev, err := os.Getwd(); err == nil {
|
||||
if err := os.Chdir(root); err == nil {
|
||||
defer func() { _ = os.Chdir(prev) }()
|
||||
}
|
||||
}
|
||||
}
|
||||
return control.SaveAttachmentDataURL(name, dataURL)
|
||||
}
|
||||
|
||||
|
||||
+105
-6
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -129,6 +130,54 @@ func TestBackgroundCloseHideStrategyByPlatform(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackgroundRestoreMaximiseStrategy(t *testing.T) {
|
||||
tests := []struct {
|
||||
goos string
|
||||
maximised bool
|
||||
want bool
|
||||
}{
|
||||
{goos: "windows", maximised: true, want: true},
|
||||
{goos: "linux", maximised: true, want: true},
|
||||
{goos: "darwin", maximised: true, want: false},
|
||||
{goos: "windows", maximised: false, want: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := backgroundRestoreShouldMaximise(tt.goos, tt.maximised); got != tt.want {
|
||||
t.Fatalf("backgroundRestoreShouldMaximise(%q, %v) = %v, want %v", tt.goos, tt.maximised, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackgroundRestorePlanAvoidsNormalWindowFlash(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
goos string
|
||||
maximised bool
|
||||
want backgroundRestorePlan
|
||||
}{
|
||||
{
|
||||
name: "maximised Windows window",
|
||||
goos: "windows",
|
||||
maximised: true,
|
||||
want: backgroundRestorePlan{maximiseBeforeShow: true},
|
||||
},
|
||||
{
|
||||
name: "normal Windows window",
|
||||
goos: "windows",
|
||||
maximised: false,
|
||||
want: backgroundRestorePlan{unminimiseAfterShow: true},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := backgroundRestorePlanFor(tt.goos, tt.maximised)
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("backgroundRestorePlanFor(%q, %v) = %v, want %v", tt.goos, tt.maximised, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitReadyInvokesReadyHook(t *testing.T) {
|
||||
app := NewApp()
|
||||
var calls int32
|
||||
@@ -325,6 +374,9 @@ api_key_env = "DEEPSEEK_API_KEY"
|
||||
if p.Name != "deepseek" {
|
||||
continue
|
||||
}
|
||||
if !p.BuiltIn {
|
||||
t.Fatalf("deepseek provider should be marked built-in for official endpoint: %+v", p)
|
||||
}
|
||||
if !p.Added || !p.KeySet || len(p.Models) != 2 || p.Models[0] != "deepseek-v4-flash" || p.Models[1] != "deepseek-v4-pro" || p.Default != "deepseek-v4-flash" {
|
||||
t.Fatalf("deepseek provider = %+v, want added repaired official model list", p)
|
||||
}
|
||||
@@ -336,6 +388,53 @@ api_key_env = "DEEPSEEK_API_KEY"
|
||||
t.Fatalf("settings providers missing deepseek: %+v", got.Providers)
|
||||
}
|
||||
|
||||
func TestSettingsTreatsReservedProviderNameWithExternalEndpointAsCustom(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
t.Setenv("DEEPSEEK_API_KEY", "sk-test")
|
||||
if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
|
||||
t.Fatalf("mkdir config dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(config.UserConfigPath(), []byte(`
|
||||
default_model = "deepseek/deepseek-v4-Flash"
|
||||
|
||||
[desktop]
|
||||
provider_access = ["deepseek"]
|
||||
|
||||
[[providers]]
|
||||
name = "deepseek"
|
||||
kind = "openai"
|
||||
base_url = "https://opencode.ai/zen/go/v1"
|
||||
models = ["deepseek-v4-Flash", "deepseek-v4-pro", "glm-5"]
|
||||
default = "deepseek-v4-Flash"
|
||||
api_key_env = "DEEPSEEK_API_KEY"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
got := NewApp().Settings()
|
||||
var custom *ProviderView
|
||||
for i := range got.Providers {
|
||||
if got.Providers[i].Name == "deepseek" {
|
||||
custom = &got.Providers[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if custom == nil {
|
||||
t.Fatalf("settings providers missing deepseek: %+v", got.Providers)
|
||||
}
|
||||
if custom.BuiltIn {
|
||||
t.Fatalf("external deepseek endpoint should be custom, got built-in provider: %+v", *custom)
|
||||
}
|
||||
if !custom.Added || !custom.KeySet || custom.BaseURL != "https://opencode.ai/zen/go/v1" {
|
||||
t.Fatalf("external deepseek provider = %+v, want added key-set custom opencode endpoint", *custom)
|
||||
}
|
||||
for _, p := range got.OfficialProviders {
|
||||
if p.Name == "deepseek" && p.Added {
|
||||
t.Fatalf("official DeepSeek template should not be marked added by external endpoint: %+v", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsInfersLegacyProviderAccessWhenMissing(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
t.Setenv("DEEPSEEK_API_KEY", "sk-test")
|
||||
@@ -1674,7 +1773,7 @@ tier = "lazy"
|
||||
t.Fatalf("broken MCP missing from Capabilities: %+v", view.Servers)
|
||||
}
|
||||
|
||||
func TestSetMCPServerTierPersistsCodegraphConfig(t *testing.T) {
|
||||
func TestSetMCPServerTierEnablesCodegraphAndIgnoresLegacyTier(t *testing.T) {
|
||||
t.Setenv("HOME", robustTempDir(t))
|
||||
t.Setenv("USERPROFILE", robustTempDir(t))
|
||||
t.Setenv("XDG_CONFIG_HOME", robustTempDir(t))
|
||||
@@ -1695,7 +1794,7 @@ auto_install = true
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
defer app.activeCtrl().Close()
|
||||
|
||||
if err := app.SetMCPServerTier("codegraph", "background"); err != nil {
|
||||
if err := app.SetMCPServerTier("codegraph", "eager"); err != nil {
|
||||
t.Fatalf("SetMCPServerTier(codegraph): %v", err)
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
@@ -1703,17 +1802,17 @@ auto_install = true
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !cfg.Codegraph.Enabled {
|
||||
t.Fatal("codegraph enabled = false, want true after selecting a startup tier")
|
||||
t.Fatal("codegraph enabled = false, want true after legacy tier update")
|
||||
}
|
||||
if got := cfg.Codegraph.Tier; got != "" {
|
||||
t.Fatalf("codegraph tier = %q, want migrated empty", got)
|
||||
t.Fatalf("codegraph tier = %q, want ignored legacy tier", got)
|
||||
}
|
||||
userCfg := config.LoadForEdit(config.UserConfigPath())
|
||||
if !userCfg.Codegraph.Enabled {
|
||||
t.Fatal("user codegraph enabled = false, want true after selecting a startup tier")
|
||||
t.Fatal("user codegraph enabled = false, want true after legacy tier update")
|
||||
}
|
||||
if got := userCfg.Codegraph.Tier; got != "" {
|
||||
t.Fatalf("user codegraph tier = %q, want migrated empty", got)
|
||||
t.Fatalf("user codegraph tier = %q, want ignored legacy tier", got)
|
||||
}
|
||||
if !mcpFailed(app.activeCtrl(), "codegraph") {
|
||||
t.Fatalf("Host.Failures() = %+v, want codegraph failure recorded for missing runtime", app.activeCtrl().Host().Failures())
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Hardened-runtime entitlements required to notarize a Wails (Go + WKWebView)
|
||||
app. The web view's JS engine needs JIT and writable/executable memory;
|
||||
the Go binary links system libs that aren't team-signed. Kept minimal — no
|
||||
network/file entitlements, since this is a hardened-runtime carve-out, not
|
||||
an App Sandbox profile. -->
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
Generated
+28
@@ -8,6 +8,7 @@
|
||||
"name": "reasonix-desktop-frontend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.14.2",
|
||||
"highlight.js": "^11.10.0",
|
||||
"katex": "^0.17.0",
|
||||
"lucide-react": "^0.460.0",
|
||||
@@ -1171,6 +1172,33 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@tanstack/react-virtual": {
|
||||
"version": "3.14.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz",
|
||||
"integrity": "sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "3.17.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.17.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.0.tgz",
|
||||
"integrity": "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
"check:css": "node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css",
|
||||
"test:todo-visibility": "node scripts/test-todo-visibility.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "tsx src/__tests__/math-golden.test.ts",
|
||||
"test": "tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/provider-model-refresh.test.ts",
|
||||
"test:typecheck": "tsc --noEmit -p tsconfig.test.json",
|
||||
"test:all": "tsc --noEmit -p tsconfig.test.json && tsx src/__tests__/math-golden.test.ts"
|
||||
"test:all": "tsc --noEmit -p tsconfig.test.json && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/provider-model-refresh.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.14.2",
|
||||
"highlight.js": "^11.10.0",
|
||||
"katex": "^0.17.0",
|
||||
"lucide-react": "^0.460.0",
|
||||
@@ -35,9 +36,7 @@
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.0.7"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"mdast-util-gfm-autolink-literal": "2.0.0"
|
||||
}
|
||||
"overrides": {
|
||||
"mdast-util-gfm-autolink-literal": "2.0.0"
|
||||
}
|
||||
}
|
||||
Generated
+20
-13
@@ -11,6 +11,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@tanstack/react-virtual':
|
||||
specifier: ^3.14.2
|
||||
version: 3.14.2([email protected]([email protected]))([email protected])
|
||||
highlight.js:
|
||||
specifier: ^11.10.0
|
||||
version: 11.11.1
|
||||
@@ -517,79 +520,66 @@ packages:
|
||||
resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==}
|
||||
@@ -621,6 +611,15 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@tanstack/[email protected]':
|
||||
resolution: {integrity: sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@tanstack/[email protected]':
|
||||
resolution: {integrity: sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
|
||||
|
||||
@@ -1627,6 +1626,14 @@ snapshots:
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@tanstack/[email protected]([email protected]([email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.17.0
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1([email protected])
|
||||
|
||||
'@tanstack/[email protected]': {}
|
||||
|
||||
'@types/[email protected]':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.7
|
||||
|
||||
+120
-47
@@ -18,6 +18,7 @@ import {
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import logoWordmark from "./assets/logo-wordmark.svg";
|
||||
import { useToast } from "./lib/toast";
|
||||
import { asArray } from "./lib/array";
|
||||
import { clearLegacyLangPref, normalizeLangPref, readLegacyLangPref, t, useI18n, useT } from "./lib/i18n";
|
||||
import { useController, type Item, type LiveStream } from "./lib/useController";
|
||||
@@ -39,6 +40,7 @@ import { OnboardingOverlay } from "./components/OnboardingOverlay";
|
||||
import { TabBar } from "./components/TabBar";
|
||||
import { ProjectTree } from "./components/ProjectTree";
|
||||
import { CopyButton } from "./components/CopyButton";
|
||||
import { CommandPalette, type PaletteItem } from "./components/CommandPalette";
|
||||
import { parseTodos } from "./lib/tools";
|
||||
import { shouldShowTodoPanel } from "./lib/todoVisibility";
|
||||
import type { ComposerInsertRequest, Meta, Mode, SessionMeta, SettingsTab, TabMeta } from "./lib/types";
|
||||
@@ -55,6 +57,7 @@ import {
|
||||
themeForStyle,
|
||||
type Theme,
|
||||
} from "./lib/theme";
|
||||
import { applyTextSize, DEFAULT_TEXT_SIZE, getTextSize, nextTextSize } from "./lib/textSize";
|
||||
import { useWindowStatePersistence } from "./lib/windowState";
|
||||
|
||||
const SIDEBAR_COLLAPSED_KEY = "reasonix.sidebar.collapsed";
|
||||
@@ -337,6 +340,26 @@ function ShellHotkeys() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Global hotkey handler for text-size shortcuts (Ctrl/Cmd + Plus/Minus/0). */
|
||||
function TextSizeHotkeys() {
|
||||
useEffect(() => {
|
||||
const onKey = (e: globalThis.KeyboardEvent) => {
|
||||
if (!(e.ctrlKey || e.metaKey)) return;
|
||||
if (e.key !== "+" && e.key !== "=" && e.key !== "-" && e.key !== "0") return;
|
||||
|
||||
e.preventDefault();
|
||||
if (e.key === "0") {
|
||||
applyTextSize(DEFAULT_TEXT_SIZE);
|
||||
return;
|
||||
}
|
||||
applyTextSize(nextTextSize(getTextSize(), e.key === "-" ? -1 : 1));
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const {
|
||||
state,
|
||||
@@ -382,6 +405,7 @@ export default function App() {
|
||||
const [needsOnboarding, setNeedsOnboarding] = useState<boolean | null>(null);
|
||||
const [settingsTarget, setSettingsTarget] = useState<SettingsTab | null>(null);
|
||||
const [histView, setHistView] = useState<HistoryViewState | null>(null);
|
||||
const { showToast } = useToast();
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(loadSidebarCollapsed);
|
||||
const [sidebarWidth, setSidebarWidth] = useState(loadSidebarWidth);
|
||||
const [sidebarResizing, setSidebarResizing] = useState(false);
|
||||
@@ -399,6 +423,7 @@ export default function App() {
|
||||
const [renamingTopicId, setRenamingTopicId] = useState<string | null>(null);
|
||||
const [topicTitleDraft, setTopicTitleDraft] = useState("");
|
||||
const [topicExportOpen, setTopicExportOpen] = useState(false);
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
const topicRenameSkipCommitRef = useRef(false);
|
||||
const topicRenameCommitHandledRef = useRef(false);
|
||||
|
||||
@@ -611,39 +636,6 @@ export default function App() {
|
||||
const todos = useMemo(() => (todoItem ? parseTodos(todoItem.args) : []), [todoItem]);
|
||||
const [dismissedTodo, setDismissedTodo] = useState<string | null>(null);
|
||||
const showTodos = shouldShowTodoPanel(todoItem?.id, dismissedTodo, todos);
|
||||
const [todoNow, setTodoNow] = useState(() => Date.now());
|
||||
const todoSeenRef = useRef<{ id: string; at: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!todoItem) {
|
||||
todoSeenRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (todoSeenRef.current?.id !== todoItem.id) {
|
||||
todoSeenRef.current = { id: todoItem.id, at: Date.now() };
|
||||
setTodoNow(Date.now());
|
||||
}
|
||||
}, [todoItem]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showTodos) return;
|
||||
const id = window.setInterval(() => setTodoNow(Date.now()), 15000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [showTodos]);
|
||||
|
||||
const todoStale = useMemo(() => {
|
||||
if (!showTodos || !todoEntry) return false;
|
||||
const after = state.items.slice(todoEntry.index + 1);
|
||||
const completedToolsAfter = after.filter(
|
||||
(it) => it.kind === "tool" && it.name !== "todo_write" && !it.parentId && (it.status === "done" || it.status === "error"),
|
||||
).length;
|
||||
const finalAssistantAfter = after.some((it) => it.kind === "assistant" && !it.streaming && it.text.trim() !== "");
|
||||
const readinessNoticeAfter = after.some(
|
||||
(it) => it.kind === "notice" && /final-answer readiness|todo_write|complete_step/i.test(it.text),
|
||||
);
|
||||
const staleByTime = state.running && todoSeenRef.current?.id === todoEntry.item.id && todoNow - todoSeenRef.current.at > 90_000;
|
||||
return completedToolsAfter >= 2 || finalAssistantAfter || readinessNoticeAfter || staleByTime;
|
||||
}, [showTodos, state.items, state.running, todoEntry, todoNow]);
|
||||
|
||||
// useDeferredValue lets React prioritise Composer input (high-priority) over
|
||||
// Transcript re-renders (low-priority) during streaming. When a keystroke
|
||||
@@ -1080,6 +1072,17 @@ export default function App() {
|
||||
setTabRevealSignal((signal) => signal + 1);
|
||||
}, [activeTab?.scope, activeTab?.workspaceRoot, openGlobalTab, openProjectTab, refreshTabMetas, state.meta?.cwd]);
|
||||
|
||||
// ── Command palette (⌘K) ────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||
if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey || event.key.toLowerCase() !== "k") return;
|
||||
event.preventDefault();
|
||||
setPaletteOpen((open) => !open);
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown, { capture: true });
|
||||
return () => window.removeEventListener("keydown", onKeyDown, { capture: true });
|
||||
}, []);
|
||||
|
||||
const handleMessageAction = useCallback(async (turn: number, scope: string) => {
|
||||
await rewind(turn, scope);
|
||||
if (scope === "fork") {
|
||||
@@ -1117,24 +1120,85 @@ export default function App() {
|
||||
setHistView({ kind: "trash", sessions: await listTrashedSessions() });
|
||||
}, [listTrashedSessions]);
|
||||
const closeHistory = useCallback(() => setHistView(null), []);
|
||||
|
||||
// ── Command palette (⌘K) item builder ───────────────────────────────
|
||||
const buildPaletteItems = useCallback((): PaletteItem[] => {
|
||||
const items: PaletteItem[] = [];
|
||||
|
||||
for (const tab of tabMetas) {
|
||||
items.push({
|
||||
id: `tab:${tab.id}`,
|
||||
title: tab.topicTitle || "Untitled",
|
||||
hint: tab.scope === "global" ? "Global" : tab.workspaceName || tab.workspaceRoot,
|
||||
group: t("sidebar.conversations"),
|
||||
keywords: [tab.label],
|
||||
run: () => void handleTabChange(tab.id),
|
||||
});
|
||||
}
|
||||
|
||||
items.push(
|
||||
{
|
||||
id: "action:new-session",
|
||||
title: t("topbar.newSession"),
|
||||
group: "Actions",
|
||||
keywords: ["new", "chat", "session"],
|
||||
run: () => void handleNewTab(),
|
||||
},
|
||||
{
|
||||
id: "action:history",
|
||||
title: t("sidebar.allHistory"),
|
||||
group: "Actions",
|
||||
keywords: ["history", "sessions", "past"],
|
||||
run: () => void openAllHistory(),
|
||||
},
|
||||
{
|
||||
id: "action:settings",
|
||||
title: t("topbar.settings"),
|
||||
group: "Actions",
|
||||
keywords: ["preferences", "config", "options"],
|
||||
run: () => setSettingsTarget("general"),
|
||||
},
|
||||
{
|
||||
id: "action:trash",
|
||||
title: t("sidebar.trash"),
|
||||
group: "Actions",
|
||||
keywords: ["deleted", "bin"],
|
||||
run: () => void openTrash(),
|
||||
},
|
||||
);
|
||||
|
||||
return items;
|
||||
}, [tabMetas, handleTabChange, handleNewTab, openAllHistory, openTrash, t]);
|
||||
const onResumeSession = useCallback(
|
||||
async (session: SessionMeta) => {
|
||||
if (state.running) return;
|
||||
setHistView(null);
|
||||
const scope = session.scope || (session.workspaceRoot ? "project" : "global");
|
||||
let targetTab: TabMeta | undefined;
|
||||
if (scope === "project" && session.workspaceRoot && session.topicId) {
|
||||
targetTab = await openProjectTab(session.workspaceRoot, session.topicId);
|
||||
} else if (scope === "global" && session.topicId) {
|
||||
targetTab = await openGlobalTab(session.topicId);
|
||||
}
|
||||
await resumeSession(session.path, targetTab?.id);
|
||||
if (targetTab) {
|
||||
try {
|
||||
let targetTab: TabMeta;
|
||||
if (scope === "project" && session.workspaceRoot && session.topicId) {
|
||||
targetTab = await openProjectTab(session.workspaceRoot, session.topicId);
|
||||
} else if (scope === "global" && session.topicId) {
|
||||
targetTab = await openGlobalTab(session.topicId);
|
||||
} else {
|
||||
throw new Error(scope === "global" && !session.topicId
|
||||
? t("history.failedOpenSession")
|
||||
: (session.topicId ? "Missing workspaceRoot" : t("history.failedOpenSession")));
|
||||
}
|
||||
setHistView(null);
|
||||
await resumeSession(session.path, targetTab.id);
|
||||
await refreshTabMetas();
|
||||
setTabRevealSignal((signal) => signal + 1);
|
||||
} catch (err: any) {
|
||||
setHistView(null);
|
||||
if (scope === "project" && session.workspaceRoot) {
|
||||
const name = workspaceDisplayName(session.workspaceRoot);
|
||||
showToast(t("history.failedOpenProject", { name, path: session.workspaceRoot }));
|
||||
} else {
|
||||
showToast(err?.message || String(err));
|
||||
}
|
||||
}
|
||||
},
|
||||
[openGlobalTab, openProjectTab, refreshTabMetas, state.running, resumeSession],
|
||||
[openGlobalTab, openProjectTab, refreshTabMetas, state.running, resumeSession, t, showToast],
|
||||
);
|
||||
// Delete / rename act on disk, then re-fetch so the panel reflects the change.
|
||||
const onDeleteSession = useCallback(
|
||||
@@ -1278,6 +1342,7 @@ export default function App() {
|
||||
return (
|
||||
<ShellExpandProvider>
|
||||
<ShellHotkeys />
|
||||
<TextSizeHotkeys />
|
||||
<div className={`app app--${desktopPlatform}`}>
|
||||
<div
|
||||
ref={layoutRef}
|
||||
@@ -1310,7 +1375,7 @@ export default function App() {
|
||||
{sidebarCollapsed ? <PanelLeftOpen size={16} /> : <PanelLeftClose size={16} />}
|
||||
</button>
|
||||
<div className="app-chrome__identity" aria-label="Reasonix">
|
||||
<img src={logoWordmark} alt="" className="app-chrome__logo" />
|
||||
<img src={logoWordmark} alt="" className="app-chrome__logo" draggable={false} />
|
||||
<span className="app-chrome__separator">/</span>
|
||||
<span className="app-chrome__scope">{appChromeScopeLabel(activeTab, state.meta)}</span>
|
||||
</div>
|
||||
@@ -1533,15 +1598,15 @@ export default function App() {
|
||||
</main>
|
||||
|
||||
<footer className="footer" ref={footerRef}>
|
||||
{showTodos && <TodoPanel todos={todos} stale={todoStale} onDismiss={() => setDismissedTodo(todoItem!.id)} />}
|
||||
{showTodos && <TodoPanel todos={todos} onDismiss={() => setDismissedTodo(todoItem!.id)} />}
|
||||
{state.approval && (
|
||||
<ApprovalModal
|
||||
approval={state.approval}
|
||||
onAnswer={(allow, session, persist) => {
|
||||
onAnswer={(allow, session, persist, scope) => {
|
||||
// Approving an exit_plan_mode plan leaves plan mode; sync the
|
||||
// tab-local indicator and persisted safe mode immediately.
|
||||
if (state.approval!.tool === "exit_plan_mode" && allow) applyMode("normal");
|
||||
approve(state.approval!.id, allow, session, persist);
|
||||
approve(state.approval!.id, allow, session, persist, scope);
|
||||
}}
|
||||
onRevisePlan={(text) => {
|
||||
setPendingPlanRevision(text);
|
||||
@@ -1719,6 +1784,14 @@ export default function App() {
|
||||
)}
|
||||
|
||||
{needsOnboarding && <OnboardingOverlay onComplete={() => setNeedsOnboarding(false)} />}
|
||||
|
||||
<CommandPalette
|
||||
open={paletteOpen}
|
||||
onClose={() => setPaletteOpen(false)}
|
||||
items={buildPaletteItems()}
|
||||
placeholder="Quick actions…"
|
||||
emptyText="No matches"
|
||||
/>
|
||||
</div>
|
||||
</ShellExpandProvider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// Run: tsx src/__tests__/provider-model-refresh.test.ts
|
||||
|
||||
import { mergedFetchedProviderModels, providerDefaultModel } from "../lib/providerModels";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function eq(a: unknown, b: unknown, label: string) {
|
||||
if (JSON.stringify(a) === JSON.stringify(b)) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\nprovider model refresh");
|
||||
|
||||
eq(
|
||||
mergedFetchedProviderModels(["coding-pro"], ["coding-pro", "chat", "vision"]),
|
||||
["coding-pro", "chat", "vision"],
|
||||
"appends discovered models without removing curated ones",
|
||||
);
|
||||
|
||||
eq(
|
||||
mergedFetchedProviderModels(["coding-pro"], ["coding-pro", "chat", "vision"], { preserveCurated: true }),
|
||||
["coding-pro"],
|
||||
"background refresh preserves manually curated model list",
|
||||
);
|
||||
|
||||
eq(
|
||||
mergedFetchedProviderModels(["coding-pro"], ["chat", "vision"], { preserveCurated: true }),
|
||||
["coding-pro"],
|
||||
"background refresh does not re-add deleted models",
|
||||
);
|
||||
|
||||
eq(
|
||||
mergedFetchedProviderModels([], ["coding-pro", "chat"], { preserveCurated: true }),
|
||||
["coding-pro", "chat"],
|
||||
"background refresh can populate an empty model list",
|
||||
);
|
||||
|
||||
eq(
|
||||
providerDefaultModel("coding-pro", ["coding-pro", "chat"]),
|
||||
"coding-pro",
|
||||
"preserves current default when it remains available",
|
||||
);
|
||||
|
||||
eq(
|
||||
providerDefaultModel("deleted", ["coding-pro", "chat"]),
|
||||
"coding-pro",
|
||||
"falls back to first saved model when default is unavailable",
|
||||
);
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,29 @@
|
||||
// Run: tsx src/__tests__/text-size.test.ts
|
||||
|
||||
import { DEFAULT_TEXT_SIZE, nextTextSize } from "../lib/textSize";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function eq(a: unknown, b: unknown, label: string) {
|
||||
if (a === b) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\ntext size shortcuts");
|
||||
eq(nextTextSize("small", 1), DEFAULT_TEXT_SIZE, "increase from small");
|
||||
eq(nextTextSize("default", 1), "large", "increase from default");
|
||||
eq(nextTextSize("large", 1), "xlarge", "increase from large");
|
||||
eq(nextTextSize("xlarge", 1), "xlarge", "increase clamps at largest");
|
||||
eq(nextTextSize("xlarge", -1), "large", "decrease from xlarge");
|
||||
eq(nextTextSize("large", -1), DEFAULT_TEXT_SIZE, "decrease from large");
|
||||
eq(nextTextSize("default", -1), "small", "decrease from default");
|
||||
eq(nextTextSize("small", -1), "small", "decrease clamps at smallest");
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -73,26 +73,19 @@ export function AnchoredPopover({
|
||||
if (!anchor || !menu) return;
|
||||
const next = calculatePosition(anchor, menu, align, offset, placement);
|
||||
setPosition((current) => (samePosition(current, next) ? current : next));
|
||||
});
|
||||
}, [open, align, offset, placement]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
const closeOnScroll = (event: Event) => {
|
||||
const target = event.target;
|
||||
if (target instanceof Node && popoverRef.current?.contains(target)) return;
|
||||
onClose();
|
||||
};
|
||||
const closeOnViewportChange = () => onClose();
|
||||
window.addEventListener("keydown", closeOnEscape);
|
||||
window.addEventListener("resize", closeOnViewportChange);
|
||||
window.addEventListener("scroll", closeOnScroll, true);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", closeOnEscape);
|
||||
window.removeEventListener("resize", closeOnViewportChange);
|
||||
window.removeEventListener("scroll", closeOnScroll, true);
|
||||
};
|
||||
}, [onClose, open]);
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export function ApprovalModal({
|
||||
onExitPlan,
|
||||
}: {
|
||||
approval: WireApproval;
|
||||
onAnswer: (allow: boolean, session: boolean, persist: boolean) => void;
|
||||
onAnswer: (allow: boolean, session: boolean, persist: boolean, scope?: string) => void;
|
||||
onRevisePlan?: (text: string) => void;
|
||||
onExitPlan?: () => void;
|
||||
}) {
|
||||
@@ -21,8 +21,14 @@ export function ApprovalModal({
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const isPlanApproval = approval.tool === "exit_plan_mode";
|
||||
const isBashApproval = approval.tool === "bash";
|
||||
const subject = approval.subject.trim();
|
||||
const bashPrefix = isBashApproval ? bashCommandPrefix(subject) : "";
|
||||
const hasBashPrefix = bashPrefix !== "";
|
||||
const subjectSummary = subject.split("\n").find((line) => line.trim())?.trim() ?? "";
|
||||
const exactSessionRule = approvalSessionRule(approval.tool, subject);
|
||||
const exactPersistentRule = approvalPersistentRule(approval.tool, subject);
|
||||
const prefixRule = hasBashPrefix ? `Bash(${bashPrefix})` : "";
|
||||
|
||||
const choosePlanAction = (key: string) => {
|
||||
if (key === "1") setRevisionOpen((open) => !open);
|
||||
@@ -32,7 +38,9 @@ export function ApprovalModal({
|
||||
|
||||
const chooseToolAction = (key: string) => {
|
||||
if (key === "1") onAnswer(true, false, false);
|
||||
else if (key === "2" && hasBashPrefix) onAnswer(true, true, false, "prefix");
|
||||
else if (key === "2") onAnswer(true, true, false);
|
||||
else if (key === "3" && hasBashPrefix) onAnswer(true, true, true, "prefix");
|
||||
else if (key === "3") onAnswer(true, true, true);
|
||||
else if (key === "4" || key === "Escape") onAnswer(false, false, false);
|
||||
};
|
||||
@@ -56,7 +64,7 @@ export function ApprovalModal({
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [isPlanApproval, onAnswer, onExitPlan]);
|
||||
}, [hasBashPrefix, isPlanApproval, onAnswer, onExitPlan]);
|
||||
|
||||
useEffect(() => {
|
||||
if (revisionOpen) inputRef.current?.focus();
|
||||
@@ -142,9 +150,35 @@ export function ApprovalModal({
|
||||
/>
|
||||
)}
|
||||
<PromptAction keyLabel="1" label={t("approval.allowOnce")} onClick={() => onAnswer(true, false, false)} selected />
|
||||
<PromptAction keyLabel="2" label={t("approval.allowSession")} onClick={() => onAnswer(true, true, false)} />
|
||||
<PromptAction keyLabel="3" label={t("approval.allowPersistent")} onClick={() => onAnswer(true, true, true)} />
|
||||
<PromptAction keyLabel="4" label={t("approval.deny")} onClick={() => onAnswer(false, false, false)} />
|
||||
{hasBashPrefix ? (
|
||||
<>
|
||||
<PromptAction
|
||||
keyLabel="2"
|
||||
label={<RuleActionLabel label={t("approval.allowRuleSession")} rule={prefixRule} />}
|
||||
onClick={() => onAnswer(true, true, false, "prefix")}
|
||||
/>
|
||||
<PromptAction
|
||||
keyLabel="3"
|
||||
label={<RuleActionLabel label={t("approval.allowRulePersistent")} rule={prefixRule} />}
|
||||
onClick={() => onAnswer(true, true, true, "prefix")}
|
||||
/>
|
||||
<PromptAction keyLabel="4" label={t("approval.deny")} onClick={() => onAnswer(false, false, false)} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PromptAction
|
||||
keyLabel="2"
|
||||
label={<RuleActionLabel label={t("approval.allowRuleSession")} rule={exactSessionRule} />}
|
||||
onClick={() => onAnswer(true, true, false)}
|
||||
/>
|
||||
<PromptAction
|
||||
keyLabel="3"
|
||||
label={<RuleActionLabel label={t("approval.allowRulePersistent")} rule={exactPersistentRule} />}
|
||||
onClick={() => onAnswer(true, true, true)}
|
||||
/>
|
||||
<PromptAction keyLabel="4" label={t("approval.deny")} onClick={() => onAnswer(false, false, false)} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -154,3 +188,60 @@ export function ApprovalModal({
|
||||
</PromptShelf>
|
||||
);
|
||||
}
|
||||
|
||||
function RuleActionLabel({ label, rule }: { label: string; rule: string }) {
|
||||
return (
|
||||
<span className="approval-rule-label">
|
||||
<span>{label}</span>
|
||||
<code>{rule}</code>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function approvalSessionRule(tool: string, subject: string): string {
|
||||
if (tool === "bash" && subject) return `Bash(${subject})`;
|
||||
if (isFileMutationTool(tool)) return "Edit";
|
||||
return tool;
|
||||
}
|
||||
|
||||
function approvalPersistentRule(tool: string, subject: string): string {
|
||||
if (tool === "bash" && subject) return `Bash(${subject})`;
|
||||
if (isFileMutationTool(tool)) return subject ? `Edit(${subject})` : "Edit";
|
||||
return tool;
|
||||
}
|
||||
|
||||
function bashCommandPrefix(subject: string): string {
|
||||
const command = subject.trim();
|
||||
if (!command || command.includes("`") || command.includes("$(") || /[;|&<>\n]/.test(command)) return "";
|
||||
const fields = command.split(/\s+/).filter(Boolean);
|
||||
if (fields.length < 2) return "";
|
||||
if (dangerousBashCommand(command)) return "";
|
||||
const base = fields[0].toLowerCase();
|
||||
if ((base === "npm" || base === "pnpm" || base === "yarn" || base === "bun") && fields[1]?.toLowerCase() === "run") {
|
||||
return fields.length >= 3 ? `${fields[0]} ${fields[1]} ${fields[2]}:*` : "";
|
||||
}
|
||||
return `${fields[0]} ${fields[1]}:*`;
|
||||
}
|
||||
|
||||
function dangerousBashCommand(command: string): boolean {
|
||||
return /^rm\s+-[^\s]*[rf][^\s]*\b/.test(command)
|
||||
|| /^git\s+push\b.*\s--force\b/.test(command)
|
||||
|| /^git\s+push\b.*\s-f\b/.test(command)
|
||||
|| /^git\s+reset\s+--hard\b/.test(command)
|
||||
|| /^git\s+clean\s+-f\b/.test(command)
|
||||
|| /^chmod\s+(?:-R\s+)?777\b/.test(command)
|
||||
|| /^chown\b/.test(command)
|
||||
|| /^sudo\b/.test(command)
|
||||
|| /^mkfs\b/.test(command)
|
||||
|| /^dd\s+if=/.test(command)
|
||||
|| /^fdisk\b/.test(command);
|
||||
}
|
||||
|
||||
function isFileMutationTool(tool: string): boolean {
|
||||
return tool === "write_file"
|
||||
|| tool === "edit_file"
|
||||
|| tool === "multi_edit"
|
||||
|| tool === "notebook_edit"
|
||||
|| tool === "delete_range"
|
||||
|| tool === "delete_symbol";
|
||||
}
|
||||
@@ -1219,7 +1219,11 @@ function SkillRow({
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="cap-skill-card__desc">{expanded ? skill.description : summary}</div>
|
||||
{canExpand && <div className="cap-skill-card__more">{expanded ? t("common.collapse") : t("common.expand")}</div>}
|
||||
{canExpand && (
|
||||
<button className="cap-skill-card__more" type="button" onClick={onToggle} aria-expanded={expanded}>
|
||||
{expanded ? t("common.collapse") : t("common.expand")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import type { CSSProperties, ClipboardEvent, DragEvent, KeyboardEvent, PointerEvent as ReactPointerEvent, ReactNode } from "react";
|
||||
import { AlertTriangle, ArrowUp, Check, ChevronDown, Eye, FileText, Folder, FolderGit2, FolderPlus, List, Search, Square, Trash2, X, Zap } from "lucide-react";
|
||||
import { AlertTriangle, ArrowUp, Check, ChevronDown, Eye, FileText, Folder, FolderGit2, FolderPlus, List, MessageSquare, Search, Square, Trash2, X, Zap } from "lucide-react";
|
||||
import { asArray } from "../lib/array";
|
||||
import { DedupIndex, sha256 } from "../lib/attachDedup";
|
||||
import { app, onFilesDropped } from "../lib/bridge";
|
||||
import { SPINNER_WORDS, useI18n } from "../lib/i18n";
|
||||
import { clearLayoutSize, loadOptionalLayoutSize, saveLayoutSize } from "../lib/layoutPreferences";
|
||||
import type { CommandInfo, ComposerInsertRequest, DirEntry, EffortInfo, Mode, SlashArgItem, SlashArgsResult, WorkspaceView } from "../lib/types";
|
||||
import type { CommandInfo, ComposerInsertRequest, DirEntry, EffortInfo, HistoryMessage, Mode, SessionMeta, SessionReference, SlashArgItem, SlashArgsResult, WorkspaceView } from "../lib/types";
|
||||
import {
|
||||
formatWorkspaceReference,
|
||||
parseWorkspaceReference,
|
||||
@@ -14,7 +15,7 @@ import {
|
||||
} from "../lib/workspaceDrag";
|
||||
import { SlashMenu } from "./SlashMenu";
|
||||
import { ArgMenu } from "./ArgMenu";
|
||||
import { FileMenu } from "./FileMenu";
|
||||
import { VirtualMenu } from "./VirtualMenu";
|
||||
import { EffortSwitcher } from "./EffortSwitcher";
|
||||
import { ModelSwitcher } from "./ModelSwitcher";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
@@ -25,6 +26,11 @@ interface Attachment {
|
||||
previewUrl?: string;
|
||||
}
|
||||
|
||||
interface AttachmentDedupKey {
|
||||
hash: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
interface WorkspaceReference {
|
||||
path: string;
|
||||
isDir?: boolean;
|
||||
@@ -100,6 +106,34 @@ function fmtElapsed(ms: number): string {
|
||||
return `${Math.floor(s / 60)}m ${s % 60}s`;
|
||||
}
|
||||
|
||||
// --- past:chats hover preview helpers (PR-C2) ---
|
||||
// Pure formatting helpers used by the past:chats list tooltip. They never read
|
||||
// from disk, never call PreviewSession — they only shape the data that already
|
||||
// lives in the SessionMeta snapshot we fetched on entry.
|
||||
const PAST_CHAT_PREVIEW_MAX = 200;
|
||||
|
||||
function truncatePreview(value?: string, max = PAST_CHAT_PREVIEW_MAX): string {
|
||||
const text = (value || "").trim();
|
||||
if (text.length <= max) return text;
|
||||
return `${text.slice(0, max)}...`;
|
||||
}
|
||||
|
||||
function fmtSessionTime(value?: number): string {
|
||||
if (!value) return "";
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
const yyyy = d.getFullYear();
|
||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
const hh = String(d.getHours()).padStart(2, "0");
|
||||
const mi = String(d.getMinutes()).padStart(2, "0");
|
||||
return `${yyyy}-${mm}-${dd} ${hh}:${mi}`;
|
||||
}
|
||||
|
||||
function pastChatTitle(session: SessionMeta): string {
|
||||
return session.title || session.topicTitle || session.preview || "Untitled";
|
||||
}
|
||||
|
||||
function useTick(on: boolean): number {
|
||||
const [, setN] = useState(0);
|
||||
useEffect(() => {
|
||||
@@ -127,6 +161,91 @@ function isImeKeyEvent(
|
||||
);
|
||||
}
|
||||
|
||||
// --- past:chats session reference → prompt context (PR-B) ---
|
||||
// Send-side helpers for "@past:chats" session references. PR-A wired the menu and
|
||||
// the composer-context card; this layer reads each referenced session through the
|
||||
// existing PreviewSession API and prepends a compact "user / 助手" transcript to
|
||||
// submitText so the model sees the referenced chat as background context.
|
||||
const SESSION_REF_MAX_MESSAGES = 30;
|
||||
const SESSION_REF_MAX_CHARS = 20_000;
|
||||
const SESSION_CONTEXT_HEADER = "以下是用户引用的历史会话上下文:";
|
||||
const SESSION_CONTEXT_FOOTER = "当前用户问题:";
|
||||
const PAST_CHATS_MENU_ITEM = "past:chats";
|
||||
|
||||
// limitSessionMessages keeps the most recent useful messages within a char budget.
|
||||
// Walks from the end so the truncation is always "drop the oldest", which matches
|
||||
// the intuition that the latest turns are the relevant ones for follow-up.
|
||||
function limitSessionMessages(
|
||||
messages: HistoryMessage[],
|
||||
maxMessages = SESSION_REF_MAX_MESSAGES,
|
||||
maxChars = SESSION_REF_MAX_CHARS,
|
||||
): { messages: HistoryMessage[]; truncated: boolean } {
|
||||
const useful = messages
|
||||
.filter(
|
||||
(m) =>
|
||||
(m.role === "user" || m.role === "assistant") &&
|
||||
typeof m.content === "string" &&
|
||||
m.content.trim().length > 0,
|
||||
)
|
||||
.slice(-maxMessages);
|
||||
const result: HistoryMessage[] = [];
|
||||
let total = 0;
|
||||
let truncated = useful.length >= maxMessages;
|
||||
for (let i = useful.length - 1; i >= 0; i--) {
|
||||
const msg = useful[i];
|
||||
const content = msg.content.trim();
|
||||
if (total + content.length > maxChars) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
result.unshift({ ...msg, content });
|
||||
total += content.length;
|
||||
}
|
||||
if (result.length < useful.length) truncated = true;
|
||||
return { messages: result, truncated };
|
||||
}
|
||||
|
||||
// formatSessionContext renders one referenced session as a labelled transcript.
|
||||
// Falls back to a "no usable messages" note when filtering empties the list so
|
||||
// the model still sees that something was referenced.
|
||||
function formatSessionContext(
|
||||
ref: SessionReference,
|
||||
messages: HistoryMessage[],
|
||||
truncated: boolean,
|
||||
): string {
|
||||
const body = messages
|
||||
.map((m) => `${m.role === "user" ? "用户" : "助手"}:${m.content.trim()}`)
|
||||
.join("\n\n");
|
||||
return [
|
||||
`[会话:${ref.title}]`,
|
||||
truncated ? "注意:该会话内容较长,以下只包含最近部分内容。" : "",
|
||||
body || "注意:该会话没有可引用的用户/助手消息。",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
// buildSessionContext reads each referenced session, formats the most recent
|
||||
// slice, and joins them with a separator. A single failed read must not block
|
||||
// the others; the user gets a clear "读取失败" note for the bad one and the
|
||||
// remaining refs still flow through.
|
||||
async function buildSessionContext(refs: SessionReference[]): Promise<string> {
|
||||
if (refs.length === 0) return "";
|
||||
let context = `${SESSION_CONTEXT_HEADER}\n\n`;
|
||||
for (const ref of refs) {
|
||||
try {
|
||||
const raw = await app.PreviewSession(ref.path);
|
||||
const limited = limitSessionMessages(asArray(raw));
|
||||
context += `${formatSessionContext(ref, limited.messages, limited.truncated)}\n\n---\n\n`;
|
||||
} catch (error) {
|
||||
console.error("[past:chats] failed to preview session", ref.path, error);
|
||||
context += `[会话:${ref.title}]\n注意:该会话读取失败,已跳过。\n\n---\n\n`;
|
||||
}
|
||||
}
|
||||
context += `${SESSION_CONTEXT_FOOTER}\n`;
|
||||
return context;
|
||||
}
|
||||
|
||||
export function Composer({
|
||||
running,
|
||||
mode,
|
||||
@@ -207,6 +326,12 @@ export function Composer({
|
||||
const [composerResizing, setComposerResizing] = useState(false);
|
||||
const [textareaAutoHeight, setTextareaAutoHeight] = useState<number | null>(null);
|
||||
const [textareaAutoOverflow, setTextareaAutoOverflow] = useState(false);
|
||||
const [showPastChats, setShowPastChats] = useState(false);
|
||||
const [pastChats, setPastChats] = useState<SessionMeta[]>([]);
|
||||
const [pastChatQuery, setPastChatQuery] = useState("");
|
||||
const [sessionRefs, setSessionRefs] = useState<SessionReference[]>([]);
|
||||
const [loadingPastChats, setLoadingPastChats] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const taRef = useRef<HTMLTextAreaElement>(null);
|
||||
const composerCardRef = useRef<HTMLDivElement>(null);
|
||||
const workspaceAnchorRef = useRef<HTMLDivElement>(null);
|
||||
@@ -215,6 +340,13 @@ export function Composer({
|
||||
const lastCompositionEndAt = useRef(0);
|
||||
const lastSelectionRef = useRef({ start: 0, end: 0 });
|
||||
const consumedInsertIdRef = useRef(0);
|
||||
const submittingRef = useRef(false);
|
||||
// Snapshot of the current cwd so async callbacks (openPastChats) can detect
|
||||
// workspace switches and discard stale responses (issue #3601).
|
||||
const cwdRef = useRef(cwd);
|
||||
cwdRef.current = cwd;
|
||||
const attachmentDedupRef = useRef(new DedupIndex());
|
||||
const attachmentDedupKeysRef = useRef<Record<string, AttachmentDedupKey>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (wasRunning.current && !running && text.trim() === "") {
|
||||
@@ -236,7 +368,7 @@ export function Composer({
|
||||
return text.slice(1).toLowerCase();
|
||||
}, [text]);
|
||||
const slashMatches = useMemo(
|
||||
() => (slashQuery === null ? [] : commands.filter((c) => c.name.toLowerCase().includes(slashQuery)).slice(0, 8)),
|
||||
() => (slashQuery === null ? [] : commands.filter((c) => c.name.toLowerCase().includes(slashQuery))),
|
||||
[slashQuery, commands],
|
||||
);
|
||||
|
||||
@@ -303,6 +435,27 @@ export function Composer({
|
||||
const [searchEntries, setSearchEntries] = useState<DirEntry[]>([]);
|
||||
const dirCache = useRef<Record<string, DirEntry[]>>({});
|
||||
const searchCache = useRef<Record<string, DirEntry[]>>({});
|
||||
|
||||
// When the workspace/project changes (cwd prop), invalidate all @ mention
|
||||
// state so the picker reloads candidates for the new project. Without this,
|
||||
// dirCache/searchCache retain entries from the old project and the picker
|
||||
// shows stale results (issue #3601).
|
||||
const prevCwdRef = useRef(cwd);
|
||||
useEffect(() => {
|
||||
if (prevCwdRef.current === cwd) return; // skip mount — state already initial
|
||||
prevCwdRef.current = cwd;
|
||||
dirCache.current = {};
|
||||
searchCache.current = {};
|
||||
setEntries([]);
|
||||
setSearchEntries([]);
|
||||
setShowPastChats(false);
|
||||
setPastChats([]);
|
||||
setPastChatQuery("");
|
||||
setLoadingPastChats(false);
|
||||
setActive(0);
|
||||
setDismissed(false);
|
||||
}, [cwd]);
|
||||
|
||||
useEffect(() => {
|
||||
if (atRaw === null) return;
|
||||
const cached = dirCache.current[atDir];
|
||||
@@ -322,8 +475,8 @@ export function Composer({
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// re-fetch only when the menu opens or the directory level changes
|
||||
}, [atRaw === null, atDir]);
|
||||
// re-fetch when the menu opens or the directory level changes
|
||||
}, [atRaw === null, atDir, cwd]);
|
||||
useEffect(() => {
|
||||
if (atRaw === null || atDir !== "" || atFrag === "") {
|
||||
setSearchEntries([]);
|
||||
@@ -347,7 +500,7 @@ export function Composer({
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [atRaw === null, atDir, atFrag]);
|
||||
}, [atRaw === null, atDir, atFrag, cwd]);
|
||||
const atMatches = useMemo(
|
||||
() => {
|
||||
if (atRaw === null) return [];
|
||||
@@ -357,11 +510,28 @@ export function Composer({
|
||||
const basename = e.name.split("/").pop()?.toLowerCase() ?? "";
|
||||
return basename.includes(atFrag) && !seen.has(e.name);
|
||||
});
|
||||
return [...local, ...searched].slice(0, 10);
|
||||
return [...local, ...searched];
|
||||
},
|
||||
[atRaw, atFrag, entries, searchEntries],
|
||||
);
|
||||
|
||||
// Unified menu item model for the @ menu. "past:chats" is a real selectable
|
||||
// item (kind "pastChats"), not an active===0 special case.
|
||||
type AtMenuItem =
|
||||
| { kind: "pastChats" }
|
||||
| { kind: "file"; entry: DirEntry };
|
||||
|
||||
const includePastChatsItem = atRaw !== null && atDir === "" && (atFrag === "" || PAST_CHATS_MENU_ITEM.startsWith(atFrag));
|
||||
|
||||
const atMenuItems = useMemo<AtMenuItem[]>(
|
||||
() => [
|
||||
...(includePastChatsItem ? [{ kind: "pastChats" as const }] : []),
|
||||
...atMatches.map((entry) => ({ kind: "file" as const, entry })),
|
||||
],
|
||||
[includePastChatsItem, atMatches],
|
||||
);
|
||||
|
||||
|
||||
// --- which menu (if any) is open --- (slash command names win; then slash
|
||||
// arguments; then @-refs — they're rarely valid at once)
|
||||
const menuMode: "slash" | "slasharg" | "at" | null =
|
||||
@@ -369,16 +539,16 @@ export function Composer({
|
||||
? "slash"
|
||||
: argRes && argRes.items.length > 0 && !dismissed
|
||||
? "slasharg"
|
||||
: atMatches.length > 0 && !dismissed
|
||||
: atRaw !== null && !dismissed
|
||||
? "at"
|
||||
: null;
|
||||
const count =
|
||||
const countBase =
|
||||
menuMode === "slash"
|
||||
? slashMatches.length
|
||||
: menuMode === "slasharg"
|
||||
? argRes!.items.length
|
||||
: menuMode === "at"
|
||||
? atMatches.length
|
||||
? atMenuItems.length
|
||||
: 0;
|
||||
|
||||
// Reset highlight + un-dismiss whenever the active query changes.
|
||||
@@ -387,6 +557,17 @@ export function Composer({
|
||||
setDismissed(false);
|
||||
}, [slashQuery, atRaw]);
|
||||
|
||||
// When the @ trigger disappears (user deleted the @), close the past:chats
|
||||
// sub-menu and reset related state. Without this, showPastChats can outlive
|
||||
// the @ token and leave the session list visible with no way to dismiss it.
|
||||
useEffect(() => {
|
||||
if (menuMode !== "at" && showPastChats) {
|
||||
setShowPastChats(false);
|
||||
setPastChatQuery("");
|
||||
setActive(0);
|
||||
}
|
||||
}, [menuMode]);
|
||||
|
||||
const setTextCaretEnd = (next: string) => {
|
||||
setText(next);
|
||||
requestAnimationFrame(() => {
|
||||
@@ -456,20 +637,64 @@ export function Composer({
|
||||
return expanded;
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
if (disabled) return;
|
||||
const rememberAttachment = (path: string, key: AttachmentDedupKey) => {
|
||||
attachmentDedupRef.current.add(key.hash, key.source);
|
||||
attachmentDedupKeysRef.current[path] = key;
|
||||
};
|
||||
|
||||
const forgetAttachment = (path: string) => {
|
||||
const key = attachmentDedupKeysRef.current[path];
|
||||
if (key) {
|
||||
attachmentDedupRef.current.forget(key.hash, key.source);
|
||||
delete attachmentDedupKeysRef.current[path];
|
||||
}
|
||||
};
|
||||
|
||||
const clearAttachments = () => {
|
||||
setAttachments([]);
|
||||
attachmentDedupRef.current.clear();
|
||||
attachmentDedupKeysRef.current = {};
|
||||
};
|
||||
|
||||
const removeAttachment = (path: string) => {
|
||||
forgetAttachment(path);
|
||||
setAttachments((prev) => prev.filter((x) => x.path !== path));
|
||||
requestAnimationFrame(() => taRef.current?.focus());
|
||||
};
|
||||
|
||||
const fileDedupKey = async (file: File): Promise<AttachmentDedupKey> => ({
|
||||
hash: await sha256(file),
|
||||
source: `file:${file.name}:${file.size}:${file.lastModified}`,
|
||||
});
|
||||
|
||||
const submit = async () => {
|
||||
if (disabled || submittingRef.current) return;
|
||||
const t = text.trim();
|
||||
if ((!t && attachments.length === 0 && workspaceRefs.length === 0) || pendingPaste > 0) return;
|
||||
submittingRef.current = true;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const refs = [
|
||||
...workspaceRefs.map((ref) => formatWorkspaceReference(ref.path, ref.isDir)),
|
||||
...attachments.map((a) => `@${a.path}`),
|
||||
].join(" ");
|
||||
const displayText = [t, refs].filter(Boolean).join(t && refs ? " " : "");
|
||||
const submitText = [expandPastedBlocks(t), refs].filter(Boolean).join(t && refs ? " " : "");
|
||||
// PR-B: when past:chats refs are attached, prepend their formatted transcript
|
||||
// to submitText only (displayText stays unchanged so the user still sees their
|
||||
// original prompt in the input preview). With no refs we keep the original
|
||||
// submitText verbatim — no header, no rewording, byte-identical to pre-PR-B.
|
||||
const sessionContext = sessionRefs.length === 0 ? "" : await buildSessionContext(sessionRefs);
|
||||
const baseSubmitText = [expandPastedBlocks(t), refs].filter(Boolean).join(t && refs ? " " : "");
|
||||
const submitText = sessionContext ? `${sessionContext}${baseSubmitText}` : baseSubmitText;
|
||||
onSend(displayText, submitText);
|
||||
setText("");
|
||||
setAttachments([]);
|
||||
clearAttachments();
|
||||
setWorkspaceRefs([]);
|
||||
setSessionRefs([]);
|
||||
} finally {
|
||||
submittingRef.current = false;
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const readFileAsDataURL = (file: File) =>
|
||||
@@ -486,9 +711,12 @@ export function Composer({
|
||||
for (const file of images) {
|
||||
setPendingPaste((n) => n + 1);
|
||||
try {
|
||||
const key = await fileDedupKey(file);
|
||||
if (attachmentDedupRef.current.seen(key.hash, key.source)) continue;
|
||||
const dataUrl = await readFileAsDataURL(file);
|
||||
const path = await app.SavePastedImage(dataUrl);
|
||||
const previewUrl = await app.AttachmentDataURL(path);
|
||||
rememberAttachment(path, key);
|
||||
setAttachments((prev) => [...prev, { path, previewUrl }]);
|
||||
} catch {
|
||||
// non-fatal: a failed image attach must not block normal text input
|
||||
@@ -506,8 +734,11 @@ export function Composer({
|
||||
for (const file of others) {
|
||||
setPendingPaste((n) => n + 1);
|
||||
try {
|
||||
const key = await fileDedupKey(file);
|
||||
if (attachmentDedupRef.current.seen(key.hash, key.source)) continue;
|
||||
const dataUrl = await readFileAsDataURL(file);
|
||||
const path = await app.SavePastedFile(file.name, dataUrl);
|
||||
rememberAttachment(path, key);
|
||||
setAttachments((prev) => [...prev, { path }]);
|
||||
} catch {
|
||||
// non-fatal: a failed attach must not block normal text input
|
||||
@@ -530,10 +761,13 @@ export function Composer({
|
||||
for (const path of paths) {
|
||||
setPendingPaste((n) => n + 1);
|
||||
try {
|
||||
const key = { hash: "", source: `path:${path}` };
|
||||
if (attachmentDedupRef.current.seen(key.hash, key.source)) continue;
|
||||
const item = await app.AttachDropped(path);
|
||||
if (item.kind === "workspace") {
|
||||
addWorkspaceReference({ path: item.path, isDir: item.isDir });
|
||||
} else {
|
||||
rememberAttachment(item.path, key);
|
||||
setAttachments((prev) => [...prev, { path: item.path, previewUrl: item.previewUrl }]);
|
||||
}
|
||||
} catch {
|
||||
@@ -839,6 +1073,107 @@ export function Composer({
|
||||
setTextCaretEnd(prefix + "@" + atDir + e.name + (e.isDir ? "/" : " "));
|
||||
};
|
||||
|
||||
// --- past:chats session reference ---
|
||||
const openPastChats = async () => {
|
||||
const snapshotCwd = cwdRef.current;
|
||||
setShowPastChats(true);
|
||||
setActive(0);
|
||||
setPastChatQuery("");
|
||||
setLoadingPastChats(true);
|
||||
try {
|
||||
const sessions = await app.ListSessions();
|
||||
// Discard stale response if workspace changed while the request was in-flight.
|
||||
if (cwdRef.current !== snapshotCwd) return;
|
||||
const sorted = asArray(sessions)
|
||||
.filter((s) => !s.current)
|
||||
.sort((a, b) => {
|
||||
const at = a.lastActivityAt || a.modTime || a.createdAt || 0;
|
||||
const bt = b.lastActivityAt || b.modTime || b.createdAt || 0;
|
||||
return bt - at;
|
||||
})
|
||||
.slice(0, 50);
|
||||
setPastChats(sorted);
|
||||
} catch {
|
||||
if (cwdRef.current !== snapshotCwd) return;
|
||||
setPastChats([]);
|
||||
} finally {
|
||||
if (cwdRef.current === snapshotCwd) setLoadingPastChats(false);
|
||||
}
|
||||
};
|
||||
|
||||
// PR-C1: client-side filter for the past:chats list. Matches against the
|
||||
// human-visible fields (title, topic, preview, path, workspace) so users
|
||||
// can narrow long session lists without a backend round-trip. Lowercased
|
||||
// substring match keeps the behaviour predictable across locales.
|
||||
const filteredPastChats = useMemo(() => {
|
||||
const q = pastChatQuery.trim().toLowerCase();
|
||||
if (!q) return pastChats;
|
||||
return pastChats.filter((session) =>
|
||||
[
|
||||
session.title,
|
||||
session.topicTitle,
|
||||
session.preview,
|
||||
session.path,
|
||||
session.workspaceRoot,
|
||||
]
|
||||
.map((value) => String(value ?? "").toLowerCase())
|
||||
.some((value) => value.includes(q)),
|
||||
);
|
||||
}, [pastChats, pastChatQuery]);
|
||||
|
||||
// Final menu item count: when the past:chats list is open, count the
|
||||
// filtered sessions instead of file entries + the "past:chats" row.
|
||||
const count = menuMode === "at" && showPastChats
|
||||
? filteredPastChats.length
|
||||
: countBase;
|
||||
|
||||
// Clamp active index when the menu item count changes (e.g. switching
|
||||
// between file list and past:chats list, or filtering sessions).
|
||||
useEffect(() => {
|
||||
const maxIdx = Math.max(0, count - 1);
|
||||
setActive((prev) => (prev > maxIdx ? 0 : prev));
|
||||
}, [count]);
|
||||
|
||||
|
||||
const removeAtToken = (value: string) => {
|
||||
return value.replace(/(?:^|\s)@[^\s]*$/, "").trimEnd();
|
||||
};
|
||||
|
||||
const pickSession = (session: SessionMeta) => {
|
||||
setSessionRefs((prev) => {
|
||||
if (prev.some((x) => x.path === session.path)) {
|
||||
return prev;
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
path: session.path,
|
||||
title: session.title || session.topicTitle || session.preview || "Untitled",
|
||||
preview: session.preview,
|
||||
turns: session.turns,
|
||||
createdAt: session.createdAt,
|
||||
lastActivityAt: session.lastActivityAt,
|
||||
},
|
||||
];
|
||||
});
|
||||
setText((prev) => removeAtToken(prev));
|
||||
setPastChatQuery("");
|
||||
setShowPastChats(false);
|
||||
setActive(0);
|
||||
// Return focus to textarea so the user can keep typing immediately.
|
||||
requestAnimationFrame(() => {
|
||||
taRef.current?.focus();
|
||||
taRef.current?.setSelectionRange(
|
||||
taRef.current.value.length,
|
||||
taRef.current.value.length,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const removeSessionRef = (path: string) => {
|
||||
setSessionRefs((prev) => prev.filter((ref) => ref.path !== path));
|
||||
};
|
||||
|
||||
// pickArg replaces just the current token with the suggestion. A "descend" item
|
||||
// (e.g. "/skill show ") ends with a space, so the effect re-fetches the next
|
||||
// level; a terminal item leaves the menu (next fetch returns nothing).
|
||||
@@ -848,9 +1183,30 @@ export function Composer({
|
||||
};
|
||||
|
||||
const pickActive = () => {
|
||||
if (menuMode === "slash") pickCommand(slashMatches[active]);
|
||||
else if (menuMode === "slasharg" && argRes) pickArg(argRes.items[active]);
|
||||
else if (menuMode === "at") pickEntry(atMatches[active]);
|
||||
if (menuMode === "slash") {
|
||||
const item = slashMatches[active];
|
||||
if (item) pickCommand(item);
|
||||
return;
|
||||
}
|
||||
if (menuMode === "slasharg" && argRes) {
|
||||
const item = argRes.items[active];
|
||||
if (item) pickArg(item);
|
||||
return;
|
||||
}
|
||||
if (menuMode === "at") {
|
||||
if (showPastChats) {
|
||||
const session = filteredPastChats[active];
|
||||
if (session) pickSession(session);
|
||||
return;
|
||||
}
|
||||
const item = atMenuItems[active];
|
||||
if (!item) return;
|
||||
if (item.kind === "pastChats") {
|
||||
void openPastChats();
|
||||
return;
|
||||
}
|
||||
pickEntry(item.entry);
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
@@ -866,12 +1222,12 @@ export function Composer({
|
||||
}
|
||||
|
||||
if (menuMode && !composing) {
|
||||
if (e.key === "ArrowDown") {
|
||||
if (e.key === "ArrowDown" && count > 0) {
|
||||
e.preventDefault();
|
||||
setActive((i) => (i + 1) % count);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
if (e.key === "ArrowUp" && count > 0) {
|
||||
e.preventDefault();
|
||||
setActive((i) => (i - 1 + count) % count);
|
||||
return;
|
||||
@@ -883,7 +1239,13 @@ export function Composer({
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setDismissed(true);
|
||||
if (showPastChats) {
|
||||
setPastChatQuery("");
|
||||
setShowPastChats(false);
|
||||
setActive(0);
|
||||
} else {
|
||||
setDismissed(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -901,6 +1263,29 @@ export function Composer({
|
||||
}
|
||||
};
|
||||
|
||||
// Keydown handler for the past:chats search <input>. The search input is a
|
||||
// sibling of the <textarea>, so keyboard events never reach the textarea's
|
||||
// onKeyDown. We intercept navigation keys here and delegate to the same
|
||||
// menu logic. Regular typing keys (letters, Backspace, etc.) pass through
|
||||
// so the user can type a search query.
|
||||
const onPastChatSearchKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Enter" || e.key === "Tab" || e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.key === "ArrowDown" && count > 0) {
|
||||
setActive((i) => (i + 1) % count);
|
||||
} else if (e.key === "ArrowUp" && count > 0) {
|
||||
setActive((i) => (i - 1 + count) % count);
|
||||
} else if (e.key === "Enter" || e.key === "Tab") {
|
||||
pickActive();
|
||||
} else if (e.key === "Escape") {
|
||||
setPastChatQuery("");
|
||||
setShowPastChats(false);
|
||||
setActive(0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const composerCardStyle = composerHeight === null ? undefined : ({ "--composer-height": `${composerHeight}px` } as CSSProperties);
|
||||
const textareaStyle = composerHeight === null && textareaAutoHeight !== null
|
||||
? ({ height: `${textareaAutoHeight}px`, overflowY: textareaAutoOverflow ? "auto" : "hidden" } as CSSProperties)
|
||||
@@ -1013,7 +1398,139 @@ export function Composer({
|
||||
{menuMode === "slasharg" && argRes && (
|
||||
<ArgMenu items={argRes.items} activeIndex={active} onPick={pickArg} onHover={setActive} />
|
||||
)}
|
||||
{menuMode === "at" && <FileMenu items={atMatches} activeIndex={active} onPick={pickEntry} onHover={setActive} />}
|
||||
{menuMode === "at" && (
|
||||
showPastChats ? (
|
||||
<div className="slashmenu" role="listbox">
|
||||
{loadingPastChats ? (
|
||||
<div className="slashmenu__item slashmenu__item--empty">
|
||||
<span className="slashmenu__name">正在加载历史会话...</span>
|
||||
</div>
|
||||
) : pastChats.length === 0 ? (
|
||||
<div className="slashmenu__item slashmenu__item--empty">
|
||||
<span className="slashmenu__name">暂无历史会话</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="slashmenu__item slashmenu__item--search" onMouseDown={(ev) => ev.preventDefault()}>
|
||||
<Search size={13} className="filemenu__icon" />
|
||||
<input
|
||||
className="slashmenu__search"
|
||||
type="text"
|
||||
placeholder="搜索历史会话…"
|
||||
value={pastChatQuery}
|
||||
autoFocus
|
||||
onChange={(ev) => {
|
||||
setPastChatQuery(ev.target.value);
|
||||
setActive(0);
|
||||
}}
|
||||
onKeyDown={onPastChatSearchKeyDown}
|
||||
/>
|
||||
</div>
|
||||
{filteredPastChats.length === 0 ? (
|
||||
<div className="slashmenu__item slashmenu__item--empty">
|
||||
<span className="slashmenu__name">没有匹配的历史会话</span>
|
||||
</div>
|
||||
) : (
|
||||
filteredPastChats.map((session, i) => {
|
||||
// PR-C2: hover preview uses only the SessionMeta fields we
|
||||
// already have on hand — no extra PreviewSession call, no
|
||||
// backend round-trip, no read of the full transcript.
|
||||
const turns = typeof session.turns === "number";
|
||||
const ts = session.lastActivityAt || session.modTime || session.createdAt;
|
||||
const preview = truncatePreview(session.preview);
|
||||
const pathText = session.workspaceRoot || session.path;
|
||||
const tooltipLabel =
|
||||
turns || ts || preview || pathText ? (
|
||||
<div className="past-chat-hover">
|
||||
<div className="past-chat-hover__title">{pastChatTitle(session)}</div>
|
||||
{preview && <div className="past-chat-hover__preview">{preview}</div>}
|
||||
{(turns || ts) && (
|
||||
<div className="past-chat-hover__meta">
|
||||
{turns && <span>{session.turns} 轮</span>}
|
||||
{ts && <span>· {fmtSessionTime(ts)}</span>}
|
||||
</div>
|
||||
)}
|
||||
{pathText && <div className="past-chat-hover__path">{pathText}</div>}
|
||||
</div>
|
||||
) : null;
|
||||
return (
|
||||
<Tooltip key={session.path} block label={tooltipLabel}>
|
||||
<button
|
||||
className={`slashmenu__item ${i === active ? "slashmenu__item--active" : ""}`}
|
||||
onMouseDown={(ev) => {
|
||||
ev.preventDefault();
|
||||
pickSession(session);
|
||||
}}
|
||||
onMouseMove={() => setActive(i)}
|
||||
>
|
||||
<MessageSquare size={13} className="filemenu__icon" />
|
||||
<span className="slashmenu__name slashmenu__name--file">
|
||||
{pastChatTitle(session)}
|
||||
{turns ? ` (${session.turns} 轮)` : ""}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="slashmenu__item slashmenu__item--back"
|
||||
onMouseDown={(ev) => {
|
||||
ev.preventDefault();
|
||||
setPastChatQuery("");
|
||||
setShowPastChats(false);
|
||||
setActive(0);
|
||||
}}
|
||||
>
|
||||
<span className="slashmenu__name">← 返回文件列表</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<VirtualMenu
|
||||
items={atMenuItems}
|
||||
activeIndex={active}
|
||||
itemKey={(it) => (it.kind === "pastChats" ? "past:chats" : (it.entry.isDir ? "d:" : "f:") + it.entry.name)}
|
||||
renderItem={(it, i) =>
|
||||
it.kind === "pastChats" ? (
|
||||
<button
|
||||
className={`slashmenu__item${i === active ? " slashmenu__item--active" : ""}`}
|
||||
onMouseDown={(ev) => {
|
||||
ev.preventDefault();
|
||||
void openPastChats();
|
||||
}}
|
||||
onMouseMove={() => setActive(i)}
|
||||
>
|
||||
<MessageSquare size={13} className="filemenu__icon" />
|
||||
<span className="slashmenu__name">{PAST_CHATS_MENU_ITEM}</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
role="option"
|
||||
aria-selected={i === active}
|
||||
className={`slashmenu__item ${i === active ? "slashmenu__item--active" : ""}`}
|
||||
onMouseDown={(ev) => {
|
||||
ev.preventDefault();
|
||||
pickEntry(it.entry);
|
||||
}}
|
||||
onMouseMove={() => setActive(i)}
|
||||
>
|
||||
{it.entry.isDir ? (
|
||||
<Folder size={13} className="filemenu__icon filemenu__icon--dir" />
|
||||
) : (
|
||||
<FileText size={13} className="filemenu__icon" />
|
||||
)}
|
||||
<span className="slashmenu__name slashmenu__name--file">
|
||||
{it.entry.name}
|
||||
{it.entry.isDir ? "/" : ""}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div className="composer-toolbar">
|
||||
<div className="composer-modebar" role="toolbar" aria-label={t("composer.modeTitle")}>
|
||||
{modeOptions.map((option) => (
|
||||
@@ -1043,7 +1560,7 @@ export function Composer({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(attachments.length > 0 || workspaceRefs.length > 0) && (
|
||||
{(attachments.length > 0 || workspaceRefs.length > 0 || sessionRefs.length > 0) && (
|
||||
<div className="composer-context" aria-label={t("composer.contextItems")}>
|
||||
{attachments.map((a) => (
|
||||
<div
|
||||
@@ -1052,14 +1569,14 @@ export function Composer({
|
||||
>
|
||||
<Tooltip label={a.path}>
|
||||
<span className="composer-context__label">
|
||||
{a.previewUrl ? <img src={a.previewUrl} alt="" /> : <FileText size={15} />}
|
||||
{a.previewUrl ? <img src={a.previewUrl} alt="" draggable={false} /> : <FileText size={15} />}
|
||||
<span>{a.path.split("/").pop()}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("composer.removeImage")}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAttachments((prev) => prev.filter((x) => x.path !== a.path))}
|
||||
onClick={() => removeAttachment(a.path)}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
@@ -1087,6 +1604,30 @@ export function Composer({
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
{sessionRefs.map((ref) => (
|
||||
<div
|
||||
className="composer-context__item composer-context__item--session"
|
||||
key={ref.path}
|
||||
>
|
||||
<Tooltip label={ref.preview || ref.title}>
|
||||
<span className="composer-context__label">
|
||||
<MessageSquare size={15} />
|
||||
<span>
|
||||
{ref.title}
|
||||
{typeof ref.turns === "number" ? ` (${ref.turns} 轮)` : ""}
|
||||
</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip label="移除引用会话">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeSessionRef(ref.path)}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{activePastedBlocks.length > 0 && (
|
||||
@@ -1171,7 +1712,7 @@ export function Composer({
|
||||
<button
|
||||
className="composer__btn composer__btn--send"
|
||||
onClick={submit}
|
||||
disabled={pendingPaste > 0 || (!text.trim() && attachments.length === 0 && workspaceRefs.length === 0) || disabled}
|
||||
disabled={submitting || pendingPaste > 0 || (!text.trim() && attachments.length === 0 && workspaceRefs.length === 0) || disabled}
|
||||
>
|
||||
<ArrowUp size={16} />
|
||||
</button>
|
||||
|
||||
@@ -75,22 +75,15 @@ export function ContextMenu({
|
||||
onClose();
|
||||
};
|
||||
const close = () => onClose();
|
||||
const closeOnScroll = (event: Event) => {
|
||||
const target = event.target;
|
||||
if (target instanceof Node && menuRef.current?.contains(target)) return;
|
||||
onClose();
|
||||
};
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("pointerdown", closeOnOutsidePointerDown, true);
|
||||
window.addEventListener("resize", close);
|
||||
window.addEventListener("scroll", closeOnScroll, true);
|
||||
window.addEventListener("keydown", closeOnEscape);
|
||||
return () => {
|
||||
window.removeEventListener("pointerdown", closeOnOutsidePointerDown, true);
|
||||
window.removeEventListener("resize", close);
|
||||
window.removeEventListener("scroll", closeOnScroll, true);
|
||||
window.removeEventListener("keydown", closeOnEscape);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Folder, FileText } from "lucide-react";
|
||||
import type { DirEntry } from "../lib/types";
|
||||
|
||||
// FileMenu is the "@" file-reference dropdown above the composer. Like SlashMenu,
|
||||
// it's presentational — the Composer owns navigation and the one-level-at-a-time
|
||||
// descend logic. Reuses the .slashmenu container styling.
|
||||
export function FileMenu({
|
||||
items,
|
||||
activeIndex,
|
||||
onPick,
|
||||
onHover,
|
||||
}: {
|
||||
items: DirEntry[];
|
||||
activeIndex: number;
|
||||
onPick: (e: DirEntry) => void;
|
||||
onHover: (i: number) => void;
|
||||
}) {
|
||||
// Keep the keyboard-selected item in view (the list overflows at 280px).
|
||||
const activeRef = useRef<HTMLButtonElement>(null);
|
||||
useEffect(() => {
|
||||
activeRef.current?.scrollIntoView({ block: "nearest" });
|
||||
}, [activeIndex]);
|
||||
return (
|
||||
<div className="slashmenu" role="listbox">
|
||||
{items.map((e, i) => (
|
||||
<button
|
||||
key={(e.isDir ? "d:" : "f:") + e.name}
|
||||
ref={i === activeIndex ? activeRef : undefined}
|
||||
role="option"
|
||||
aria-selected={i === activeIndex}
|
||||
className={`slashmenu__item ${i === activeIndex ? "slashmenu__item--active" : ""}`}
|
||||
onMouseDown={(ev) => {
|
||||
ev.preventDefault();
|
||||
onPick(e);
|
||||
}}
|
||||
onMouseMove={() => onHover(i)}
|
||||
>
|
||||
{e.isDir ? (
|
||||
<Folder size={13} className="filemenu__icon filemenu__icon--dir" />
|
||||
) : (
|
||||
<FileText size={13} className="filemenu__icon" />
|
||||
)}
|
||||
<span className="slashmenu__name slashmenu__name--file">
|
||||
{e.name}
|
||||
{e.isDir ? "/" : ""}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,13 @@ const components: Components = {
|
||||
e.preventDefault();
|
||||
if (href) openExternal(href);
|
||||
}}
|
||||
onAuxClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (href) openExternal(href);
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button === 1) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { memo, useState } from "react";
|
||||
import { ChevronDown, GitBranch, RotateCcw, ScrollText } from "lucide-react";
|
||||
import { ChevronDown, FileText, Folder, GitBranch, Image, RotateCcw, ScrollText } from "lucide-react";
|
||||
import { Markdown } from "./Markdown";
|
||||
import { CopyButton } from "./CopyButton";
|
||||
import { ProcessBrainIcon, ProcessCard, ProcessStatusIcon } from "./ProcessCard";
|
||||
import { parseAttachmentRefsForDisplay } from "../lib/attachmentDisplay";
|
||||
import { useT } from "../lib/i18n";
|
||||
import type { Item, MessageActionScope } from "../lib/useController";
|
||||
import type { CheckpointMeta } from "../lib/types";
|
||||
@@ -10,6 +11,12 @@ import type { CheckpointMeta } from "../lib/types";
|
||||
type AssistantItem = Extract<Item, { kind: "assistant" }>;
|
||||
export type TurnActionMenu = "summary" | "rewind";
|
||||
|
||||
function attachmentIcon(kind: "image" | "file" | "folder") {
|
||||
if (kind === "image") return <Image size={15} />;
|
||||
if (kind === "folder") return <Folder size={15} />;
|
||||
return <FileText size={15} />;
|
||||
}
|
||||
|
||||
export function UserMessage({
|
||||
text,
|
||||
turn,
|
||||
@@ -19,11 +26,31 @@ export function UserMessage({
|
||||
turn?: number;
|
||||
anchorId?: string;
|
||||
}) {
|
||||
const displayText = text.replace(/@\.reasonix\/attachments\/[^\s]+/g, "[image]");
|
||||
const t = useT();
|
||||
const { text: displayText, attachments } = parseAttachmentRefsForDisplay(text);
|
||||
return (
|
||||
<div className="msg msg--user" id={anchorId} data-question-anchor={anchorId} data-turn={turn}>
|
||||
<div className="msg__body">
|
||||
<div className="msg__text">{displayText}</div>
|
||||
{displayText && <div className="msg__text">{displayText}</div>}
|
||||
{attachments.length > 0 && (
|
||||
<div className="msg-attachments" aria-label={t("msg.attachments")}>
|
||||
{attachments.map((attachment, index) => (
|
||||
<div className="msg-attachment" key={`${attachment.path}:${index}`} title={attachment.path}>
|
||||
<span className={`msg-attachment__icon msg-attachment__icon--${attachment.kind}`} aria-hidden="true">
|
||||
{attachmentIcon(attachment.kind)}
|
||||
</span>
|
||||
<span className="msg-attachment__main">
|
||||
<span className="msg-attachment__name">{attachment.name}</span>
|
||||
<span className="msg-attachment__meta">
|
||||
{attachment.kind === "folder"
|
||||
? t("msg.folderReference")
|
||||
: `${attachment.ext || t("msg.fileAttachment")} · ${attachment.source === "workspace" ? t("msg.workspaceReference") : attachment.kind === "image" ? t("msg.imageAttachment") : t("msg.fileAttachment")}`}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -43,7 +43,7 @@ export function OnboardingOverlay({ onComplete }: { onComplete: () => void }) {
|
||||
return (
|
||||
<div className="onboarding">
|
||||
<div className="onboarding__card">
|
||||
<img src={logo} className="onboarding__logo" alt="Reasonix" />
|
||||
<img src={logo} className="onboarding__logo" alt="Reasonix" draggable={false} />
|
||||
<div className="onboarding__title">{t("onboarding.title")}</div>
|
||||
<div className="onboarding__tag">{t("onboarding.tagline")}</div>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Check, ChevronDown } from "lucide-react";
|
||||
import { asArray } from "../lib/array";
|
||||
import { app } from "../lib/bridge";
|
||||
import { normalizeLangPref, useI18n, useT, type DictKey, type LangPref } from "../lib/i18n";
|
||||
import { mergedFetchedProviderModels, providerDefaultModel } from "../lib/providerModels";
|
||||
import { useUpdater } from "../lib/useUpdater";
|
||||
import {
|
||||
THEME_STYLES,
|
||||
@@ -400,7 +401,9 @@ function normalizeSettingsView(view: SettingsView | null | undefined): SettingsV
|
||||
noProxy: "",
|
||||
proxy: { type: "socks5", server: "", port: 0, username: "", password: "" },
|
||||
};
|
||||
const agent = view.agent ?? { temperature: 0, maxSteps: 0, systemPrompt: "" };
|
||||
const agent = view.agent ?? { temperature: 0, maxSteps: 0, plannerMaxSteps: 12, systemPrompt: "" };
|
||||
agent.plannerMaxSteps = Number.isFinite(agent.plannerMaxSteps) ? Math.max(0, Math.trunc(agent.plannerMaxSteps)) : 12;
|
||||
agent.maxSteps = Number.isFinite(agent.maxSteps) ? Math.max(0, Math.trunc(agent.maxSteps)) : 0;
|
||||
return {
|
||||
...view,
|
||||
providers: asArray(view.providers).map((p) => ({
|
||||
@@ -531,6 +534,80 @@ function GeneralSection({ s, busy, apply }: SectionProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function StepLimitControl({
|
||||
value,
|
||||
presets,
|
||||
busy,
|
||||
onChange,
|
||||
}: {
|
||||
value: number;
|
||||
presets: number[];
|
||||
busy: boolean;
|
||||
onChange: (value: number) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const normalized = normalizeStepLimit(value);
|
||||
const presetSet = new Set(presets.map(normalizeStepLimit));
|
||||
const [custom, setCustom] = useState(String(normalized));
|
||||
useEffect(() => setCustom(String(normalized)), [normalized]);
|
||||
const isCustom = !presetSet.has(normalized);
|
||||
const commitCustom = () => {
|
||||
const next = normalizeStepLimit(Number(custom));
|
||||
setCustom(String(next));
|
||||
if (next !== normalized) onChange(next);
|
||||
};
|
||||
return (
|
||||
<div className="step-limit-control">
|
||||
<div className="set-seg">
|
||||
{presets.map((preset) => {
|
||||
const n = normalizeStepLimit(preset);
|
||||
return (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
className={`set-seg__btn${normalized === n ? " set-seg__btn--on" : ""}`}
|
||||
disabled={busy}
|
||||
onClick={() => n !== normalized && onChange(n)}
|
||||
>
|
||||
{stepLimitLabel(n, t)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
className={`set-seg__btn${isCustom ? " set-seg__btn--on" : ""}`}
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
if (!isCustom) setCustom(String(normalized || 12));
|
||||
}}
|
||||
>
|
||||
{t("settings.stepLimit.custom")}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
className="mem-input step-limit-control__custom"
|
||||
value={custom}
|
||||
disabled={busy}
|
||||
inputMode="numeric"
|
||||
aria-label={t("settings.stepLimit.custom")}
|
||||
onChange={(e) => setCustom(e.target.value.replace(/[^\d]/g, ""))}
|
||||
onBlur={commitCustom}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") e.currentTarget.blur();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeStepLimit(value: number): number {
|
||||
return Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;
|
||||
}
|
||||
|
||||
function stepLimitLabel(value: number, t: ReturnType<typeof useT>): string {
|
||||
return value === 0 ? t("settings.stepLimit.unlimited") : String(value);
|
||||
}
|
||||
|
||||
function NetworkSection({ s, busy, apply }: SectionProps) {
|
||||
const t = useT();
|
||||
const savedNetwork = normalizeNetworkView(s.network);
|
||||
@@ -662,6 +739,10 @@ function ModelsSection({ s, busy, apply, backgroundApply }: ModelsSectionProps)
|
||||
const providerLabel = defaultProvider ? modelProviderLabel(defaultProvider, defaultProviderView, t) : t("common.none");
|
||||
const plannerLabel = plannerSelectRef || t("settings.plannerNone");
|
||||
const keyStatusLabel = defaultProviderView?.keySet ? t("settings.keySet") : t("settings.noKey");
|
||||
const agent = s.agent ?? { temperature: 0, maxSteps: 0, plannerMaxSteps: 12, systemPrompt: "" };
|
||||
const setAgentSteps = (maxSteps: number, plannerMaxSteps: number) => (
|
||||
app.SetAgentParams(agent.temperature, maxSteps, plannerMaxSteps, agent.systemPrompt)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (subtab !== "usage") return;
|
||||
@@ -681,9 +762,10 @@ function ModelsSection({ s, busy, apply, backgroundApply }: ModelsSectionProps)
|
||||
try {
|
||||
const fetched = await app.FetchProviderModels(provider);
|
||||
if (fetched.length === 0) continue;
|
||||
const currentDefault = provider.default && fetched.includes(provider.default) ? provider.default : fetched[0];
|
||||
if (sameStringList(provider.models, fetched) && provider.default === currentDefault) continue;
|
||||
await app.SaveProvider({ ...provider, models: fetched, default: currentDefault });
|
||||
const models = mergedFetchedProviderModels(provider.models, fetched, { preserveCurated: true });
|
||||
const currentDefault = providerDefaultModel(provider.default, models);
|
||||
if (sameStringList(provider.models, models) && provider.default === currentDefault) continue;
|
||||
await app.SaveProvider({ ...provider, models, default: currentDefault });
|
||||
} catch {
|
||||
// Background discovery is opportunistic; manual refresh shows errors.
|
||||
}
|
||||
@@ -713,68 +795,88 @@ function ModelsSection({ s, busy, apply, backgroundApply }: ModelsSectionProps)
|
||||
</div>
|
||||
|
||||
{subtab === "usage" ? (
|
||||
<SettingsSection title={t("settings.modelUsage")}>
|
||||
<SettingsField label={t("settings.defaultModel")}>
|
||||
<ModelPicker
|
||||
s={s}
|
||||
refs={refs}
|
||||
value={toRef(s.defaultModel, s)}
|
||||
disabled={busy}
|
||||
onPick={(ref) => void apply(() => app.SetDefaultModel(ref))}
|
||||
/>
|
||||
</SettingsField>
|
||||
<>
|
||||
<SettingsSection title={t("settings.modelUsage")}>
|
||||
<SettingsField label={t("settings.defaultModel")}>
|
||||
<ModelPicker
|
||||
s={s}
|
||||
refs={refs}
|
||||
value={toRef(s.defaultModel, s)}
|
||||
disabled={busy}
|
||||
onPick={(ref) => void apply(() => app.SetDefaultModel(ref))}
|
||||
/>
|
||||
</SettingsField>
|
||||
|
||||
<SettingsField label={t("settings.plannerModel")}>
|
||||
<ModelPicker
|
||||
s={s}
|
||||
refs={refs}
|
||||
value={plannerSelectRef}
|
||||
disabled={busy}
|
||||
includeSameDefault
|
||||
onPick={(ref) => void apply(() => app.SetPlannerModel(ref))}
|
||||
/>
|
||||
</SettingsField>
|
||||
<SettingsField label={t("settings.plannerModel")}>
|
||||
<ModelPicker
|
||||
s={s}
|
||||
refs={refs}
|
||||
value={plannerSelectRef}
|
||||
disabled={busy}
|
||||
includeSameDefault
|
||||
onPick={(ref) => void apply(() => app.SetPlannerModel(ref))}
|
||||
/>
|
||||
</SettingsField>
|
||||
|
||||
<SettingsField label={t("settings.subagentModel")}>
|
||||
<ModelPicker
|
||||
s={s}
|
||||
refs={refs}
|
||||
value={subagentRef}
|
||||
disabled={busy}
|
||||
emptyOptionLabel={t("settings.subagentModelDefault")}
|
||||
emptyOptionHint={t("common.auto")}
|
||||
onPick={(ref) => void apply(() => app.SetSubagentModel(ref))}
|
||||
/>
|
||||
</SettingsField>
|
||||
<SettingsField label={t("settings.subagentModel")}>
|
||||
<ModelPicker
|
||||
s={s}
|
||||
refs={refs}
|
||||
value={subagentRef}
|
||||
disabled={busy}
|
||||
emptyOptionLabel={t("settings.subagentModelDefault")}
|
||||
emptyOptionHint={t("common.auto")}
|
||||
onPick={(ref) => void apply(() => app.SetSubagentModel(ref))}
|
||||
/>
|
||||
</SettingsField>
|
||||
|
||||
<SettingsField label={t("settings.subagentEffort")} hint={t("settings.subagentHint")}>
|
||||
<select
|
||||
className="mem-select set-grow"
|
||||
value={s.subagentEffort || ""}
|
||||
disabled={busy}
|
||||
onChange={(e) => void apply(() => app.SetSubagentEffort(e.target.value))}
|
||||
>
|
||||
<option value="">{t("settings.subagentEffortDefault")}</option>
|
||||
{EFFORT_PRESETS.map((level) => (
|
||||
<option key={level} value={level}>
|
||||
{level}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SettingsField>
|
||||
<SettingsField label={t("settings.subagentEffort")} hint={t("settings.subagentHint")}>
|
||||
<select
|
||||
className="mem-select set-grow"
|
||||
value={s.subagentEffort || ""}
|
||||
disabled={busy}
|
||||
onChange={(e) => void apply(() => app.SetSubagentEffort(e.target.value))}
|
||||
>
|
||||
<option value="">{t("settings.subagentEffortDefault")}</option>
|
||||
{EFFORT_PRESETS.map((level) => (
|
||||
<option key={level} value={level}>
|
||||
{level}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SettingsField>
|
||||
|
||||
<div className="settings-model-current" aria-label={t("settings.modelCurrentStatus")}>
|
||||
<div>
|
||||
<span>{t("settings.modelCurrentStatus")}</span>
|
||||
<strong>{currentModelLabel}</strong>
|
||||
<div className="settings-model-current" aria-label={t("settings.modelCurrentStatus")}>
|
||||
<div>
|
||||
<span>{t("settings.modelCurrentStatus")}</span>
|
||||
<strong>{currentModelLabel}</strong>
|
||||
</div>
|
||||
<div className="settings-model-current__meta">
|
||||
<span>{providerLabel}</span>
|
||||
<span>{plannerLabel}</span>
|
||||
<span>{keyStatusLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-model-current__meta">
|
||||
<span>{providerLabel}</span>
|
||||
<span>{plannerLabel}</span>
|
||||
<span>{keyStatusLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</SettingsSection>
|
||||
<SettingsSection title={t("settings.agentRuntime")} description={t("settings.agentRuntimeHint")}>
|
||||
<SettingsField label={t("settings.executorMaxSteps")} hint={t("settings.executorMaxStepsHint")}>
|
||||
<StepLimitControl
|
||||
value={agent.maxSteps}
|
||||
presets={[0, 10, 25, 50]}
|
||||
busy={busy}
|
||||
onChange={(next) => void apply(() => setAgentSteps(next, agent.plannerMaxSteps))}
|
||||
/>
|
||||
</SettingsField>
|
||||
<SettingsField label={t("settings.plannerMaxSteps")} hint={plannerSelectRef ? t("settings.plannerMaxStepsHint") : t("settings.plannerMaxStepsDisabledHint")}>
|
||||
<StepLimitControl
|
||||
value={agent.plannerMaxSteps}
|
||||
presets={[6, 12, 25, 0]}
|
||||
busy={busy}
|
||||
onChange={(next) => void apply(() => setAgentSteps(agent.maxSteps, next))}
|
||||
/>
|
||||
</SettingsField>
|
||||
</SettingsSection>
|
||||
</>
|
||||
) : (
|
||||
<ProvidersSection s={s} busy={busy} apply={apply} />
|
||||
)}
|
||||
@@ -1043,11 +1145,12 @@ function ProvidersSection({ s, busy, apply }: SectionProps) {
|
||||
});
|
||||
return;
|
||||
}
|
||||
const currentDefault = p.default && fetched.includes(p.default) ? p.default : fetched[0];
|
||||
await app.SaveProvider({ ...p, models: fetched, default: currentDefault });
|
||||
const models = mergedFetchedProviderModels(p.models, fetched);
|
||||
const currentDefault = providerDefaultModel(p.default, models);
|
||||
await app.SaveProvider({ ...p, models, default: currentDefault });
|
||||
setGroupFetchResult(group.id, {
|
||||
kind: "ok",
|
||||
text: t("settings.fetchModelsUpdatedForProvider", { provider: group.label, n: fetched.length }),
|
||||
text: t("settings.fetchModelsUpdatedForProvider", { provider: group.label, n: models.length }),
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
@@ -1072,11 +1175,12 @@ function ProvidersSection({ s, busy, apply }: SectionProps) {
|
||||
try {
|
||||
const fetched = await app.FetchProviderModels({ ...probe, apiKeyEnv });
|
||||
if (fetched.length > 0) {
|
||||
const currentDefault = probe.default && fetched.includes(probe.default) ? probe.default : fetched[0];
|
||||
await app.SaveProvider({ ...probe, apiKeyEnv, models: fetched, default: currentDefault });
|
||||
const models = mergedFetchedProviderModels(probe.models, fetched, { preserveCurated: true });
|
||||
const currentDefault = providerDefaultModel(probe.default, models);
|
||||
await app.SaveProvider({ ...probe, apiKeyEnv, models, default: currentDefault });
|
||||
setGroupFetchResult(group.id, {
|
||||
kind: "ok",
|
||||
text: t("settings.fetchModelsUpdatedForProvider", { provider: group.label, n: fetched.length }),
|
||||
text: t("settings.fetchModelsUpdatedForProvider", { provider: group.label, n: models.length }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1479,6 +1583,7 @@ function providerAccessGroups(providers: ProviderView[], t: ReturnType<typeof us
|
||||
const groups = new Map<string, ProviderAccessGroup>();
|
||||
for (const p of providers) {
|
||||
const id = providerGroupID(p);
|
||||
const builtIn = id.startsWith("builtin:");
|
||||
const existing = groups.get(id);
|
||||
if (existing) {
|
||||
existing.providers.push(p);
|
||||
@@ -1490,7 +1595,7 @@ function providerAccessGroups(providers: ProviderView[], t: ReturnType<typeof us
|
||||
id,
|
||||
label: providerGroupLabel(p, t),
|
||||
description: providerGroupDescription(p, t),
|
||||
builtIn: p.builtIn,
|
||||
builtIn,
|
||||
providers: [p],
|
||||
apiKeyEnv: p.apiKeyEnv,
|
||||
keySet: p.keySet,
|
||||
@@ -1502,13 +1607,45 @@ function providerAccessGroups(providers: ProviderView[], t: ReturnType<typeof us
|
||||
return Array.from(groups.values());
|
||||
}
|
||||
|
||||
function providerBaseHost(baseUrl: string): string {
|
||||
try {
|
||||
return new URL(baseUrl).hostname.toLowerCase();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalOfficialProviderName(name: string): string {
|
||||
switch (name.trim()) {
|
||||
case "deepseek-flash":
|
||||
case "deepseek-pro":
|
||||
return "deepseek";
|
||||
case "mimo":
|
||||
case "xiaomi-mimo":
|
||||
case "xiaomi_mimo":
|
||||
return "mimo-api";
|
||||
case "mimo-pro":
|
||||
case "mimo-flash":
|
||||
return "mimo-token-plan";
|
||||
default:
|
||||
return name.trim();
|
||||
}
|
||||
}
|
||||
|
||||
function officialProviderKind(p: ProviderView): string {
|
||||
if (!p.builtIn) return "";
|
||||
const name = canonicalOfficialProviderName(p.name);
|
||||
const host = providerBaseHost(p.baseUrl);
|
||||
if (name === "deepseek" && host === "api.deepseek.com") return "deepseek";
|
||||
if (name === "mimo-token-plan" && host === "token-plan-cn.xiaomimimo.com") return "mimo-token-plan";
|
||||
if (name === "mimo-api" && host === "api.xiaomimimo.com") return "mimo-api";
|
||||
return "";
|
||||
}
|
||||
|
||||
function providerGroupID(p: ProviderView): string {
|
||||
if (!p.builtIn) return `custom:${p.name}`;
|
||||
const base = p.baseUrl.toLowerCase();
|
||||
if (p.apiKeyEnv === "DEEPSEEK_API_KEY" || base.includes("deepseek")) return "builtin:deepseek";
|
||||
if (base.includes("token-plan-cn.xiaomimimo.com")) return "builtin:mimo-token-plan";
|
||||
if (base.includes("api.xiaomimimo.com") || base.includes("mimo") || base.includes("xiaomimimo")) return "builtin:mimo-api";
|
||||
return `builtin:${p.name}`;
|
||||
const official = officialProviderKind(p);
|
||||
if (official) return `builtin:${official}`;
|
||||
return `custom:${p.name}`;
|
||||
}
|
||||
|
||||
function providerGroupLabel(p: ProviderView, t?: ReturnType<typeof useT>): string {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useT } from "../lib/i18n";
|
||||
import type { CommandInfo } from "../lib/types";
|
||||
import { VirtualMenu } from "./VirtualMenu";
|
||||
|
||||
// SlashMenu is the "/" autocomplete dropdown above the composer. Presentational:
|
||||
// the Composer owns filtering, the active index, and key handling; this renders
|
||||
// the list and reports hover/pick. Uses mousedown (not click) so picking an item
|
||||
// doesn't blur the textarea first.
|
||||
// the (virtualized) list and reports hover/pick. Uses mousedown (not click) so
|
||||
// picking an item doesn't blur the textarea first.
|
||||
export function SlashMenu({
|
||||
items,
|
||||
activeIndex,
|
||||
@@ -18,13 +18,6 @@ export function SlashMenu({
|
||||
onHover: (i: number) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
// Keep the keyboard-selected item scrolled into view: the list is capped at
|
||||
// 280px and overflows, so ArrowDown past the visible window would otherwise
|
||||
// hide the active row. block:"nearest" only scrolls when it's actually off-screen.
|
||||
const activeRef = useRef<HTMLButtonElement>(null);
|
||||
useEffect(() => {
|
||||
activeRef.current?.scrollIntoView({ block: "nearest" });
|
||||
}, [activeIndex]);
|
||||
// builtin commands get no tag; custom (project) and mcp commands are labelled.
|
||||
const kindTag = (kind: CommandInfo["kind"]) =>
|
||||
kind === "custom"
|
||||
@@ -35,11 +28,12 @@ export function SlashMenu({
|
||||
? t("slash.skill")
|
||||
: "";
|
||||
return (
|
||||
<div className="slashmenu" role="listbox">
|
||||
{items.map((c, i) => (
|
||||
<VirtualMenu
|
||||
items={items}
|
||||
activeIndex={activeIndex}
|
||||
itemKey={(c) => c.kind + ":" + c.name}
|
||||
renderItem={(c, i) => (
|
||||
<button
|
||||
key={c.kind + ":" + c.name}
|
||||
ref={i === activeIndex ? activeRef : undefined}
|
||||
role="option"
|
||||
aria-selected={i === activeIndex}
|
||||
className={`slashmenu__item ${i === activeIndex ? "slashmenu__item--active" : ""}`}
|
||||
@@ -54,7 +48,7 @@ export function SlashMenu({
|
||||
<span className="slashmenu__desc">{c.description}</span>
|
||||
{kindTag(c.kind) && <span className="slashmenu__kind">{kindTag(c.kind)}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -69,7 +69,7 @@ export function StartupSplash({ hold, onDone }: { hold: boolean; onDone: () => v
|
||||
<div className="startup-splash" data-leaving={leaving} onClick={() => finish(true)}>
|
||||
<div className="startup-splash__card">
|
||||
<div className="startup-splash__mark" aria-hidden="true">
|
||||
<img src={logoSymbol} alt="" />
|
||||
<img src={logoSymbol} alt="" draggable={false} />
|
||||
</div>
|
||||
<div className="startup-splash__name">Reasonix</div>
|
||||
<div className="startup-splash__sub">{t("app.splashSubtitle")}</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Check, ChevronDown, ChevronRight, Circle, CircleDot, RefreshCw, X } from "lucide-react";
|
||||
import { Check, ChevronDown, ChevronRight, Circle, CircleDot, X } from "lucide-react";
|
||||
import { useT } from "../lib/i18n";
|
||||
import type { Todo } from "../lib/tools";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
@@ -11,7 +11,7 @@ import { Tooltip } from "./Tooltip";
|
||||
// shows the current item so the footer stays compact during a long run. The ✕
|
||||
// dismisses it (onDismiss) when the user abandons the task; a fresh todo_write
|
||||
// brings it back.
|
||||
export function TodoPanel({ todos, stale, onDismiss }: { todos: Todo[]; stale?: boolean; onDismiss: () => void }) {
|
||||
export function TodoPanel({ todos, onDismiss }: { todos: Todo[]; onDismiss: () => void }) {
|
||||
const t = useT();
|
||||
const [open, setOpen] = useState(true);
|
||||
const currentRef = useRef<HTMLLIElement | null>(null);
|
||||
@@ -35,12 +35,6 @@ export function TodoPanel({ todos, stale, onDismiss }: { todos: Todo[]; stale?:
|
||||
<span className="todobar__count">
|
||||
{done}/{todos.length}
|
||||
</span>
|
||||
{stale && (
|
||||
<span className="todobar__stale">
|
||||
<RefreshCw size={11} />
|
||||
{t("todo.stale")}
|
||||
</span>
|
||||
)}
|
||||
{!open && current && (
|
||||
<span className="todobar__current">{current.activeForm || current.content}</span>
|
||||
)}
|
||||
|
||||
@@ -72,10 +72,17 @@ export const ToolCard = memo(function ToolCard({ item, subcalls }: { item: ToolI
|
||||
: summarize(item.name, item.args, item.output, item.error);
|
||||
|
||||
// edit diffs are the point of the card, so they're shown inline; everything
|
||||
// else folds its args/output away by default. Nested children always show.
|
||||
// Shell commands default to open so the output is immediately visible.
|
||||
// else folds its args/output away by default. Nested sub-agent calls (an
|
||||
// /explore's read_file / grep chain) follow the parent's expand state — a
|
||||
// single chevron now collapses the whole subagent tree to one line. Open by
|
||||
// default while the sub-agent is still running so the user can watch it
|
||||
// progress; closed by default once it settles.
|
||||
const hasArgsOrOutput = diffs.length === 0 && (!!item.args || !!item.output);
|
||||
const [open, setOpen] = useState(item.isShell && hasArgsOrOutput);
|
||||
const [open, setOpen] = useState(() => {
|
||||
if (item.isShell) return hasArgsOrOutput;
|
||||
if (hasNested) return item.status === "running";
|
||||
return false;
|
||||
});
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
// Register this shell card's toggle with the global ShellExpand context so
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createContext, memo, type CSSProperties, type MouseEvent as ReactMouseE
|
||||
import type { Item, LiveStream } from "../lib/useController";
|
||||
import type { CheckpointMeta } from "../lib/types";
|
||||
import { useT } from "../lib/i18n";
|
||||
import { replaceAttachmentRefsForDisplay } from "../lib/attachmentDisplay";
|
||||
import { AssistantMessage, TurnActions, UserMessage } from "./Message";
|
||||
import { ProcessCard, ProcessCompactIcon, ProcessInfoIcon, ProcessPhaseIcon, ProcessStatusIcon } from "./ProcessCard";
|
||||
import { ToolCard } from "./ToolCard";
|
||||
@@ -46,7 +47,7 @@ function questionAnchorId(id: string): string {
|
||||
}
|
||||
|
||||
function compactQuestionText(text: string): string {
|
||||
const cleaned = text.replace(/@\.reasonix\/attachments\/[^\s]+/g, "[image]").replace(/\s+/g, " ").trim();
|
||||
const cleaned = replaceAttachmentRefsForDisplay(text).replace(/\s+/g, " ").trim();
|
||||
if (cleaned.length <= 80) return cleaned;
|
||||
return cleaned.slice(0, 80);
|
||||
}
|
||||
@@ -84,7 +85,7 @@ function repinIfWasPinned(
|
||||
|
||||
// Summarise a warm turn for its compact card.
|
||||
function warmUserPreview(text: string): string {
|
||||
const cleaned = text.replace(/@\.reasonix\/attachments\/[^\s]+/g, "[image]").replace(/\s+/g, " ").trim();
|
||||
const cleaned = replaceAttachmentRefsForDisplay(text).replace(/\s+/g, " ").trim();
|
||||
return cleaned.length <= 80 ? cleaned : cleaned.slice(0, 77) + "...";
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
|
||||
// VirtualMenu is the shared scroll container for the composer's "/" and "@"
|
||||
// dropdowns. Rows are virtualized so a directory with thousands of entries (or a
|
||||
// long command list) only ever mounts the visible rows plus a small overscan —
|
||||
// no truncation, no jank. The caller owns the item data, the active index, and
|
||||
// per-row markup; this owns layout and keeping the active row in view.
|
||||
export function VirtualMenu<T>({
|
||||
items,
|
||||
activeIndex,
|
||||
itemKey,
|
||||
renderItem,
|
||||
}: {
|
||||
items: T[];
|
||||
activeIndex: number;
|
||||
itemKey: (item: T, index: number) => string;
|
||||
renderItem: (item: T, index: number) => ReactNode;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const virtualizer = useVirtualizer({
|
||||
count: items.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 34,
|
||||
overscan: 10,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (activeIndex >= 0 && activeIndex < items.length) {
|
||||
virtualizer.scrollToIndex(activeIndex, { align: "auto" });
|
||||
}
|
||||
}, [activeIndex, items.length, virtualizer]);
|
||||
|
||||
return (
|
||||
<div ref={scrollRef} className="slashmenu" role="listbox">
|
||||
<div className="slashmenu__sizer" style={{ height: virtualizer.getTotalSize() }}>
|
||||
{virtualizer.getVirtualItems().map((row) => (
|
||||
<div
|
||||
key={itemKey(items[row.index], row.index)}
|
||||
data-index={row.index}
|
||||
ref={virtualizer.measureElement}
|
||||
className="slashmenu__row"
|
||||
style={{ transform: `translateY(${row.start}px)` }}
|
||||
>
|
||||
{renderItem(items[row.index], row.index)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export function Welcome({ onPrompt }: { onPrompt: (text: string) => void }) {
|
||||
const examples = [t("welcome.ex1"), t("welcome.ex2"), t("welcome.ex3")];
|
||||
return (
|
||||
<div className="welcome">
|
||||
<img src={logoWordmark} className="welcome__logo" alt="Reasonix" />
|
||||
<img src={logoWordmark} className="welcome__logo" alt="Reasonix" draggable={false} />
|
||||
<div className="welcome__tag">{t("welcome.tagline")}</div>
|
||||
|
||||
<div className="welcome__hints">
|
||||
|
||||
@@ -24,8 +24,9 @@ import {
|
||||
import { app } from "../lib/bridge";
|
||||
import { useT } from "../lib/i18n";
|
||||
import { loadLayoutSize, saveLayoutSize } from "../lib/layoutPreferences";
|
||||
import type { DirEntry, FilePreview, WorkspaceChangeView, WorkspaceChangesView } from "../lib/types";
|
||||
import type { DirEntry, FilePreview, GitCommitView, GitCommitDetailView } from "../lib/types";
|
||||
import { formatWorkspaceReference, WORKSPACE_REF_DRAG_TYPE } from "../lib/workspaceDrag";
|
||||
import { cleanGitDiff } from "../lib/diff";
|
||||
import { CodeViewer } from "./CodeViewer";
|
||||
import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type ContextMenuPoint } from "./ContextMenu";
|
||||
import { FloatingMenu, FloatingMenuItems } from "./FloatingMenu";
|
||||
@@ -116,7 +117,7 @@ function renderMediaPreview(preview: FilePreview): JSX.Element | null {
|
||||
if (preview.kind === "image") {
|
||||
return (
|
||||
<div className="workspace-media workspace-media--image">
|
||||
<img src={preview.url} alt={basename(preview.path)} decoding="async" />
|
||||
<img src={preview.url} alt={basename(preview.path)} decoding="async" draggable={false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -160,15 +161,16 @@ function formatBytes(n: number): string {
|
||||
return `${n} B`;
|
||||
}
|
||||
|
||||
function isDeletedChange(row: WorkspaceChangeView): boolean {
|
||||
return !!row.gitStatus && row.gitStatus.includes("D");
|
||||
}
|
||||
|
||||
function changeDetail(row: WorkspaceChangeView): string {
|
||||
if (row.latestPrompt) return row.latestPrompt;
|
||||
if (row.oldPath) return `← ${row.oldPath}`;
|
||||
if (row.turns && row.turns.length > 0) return `#${row.turns.join(", #")}`;
|
||||
return row.path;
|
||||
function formatCommitDate(dateStr: string): string {
|
||||
const d = new Date(dateStr);
|
||||
if (isNaN(d.getTime())) return dateStr;
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
const month = monthNames[d.getMonth()];
|
||||
const year = d.getFullYear();
|
||||
const hours = String(d.getHours()).padStart(2, "0");
|
||||
const minutes = String(d.getMinutes()).padStart(2, "0");
|
||||
return `${day} ${month} ${year} ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
export function WorkspacePanel({
|
||||
@@ -209,12 +211,14 @@ export function WorkspacePanel({
|
||||
const [preview, setPreview] = useState<FilePreview | null>(null);
|
||||
const [loadingPreview, setLoadingPreview] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<"files" | "changed">(initialViewMode);
|
||||
const [changes, setChanges] = useState<WorkspaceChangesView | null>(null);
|
||||
const [loadingChanges, setLoadingChanges] = useState(false);
|
||||
const [gitHistory, setGitHistory] = useState<GitCommitView[]>([]);
|
||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||
const [expandedCommit, setExpandedCommit] = useState<string | null>(null);
|
||||
const [commitDetail, setCommitDetail] = useState<GitCommitDetailView | null>(null);
|
||||
const [loadingCommit, setLoadingCommit] = useState(false);
|
||||
const [selectionMenu, setSelectionMenu] = useState<{ x: number; y: number; text: string; path: string } | null>(null);
|
||||
const [treeMenu, setTreeMenu] = useState<{ x: number; y: number; path: string; isDir: boolean } | null>(null);
|
||||
const [treeBlankMenuPoint, setTreeBlankMenuPoint] = useState<ContextMenuPoint | null>(null);
|
||||
const changesRequestRef = useRef(0);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [treeVisible, setTreeVisible] = useState(true);
|
||||
const [treeWidth, setTreeWidth] = useState(loadWorkspaceTreeWidth);
|
||||
@@ -232,22 +236,46 @@ export function WorkspacePanel({
|
||||
setEntriesByDir((prev) => ({ ...prev, [dir]: entries ?? [] }));
|
||||
}, []);
|
||||
|
||||
const loadChanges = useCallback(async () => {
|
||||
const requestId = changesRequestRef.current + 1;
|
||||
changesRequestRef.current = requestId;
|
||||
setLoadingChanges(true);
|
||||
const loadGitHistory = useCallback(async () => {
|
||||
setLoadingHistory(true);
|
||||
try {
|
||||
const next = await app.WorkspaceChanges();
|
||||
if (changesRequestRef.current === requestId) setChanges(next);
|
||||
const result = await app.WorkspaceGitHistory(selectedPath || "");
|
||||
setGitHistory(result || []);
|
||||
} catch (err) {
|
||||
if (changesRequestRef.current === requestId) {
|
||||
setChanges({ files: [], gitAvailable: false, gitErr: String((err as Error)?.message ?? err) });
|
||||
}
|
||||
setGitHistory([]);
|
||||
} finally {
|
||||
if (changesRequestRef.current === requestId) setLoadingChanges(false);
|
||||
setLoadingHistory(false);
|
||||
}
|
||||
}, [selectedPath]);
|
||||
|
||||
const toggleCommit = useCallback((hash: string) => {
|
||||
setExpandedCommit((prev) => (prev === hash ? null : hash));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (expandedCommit) {
|
||||
let live = true;
|
||||
setLoadingCommit(true);
|
||||
app
|
||||
.WorkspaceGitCommitDetail(expandedCommit, selectedPath || "")
|
||||
.then((detail) => {
|
||||
if (live) setCommitDetail(detail);
|
||||
})
|
||||
.catch(() => {
|
||||
if (live) setCommitDetail(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (live) setLoadingCommit(false);
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
} else {
|
||||
setCommitDetail(null);
|
||||
}
|
||||
}, [expandedCommit, selectedPath, open]);
|
||||
|
||||
const selectFile = useCallback(
|
||||
(path: string) => {
|
||||
onRequestPanelWidth?.(WORKSPACE_DUAL_PANEL_TARGET_WIDTH);
|
||||
@@ -270,31 +298,38 @@ export function WorkspacePanel({
|
||||
setSelectedPath(null);
|
||||
setOpenTabs([]);
|
||||
setPreview(null);
|
||||
setChanges(null);
|
||||
setGitHistory([]);
|
||||
setExpandedCommit(null);
|
||||
setCommitDetail(null);
|
||||
setSelectionMenu(null);
|
||||
setTreeMenu(null);
|
||||
setFilter("");
|
||||
setTreeVisible(true);
|
||||
setViewMode(initialViewMode);
|
||||
void loadDir("");
|
||||
}, [cwd, initialViewMode, loadDir, open]);
|
||||
}, [cwd, loadDir, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setViewMode(initialViewMode);
|
||||
if (initialViewMode === "changed") void loadChanges();
|
||||
}, [initialViewMode, loadChanges, open]);
|
||||
if (initialViewMode === "changed") {
|
||||
void loadGitHistory();
|
||||
}
|
||||
}, [initialViewMode, loadGitHistory, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
void loadChanges();
|
||||
}, [cwd, loadChanges, open]);
|
||||
if (viewMode === "changed") {
|
||||
void loadGitHistory();
|
||||
}
|
||||
}, [selectedPath, viewMode, loadGitHistory, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !refreshKey) return;
|
||||
void loadChanges();
|
||||
if (viewMode === "changed") {
|
||||
void loadGitHistory();
|
||||
}
|
||||
openDirsRef.current.forEach((dir) => void loadDir(dir));
|
||||
}, [loadChanges, loadDir, open, refreshKey]);
|
||||
}, [loadGitHistory, loadDir, open, refreshKey, viewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectionMenu && !treeMenu) return;
|
||||
@@ -320,13 +355,13 @@ export function WorkspacePanel({
|
||||
setSelectionMenu(null);
|
||||
setTreeMenu(null);
|
||||
if (viewMode === "changed") {
|
||||
void loadChanges();
|
||||
void loadGitHistory();
|
||||
return;
|
||||
}
|
||||
const dirs = Array.from(openDirsRef.current);
|
||||
setEntriesByDir({});
|
||||
dirs.forEach((dir) => void loadDir(dir));
|
||||
}, [loadChanges, loadDir, viewMode]);
|
||||
}, [loadGitHistory, loadDir, viewMode]);
|
||||
|
||||
const refreshSelected = useCallback(() => {
|
||||
if (!selectedPath) return;
|
||||
@@ -415,16 +450,10 @@ export function WorkspacePanel({
|
||||
.sort((a, b) => a.path.localeCompare(b.path));
|
||||
}, [entriesByDir, filter]);
|
||||
|
||||
const changedRows = useMemo(() => {
|
||||
const rows = changes?.files ?? [];
|
||||
const q = filter.trim().toLowerCase();
|
||||
if (!q) return rows;
|
||||
return rows.filter((row) => `${row.path} ${row.oldPath ?? ""} ${row.gitStatus ?? ""}`.toLowerCase().includes(q));
|
||||
}, [changes?.files, filter]);
|
||||
const searchPlaceholder = viewMode === "changed" ? t("workspace.filterChanges") : t("workspace.filter");
|
||||
const searchPlaceholder = t("workspace.filter");
|
||||
|
||||
const effectiveTreeWidth = useMemo(() => clampWorkspaceTreeWidth(treeWidth, panelWidth), [panelWidth, treeWidth]);
|
||||
const previewVisible = openTabs.length > 0 || selectedPath !== null;
|
||||
const previewVisible = viewMode === "changed" || openTabs.length > 0 || selectedPath !== null;
|
||||
const selectedFileVisible = selectedPath !== null;
|
||||
const compactTreeSplit =
|
||||
treeVisible && selectedFileVisible && panelWidth !== undefined && panelWidth < WORKSPACE_DUAL_PANEL_MIN_WIDTH;
|
||||
@@ -590,41 +619,6 @@ export function WorkspacePanel({
|
||||
}
|
||||
};
|
||||
|
||||
const renderChangedRows = () => {
|
||||
if (loadingChanges) return <div className="workspace-empty">{t("workspace.loadingChanges")}</div>;
|
||||
if (!changes) return null;
|
||||
if (changedRows.length === 0) return <div className="workspace-empty">{t("workspace.noChanges")}</div>;
|
||||
return changedRows.map((row) => {
|
||||
const deleted = isDeletedChange(row);
|
||||
return (
|
||||
<button
|
||||
key={`${row.path}-${row.sources.join("-")}`}
|
||||
className={`workspace-change${selectedPath === row.path ? " workspace-change--active" : ""}${deleted ? " workspace-change--disabled" : ""}`}
|
||||
draggable
|
||||
onDragStart={(event) => startTreeDrag(event, row.path, false)}
|
||||
onContextMenu={(event) => openTreeMenu(event, row.path, false)}
|
||||
onClick={() => {
|
||||
if (!deleted) selectFile(row.path);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<FileText size={14} className="workspace-tree__icon" />
|
||||
<span className="workspace-change__body">
|
||||
<span className="workspace-change__name">{basename(row.path)}</span>
|
||||
<span className="workspace-change__path">{row.path}</span>
|
||||
<span className="workspace-change__detail">{changeDetail(row)}</span>
|
||||
</span>
|
||||
<span className="workspace-change__meta">
|
||||
{row.gitStatus && <span className="workspace-change__badge workspace-change__badge--git">{row.gitStatus}</span>}
|
||||
{deleted && <span className="workspace-change__badge">{t("workspace.deleted")}</span>}
|
||||
{row.sources.includes("session") && <span className="workspace-change__badge">{t("workspace.sourceSession")}</span>}
|
||||
{row.sources.includes("git") && <span className="workspace-change__badge">{t("workspace.sourceGit")}</span>}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const renderRows = (dir: string, depth: number): JSX.Element[] => {
|
||||
const entries = entriesByDir[dir] ?? [];
|
||||
return entries.flatMap((entry) => {
|
||||
@@ -637,7 +631,17 @@ export function WorkspacePanel({
|
||||
className={`workspace-tree__row${active ? " workspace-tree__row--active" : ""}`}
|
||||
draggable
|
||||
onDragStart={(event) => startTreeDrag(event, path, entry.isDir)}
|
||||
onClick={() => (entry.isDir ? toggleDir(path) : selectFile(path))}
|
||||
onClick={() => {
|
||||
if (entry.isDir) {
|
||||
toggleDir(path);
|
||||
} else {
|
||||
if (selectedPath === path) {
|
||||
setSelectedPath(null);
|
||||
} else {
|
||||
selectFile(path);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onContextMenu={(event) => openTreeMenu(event, path, entry.isDir)}
|
||||
style={{ paddingLeft: 8 + depth * 14 }}
|
||||
>
|
||||
@@ -792,7 +796,101 @@ export function WorkspacePanel({
|
||||
</div>
|
||||
|
||||
<div className="workspace-preview__body" ref={previewBodyRef} onContextMenu={openSelectionMenu}>
|
||||
{!selectedPath ? (
|
||||
{viewMode === "changed" && !selectedPath ? (
|
||||
<div className="workspace-git-history">
|
||||
{loadingHistory ? (
|
||||
<div className="workspace-empty">{t("workspace.loading")}</div>
|
||||
) : gitHistory.length === 0 ? (
|
||||
<div className="workspace-empty">{t("workspace.noChanges")}</div>
|
||||
) : (
|
||||
<div className="workspace-git-history__list">
|
||||
{gitHistory.map((commit) => (
|
||||
<div key={commit.hash} className={`workspace-git-history__item${expandedCommit === commit.hash ? " workspace-git-history__item--expanded" : ""}`}>
|
||||
<button
|
||||
className="workspace-git-history__head"
|
||||
onClick={() => void toggleCommit(commit.hash)}
|
||||
>
|
||||
<div className="workspace-git-history__head-top">
|
||||
{expandedCommit === commit.hash ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<span className="workspace-git-history__message">{commit.message}</span>
|
||||
</div>
|
||||
<div className="workspace-git-history__head-bottom">
|
||||
<span className="workspace-git-history__author">{commit.author}</span>
|
||||
<span className="workspace-git-history__date">
|
||||
{formatCommitDate(commit.date)} <span className="workspace-git-history__hash">{commit.hash.substring(0, 7)}</span>
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{expandedCommit === commit.hash && (
|
||||
<div className="workspace-git-history__detail">
|
||||
{loadingCommit ? (
|
||||
<div className="workspace-empty">{t("workspace.loading")}</div>
|
||||
) : commitDetail?.diff ? (
|
||||
<CodeViewer value={cleanGitDiff(commitDetail.diff)} language="diff" />
|
||||
) : commitDetail?.files ? (
|
||||
<div className="workspace-git-history__files">
|
||||
{commitDetail.files.map((file) => (
|
||||
<button
|
||||
key={file}
|
||||
className="workspace-git-history__file"
|
||||
onClick={() => selectFile(file)}
|
||||
>
|
||||
<FileText size={14} /> {file}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="workspace-empty">No details available</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : viewMode === "changed" && selectedPath ? (
|
||||
<div className="workspace-git-history">
|
||||
{loadingHistory ? (
|
||||
<div className="workspace-empty">{t("workspace.loading")}</div>
|
||||
) : gitHistory.length === 0 ? (
|
||||
<div className="workspace-empty">{t("workspace.noChanges")}</div>
|
||||
) : (
|
||||
<div className="workspace-git-history__list">
|
||||
{gitHistory.map((commit) => (
|
||||
<div key={commit.hash} className={`workspace-git-history__item${expandedCommit === commit.hash ? " workspace-git-history__item--expanded" : ""}`}>
|
||||
<button
|
||||
className="workspace-git-history__head"
|
||||
onClick={() => void toggleCommit(commit.hash)}
|
||||
>
|
||||
<div className="workspace-git-history__head-top">
|
||||
{expandedCommit === commit.hash ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<span className="workspace-git-history__message">{commit.message}</span>
|
||||
</div>
|
||||
<div className="workspace-git-history__head-bottom">
|
||||
<span className="workspace-git-history__author">{commit.author}</span>
|
||||
<span className="workspace-git-history__date">
|
||||
{formatCommitDate(commit.date)} <span className="workspace-git-history__hash">{commit.hash.substring(0, 7)}</span>
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{expandedCommit === commit.hash && (
|
||||
<div className="workspace-git-history__detail">
|
||||
{loadingCommit ? (
|
||||
<div className="workspace-empty">{t("workspace.loading")}</div>
|
||||
) : commitDetail?.diff ? (
|
||||
<CodeViewer value={cleanGitDiff(commitDetail.diff)} language="diff" />
|
||||
) : (
|
||||
<div className="workspace-empty">No details available</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : !selectedPath ? (
|
||||
<div className="workspace-empty">{t("workspace.pickFile")}</div>
|
||||
) : loadingPreview ? (
|
||||
<div className="workspace-empty">{t("workspace.loading")}</div>
|
||||
@@ -887,7 +985,7 @@ export function WorkspacePanel({
|
||||
className={viewMode === "changed" ? "workspace-files__tab workspace-files__tab--active" : "workspace-files__tab"}
|
||||
onClick={() => {
|
||||
setViewMode("changed");
|
||||
void loadChanges();
|
||||
void loadGitHistory();
|
||||
}}
|
||||
>
|
||||
<GitBranch size={13} />
|
||||
@@ -897,7 +995,7 @@ export function WorkspacePanel({
|
||||
)}
|
||||
{showViewTabs && (
|
||||
<Tooltip label={t("workspace.refreshChanges")}>
|
||||
<button className="workspace-iconbtn" onClick={() => void loadChanges()}>
|
||||
<button className="workspace-iconbtn" onClick={() => void loadGitHistory()}>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
@@ -909,13 +1007,8 @@ export function WorkspacePanel({
|
||||
<Search size={14} />
|
||||
<input ref={filterRef} value={filter} onChange={(e) => setFilter(e.target.value)} placeholder={searchPlaceholder} />
|
||||
</div>
|
||||
{viewMode === "changed" && changes && !changes.gitAvailable && changes.gitErr && (
|
||||
<div className="workspace-note workspace-note--compact">{t("workspace.gitUnavailable")}</div>
|
||||
)}
|
||||
<div className="workspace-tree" onContextMenu={openTreeBlankMenu}>
|
||||
{viewMode === "changed"
|
||||
? renderChangedRows()
|
||||
: flattened
|
||||
{flattened
|
||||
? flattened.map(({ path, entry }) => {
|
||||
const dir = parentPath(path);
|
||||
return (
|
||||
@@ -924,7 +1017,17 @@ export function WorkspacePanel({
|
||||
className={`workspace-tree__row workspace-tree__row--search${selectedPath === path ? " workspace-tree__row--active" : ""}`}
|
||||
draggable
|
||||
onDragStart={(event) => startTreeDrag(event, path, entry.isDir)}
|
||||
onClick={() => (entry.isDir ? toggleDir(path) : selectFile(path))}
|
||||
onClick={() => {
|
||||
if (entry.isDir) {
|
||||
toggleDir(path);
|
||||
} else {
|
||||
if (selectedPath === path) {
|
||||
setSelectedPath(null);
|
||||
} else {
|
||||
selectFile(path);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onContextMenu={(event) => openTreeMenu(event, path, entry.isDir)}
|
||||
>
|
||||
{entry.isDir ? (
|
||||
|
||||
@@ -58,6 +58,11 @@ export class DedupIndex {
|
||||
this.paths.add(path);
|
||||
}
|
||||
|
||||
forget(hash: string, path: string): void {
|
||||
if (hash) this.hashes.delete(hash);
|
||||
this.paths.delete(path);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.hashes.clear();
|
||||
this.paths.clear();
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
const attachmentRefRe = /@(\.reasonix\/attachments\/[^\s]+)/g;
|
||||
const referenceRefRe = /(^|\s)@([^\s]+)/g;
|
||||
const trailingPunctuationRe = /[.,;!?)\]},。;!?)】]+$/;
|
||||
|
||||
export interface DisplayAttachment {
|
||||
path: string;
|
||||
name: string;
|
||||
kind: "image" | "file" | "folder";
|
||||
source: "attachment" | "workspace";
|
||||
ext: string;
|
||||
}
|
||||
|
||||
function splitTrailingPunctuation(token: string): { core: string; suffix: string } {
|
||||
const m = token.match(trailingPunctuationRe);
|
||||
if (!m || m.index === undefined) return { core: token, suffix: "" };
|
||||
return { core: token.slice(0, m.index), suffix: m[0] };
|
||||
}
|
||||
|
||||
function baseName(path: string): string {
|
||||
const clean = path.replace(/\/+$/, "");
|
||||
const idx = clean.lastIndexOf("/");
|
||||
return idx >= 0 ? clean.slice(idx + 1) : clean;
|
||||
}
|
||||
|
||||
function isImageAttachmentRef(path: string): boolean {
|
||||
const ext = attachmentExt(path);
|
||||
switch (ext) {
|
||||
case ".png":
|
||||
case ".jpg":
|
||||
case ".jpeg":
|
||||
case ".gif":
|
||||
case ".webp":
|
||||
case ".bmp":
|
||||
case ".svg":
|
||||
case ".tif":
|
||||
case ".tiff":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function attachmentExt(path: string): string {
|
||||
const name = baseName(path).toLowerCase();
|
||||
const dot = name.lastIndexOf(".");
|
||||
return dot >= 0 ? name.slice(dot) : "";
|
||||
}
|
||||
|
||||
export function replaceAttachmentRefsForDisplay(text: string): string {
|
||||
return text.replace(attachmentRefRe, (_full, token: string) => {
|
||||
const { core, suffix } = splitTrailingPunctuation(token);
|
||||
if (!core) return _full;
|
||||
if (isImageAttachmentRef(core)) return `[image]${suffix}`;
|
||||
const name = baseName(core) || "attachment";
|
||||
return `[file:${name}]${suffix}`;
|
||||
});
|
||||
}
|
||||
|
||||
export function parseAttachmentRefsForDisplay(text: string): { text: string; attachments: DisplayAttachment[] } {
|
||||
const attachments: DisplayAttachment[] = [];
|
||||
const cleaned = text
|
||||
.replace(referenceRefRe, (_full, lead: string, token: string) => {
|
||||
const { core, suffix } = splitTrailingPunctuation(token);
|
||||
if (!core || !isDisplayReference(core)) return _full;
|
||||
const name = baseName(core) || "attachment";
|
||||
const attachment = displayAttachment(core, name);
|
||||
attachments.push(attachment);
|
||||
return lead + suffix;
|
||||
})
|
||||
.replace(/[ \t]+([.,;!?)\]},。;!?)】])/g, "$1")
|
||||
.replace(/[ \t]{2,}/g, " ")
|
||||
.trim();
|
||||
return { text: cleaned, attachments };
|
||||
}
|
||||
|
||||
function isDisplayReference(path: string): boolean {
|
||||
if (path.startsWith(".reasonix/attachments/")) return true;
|
||||
if (path.endsWith("/")) return true;
|
||||
if (path.includes("/")) return true;
|
||||
return attachmentExt(path) !== "";
|
||||
}
|
||||
|
||||
function displayAttachment(path: string, name: string): DisplayAttachment {
|
||||
if (path.startsWith(".reasonix/attachments/")) {
|
||||
const kind = isImageAttachmentRef(path) ? "image" : "file";
|
||||
return {
|
||||
path,
|
||||
name,
|
||||
kind,
|
||||
source: "attachment",
|
||||
ext: attachmentExt(path).replace(/^\./, "").toUpperCase(),
|
||||
};
|
||||
}
|
||||
const isDir = path.endsWith("/");
|
||||
return {
|
||||
path,
|
||||
name,
|
||||
kind: isDir ? "folder" : "file",
|
||||
source: "workspace",
|
||||
ext: isDir ? "" : attachmentExt(path).replace(/^\./, "").toUpperCase(),
|
||||
};
|
||||
}
|
||||
@@ -42,6 +42,8 @@ import type {
|
||||
UpdateProgress,
|
||||
WireEvent,
|
||||
WorkspaceChangesView,
|
||||
GitCommitView,
|
||||
GitCommitDetailView,
|
||||
WorkspaceView,
|
||||
} from "./types";
|
||||
|
||||
@@ -86,7 +88,9 @@ export interface AppBindings {
|
||||
Cancel(): Promise<void>;
|
||||
CancelTab(tabID: string): Promise<void>;
|
||||
Approve(id: string, allow: boolean, session: boolean, persist: boolean): Promise<void>;
|
||||
ApproveWithScope(id: string, allow: boolean, session: boolean, persist: boolean, scope: string): Promise<void>;
|
||||
ApproveTab(tabID: string, id: string, allow: boolean, session: boolean, persist: boolean): Promise<void>;
|
||||
ApproveTabWithScope(tabID: string, id: string, allow: boolean, session: boolean, persist: boolean, scope: string): Promise<void>;
|
||||
AnswerQuestion(id: string, answers: QuestionAnswer[]): Promise<void>;
|
||||
AnswerQuestionForTab(tabID: string, id: string, answers: QuestionAnswer[]): Promise<void>;
|
||||
SetPlanMode(on: boolean): Promise<void>;
|
||||
@@ -142,6 +146,10 @@ export interface AppBindings {
|
||||
SearchFileRefs(query: string): Promise<DirEntry[]>;
|
||||
ReadFile(rel: string): Promise<FilePreview>;
|
||||
WorkspaceChanges(): Promise<WorkspaceChangesView>;
|
||||
GitBranches(): Promise<string[]>;
|
||||
GitCheckout(branch: string): Promise<void>;
|
||||
WorkspaceGitHistory(path: string): Promise<GitCommitView[]>;
|
||||
WorkspaceGitCommitDetail(hash: string, path: string): Promise<GitCommitDetailView>;
|
||||
OpenWorkspacePath(rel: string): Promise<void>;
|
||||
RevealWorkspacePath(rel: string): Promise<void>;
|
||||
RevealPath(path: string): Promise<void>;
|
||||
@@ -183,7 +191,7 @@ export interface AppBindings {
|
||||
SetDesktopLanguage(lang: string): Promise<void>;
|
||||
SetDesktopAppearance(theme: string, style: string): Promise<void>;
|
||||
MigrateDesktopPreferences(language: string, theme: string, style: string): Promise<void>;
|
||||
SetAgentParams(temperature: number, maxSteps: number, systemPrompt: string): Promise<void>;
|
||||
SetAgentParams(temperature: number, maxSteps: number, plannerMaxSteps: number, systemPrompt: string): Promise<void>;
|
||||
SetTrayLocale(locale: "en" | "zh"): Promise<void>;
|
||||
// SetBypass toggles YOLO mode (auto-approve every tool call this session; deny
|
||||
// rules still apply). Runtime-only — not written to config.
|
||||
@@ -288,10 +296,32 @@ export function onUpdaterProgress(cb: (p: UpdateProgress) => void): () => void {
|
||||
export function onFilesDropped(cb: (paths: string[]) => void): () => void {
|
||||
const rt = typeof window !== "undefined" ? window.runtime : undefined;
|
||||
if (!rt?.OnFileDrop) return () => {};
|
||||
|
||||
// Wails' internal ResolveFilePaths throws when a non-file object (e.g. the
|
||||
// window icon) is dragged onto the webview. The error is uncaught and crashes
|
||||
// the app. Intercept it here so only real file drops reach the callback.
|
||||
const suppressNonFileDragError = (e: ErrorEvent) => {
|
||||
if (e.message?.includes("additional File object is not a file on the disk")) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
const suppressNonFileDragRejection = (e: PromiseRejectionEvent) => {
|
||||
const msg = e.reason?.message ?? String(e.reason);
|
||||
if (msg.includes("additional File object is not a file on the disk")) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
window.addEventListener("error", suppressNonFileDragError);
|
||||
window.addEventListener("unhandledrejection", suppressNonFileDragRejection);
|
||||
|
||||
rt.OnFileDrop((_x, _y, paths) => {
|
||||
if (Array.isArray(paths) && paths.length > 0) cb(paths);
|
||||
}, true);
|
||||
return () => rt.OnFileDropOff?.();
|
||||
return () => {
|
||||
rt.OnFileDropOff?.();
|
||||
window.removeEventListener("error", suppressNonFileDragError);
|
||||
window.removeEventListener("unhandledrejection", suppressNonFileDragRejection);
|
||||
};
|
||||
}
|
||||
|
||||
// onReady subscribes to the agent:ready event fired when boot.Build completes.
|
||||
@@ -555,7 +585,7 @@ function makeMockApp(): AppBindings {
|
||||
{ name: "mimo-api", builtIn: true, added: false, kind: "openai", baseUrl: "https://api.xiaomimimo.com/v1", modelsUrl: "", models: ["mimo-v2.5-pro"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_API_KEY", keySet: false, balanceUrl: "", contextWindow: 1_048_576, reasoningProtocol: "", supportedEfforts: [], defaultEffort: "" },
|
||||
{ name: "mimo-token-plan", builtIn: true, added: false, kind: "openai", baseUrl: "https://token-plan-cn.xiaomimimo.com/v1", modelsUrl: "", models: ["mimo-v2.5-pro"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_API_KEY", keySet: false, balanceUrl: "", contextWindow: 1_048_576, reasoningProtocol: "", supportedEfforts: [], defaultEffort: "" },
|
||||
],
|
||||
permissions: { mode: "ask", allow: ["ls", "read_file"], ask: [], deny: ["bash(rm *)"] },
|
||||
permissions: { mode: "ask", allow: ["ls", "read_file"], ask: [], deny: ["Bash(rm:*)"] },
|
||||
sandbox: { bash: "enforce", network: true, workspaceRoot: "", allowWrite: [] },
|
||||
network: {
|
||||
proxyMode: "auto",
|
||||
@@ -563,7 +593,7 @@ function makeMockApp(): AppBindings {
|
||||
noProxy: "",
|
||||
proxy: { type: "socks5", server: "127.0.0.1", port: 7890, username: "", password: "" },
|
||||
},
|
||||
agent: { temperature: 0.2, maxSteps: 0, systemPrompt: "You are Reasonix, a coding agent." },
|
||||
agent: { temperature: 0.2, maxSteps: 0, plannerMaxSteps: 12, systemPrompt: "You are Reasonix, a coding agent." },
|
||||
desktopLanguage: "",
|
||||
desktopTheme: "dark",
|
||||
desktopThemeStyle: "graphite",
|
||||
@@ -917,17 +947,24 @@ function makeMockApp(): AppBindings {
|
||||
await this.Cancel();
|
||||
},
|
||||
async Approve(_id, allow, session, persist) {
|
||||
await this.ApproveWithScope(_id, allow, session, persist, "");
|
||||
},
|
||||
async ApproveWithScope(_id, allow, session, persist, scope) {
|
||||
if (!pendingApprovalPreview) return;
|
||||
pendingApprovalPreview = false;
|
||||
const suffix = persist ? "persisted" : session ? "allowed for session" : "allowed once";
|
||||
emit({
|
||||
kind: "message",
|
||||
text: `approval preview answered: ${allow ? suffix : "denied"}`,
|
||||
});
|
||||
pendingApprovalPreview = false;
|
||||
const scopeLabel = scope === "prefix" ? "prefix" : "scope";
|
||||
const suffix = persist ? `${scopeLabel} grant saved` : session ? `${scopeLabel} grant active this session` : "allowed once";
|
||||
emit({
|
||||
kind: "message",
|
||||
text: `approval preview answered: ${allow ? suffix : "denied"}`,
|
||||
});
|
||||
emit({ kind: "turn_done" });
|
||||
},
|
||||
async ApproveTab(_tabID, id, allow, session, persist) {
|
||||
await this.Approve(id, allow, session, persist);
|
||||
await this.ApproveTabWithScope(_tabID, id, allow, session, persist, "");
|
||||
},
|
||||
async ApproveTabWithScope(_tabID, id, allow, session, persist, scope) {
|
||||
await this.ApproveWithScope(id, allow, session, persist, scope);
|
||||
},
|
||||
async AnswerQuestion(_id, answers) {
|
||||
if (!pendingAskPreview) return;
|
||||
@@ -1328,6 +1365,7 @@ function makeMockApp(): AppBindings {
|
||||
async WorkspaceChanges() {
|
||||
return {
|
||||
gitAvailable: true,
|
||||
gitBranch: "main",
|
||||
files: [
|
||||
{
|
||||
path: "desktop/frontend/src/components/WorkspacePanel.tsx",
|
||||
@@ -1342,6 +1380,23 @@ function makeMockApp(): AppBindings {
|
||||
],
|
||||
};
|
||||
},
|
||||
async GitBranches() {
|
||||
return ["main", "dev", "feature/branch-switcher"];
|
||||
},
|
||||
async GitCheckout(_branch: string) {
|
||||
console.info("mock GitCheckout", _branch);
|
||||
},
|
||||
async WorkspaceGitHistory(path: string) {
|
||||
return [
|
||||
{ hash: "abcdef123456", author: "Mock Author", date: new Date().toISOString(), message: "Mock commit message for " + path },
|
||||
];
|
||||
},
|
||||
async WorkspaceGitCommitDetail(_hash: string, path: string) {
|
||||
if (path) {
|
||||
return { diff: "--- a/mock\n+++ b/mock\n@@ -1,1 +1,1 @@\n-mock\n+mock diff" };
|
||||
}
|
||||
return { files: ["mock_file_1.ts", "mock_file_2.ts"] };
|
||||
},
|
||||
async OpenWorkspacePath(rel: string) {
|
||||
console.info("mock OpenWorkspacePath", rel);
|
||||
},
|
||||
@@ -1526,8 +1581,8 @@ function makeMockApp(): AppBindings {
|
||||
settings.desktopThemeStyle = style;
|
||||
}
|
||||
},
|
||||
async SetAgentParams(temperature: number, maxSteps: number, systemPrompt: string) {
|
||||
settings.agent = { temperature, maxSteps, systemPrompt };
|
||||
async SetAgentParams(temperature: number, maxSteps: number, plannerMaxSteps: number, systemPrompt: string) {
|
||||
settings.agent = { temperature, maxSteps, plannerMaxSteps, systemPrompt };
|
||||
},
|
||||
async SetTrayLocale(_locale: "en" | "zh") {},
|
||||
async SetBypass(on: boolean) {
|
||||
|
||||
@@ -34,3 +34,34 @@ export function diffLines(a: string, b: string): DiffRow[] {
|
||||
while (j < m) rows.push({ type: "add", text: y[j++] });
|
||||
return rows;
|
||||
}
|
||||
|
||||
// cleanGitDiff strips standard git diff headers (diff --git, index, ---, +++)
|
||||
// and hunk headers (@@ -x,y +x,y @@) so the view focuses directly on the changed lines.
|
||||
export function cleanGitDiff(diff: string): string {
|
||||
const lines = diff.split("\n");
|
||||
const cleaned: string[] = [];
|
||||
let inHunk = false;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("@@ ")) {
|
||||
inHunk = true;
|
||||
// Skip the @@ line itself, optionally we could keep context if needed,
|
||||
// but the user wants pure code changes.
|
||||
continue;
|
||||
}
|
||||
if (inHunk) {
|
||||
cleaned.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
// If no hunks were found (unlikely for a valid diff), fallback to original logic
|
||||
if (cleaned.length === 0) {
|
||||
const match = diff.match(/^@@\s/m);
|
||||
if (match && match.index !== undefined) {
|
||||
return diff.slice(match.index).replace(/^@@.*$\n?/gm, "");
|
||||
}
|
||||
return diff;
|
||||
}
|
||||
|
||||
return cleaned.join("\n");
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
import hljs from "highlight.js/lib/core";
|
||||
import bash from "highlight.js/lib/languages/bash";
|
||||
import css from "highlight.js/lib/languages/css";
|
||||
import diff from "highlight.js/lib/languages/diff";
|
||||
import go from "highlight.js/lib/languages/go";
|
||||
import javascript from "highlight.js/lib/languages/javascript";
|
||||
import json from "highlight.js/lib/languages/json";
|
||||
@@ -20,6 +21,7 @@ import { ALIASES } from "./lang";
|
||||
|
||||
hljs.registerLanguage("bash", bash);
|
||||
hljs.registerLanguage("css", css);
|
||||
hljs.registerLanguage("diff", diff);
|
||||
hljs.registerLanguage("go", go);
|
||||
hljs.registerLanguage("javascript", javascript);
|
||||
hljs.registerLanguage("json", json);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
export function mergedFetchedProviderModels(current: string[], fetched: string[], options: { preserveCurated?: boolean } = {}): string[] {
|
||||
const saved = uniqueStrings(current);
|
||||
if (options.preserveCurated && saved.length > 0) return saved;
|
||||
return uniqueStrings([...saved, ...fetched]);
|
||||
}
|
||||
|
||||
export function providerDefaultModel(currentDefault: string, models: string[]): string {
|
||||
return currentDefault && models.includes(currentDefault) ? currentDefault : models[0] ?? "";
|
||||
}
|
||||
|
||||
function uniqueStrings(values: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const value of values) {
|
||||
const model = value.trim();
|
||||
if (!model || seen.has(model)) continue;
|
||||
seen.add(model);
|
||||
out.push(model);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Coalesces text/reasoning stream deltas into one flush per animation frame.
|
||||
// Non-text events must drain() first so causal ordering is preserved.
|
||||
|
||||
type Flush<T> = (batch: T[]) => void;
|
||||
|
||||
interface BatchHandle<T> {
|
||||
push: (item: T) => void;
|
||||
drain: () => void;
|
||||
size: () => number;
|
||||
}
|
||||
|
||||
export function createRafBatch<T>(flush: Flush<T>): BatchHandle<T> {
|
||||
let buffer: T[] = [];
|
||||
let scheduled: number | null = null;
|
||||
|
||||
const run = () => {
|
||||
scheduled = null;
|
||||
// Snapshot + clear before flushing so a re-entrant push() lands next frame.
|
||||
const out = buffer;
|
||||
buffer = [];
|
||||
if (out.length > 0) flush(out);
|
||||
};
|
||||
|
||||
const handle: BatchHandle<T> = {
|
||||
push(item: T) {
|
||||
buffer.push(item);
|
||||
if (scheduled === null && typeof requestAnimationFrame !== "undefined") {
|
||||
scheduled = requestAnimationFrame(run);
|
||||
} else if (scheduled === null) {
|
||||
// No rAF (SSR / JSDOM) — fall back to a microtask.
|
||||
scheduled = 1;
|
||||
Promise.resolve().then(run);
|
||||
}
|
||||
},
|
||||
drain() {
|
||||
if (scheduled !== null) {
|
||||
if (typeof cancelAnimationFrame !== "undefined" && scheduled !== 1) {
|
||||
cancelAnimationFrame(scheduled);
|
||||
}
|
||||
scheduled = null;
|
||||
}
|
||||
run();
|
||||
},
|
||||
size() {
|
||||
return buffer.length;
|
||||
},
|
||||
};
|
||||
return handle;
|
||||
}
|
||||
@@ -10,6 +10,12 @@ export function isTextSize(value: unknown): value is TextSize {
|
||||
return typeof value === "string" && (TEXT_SIZES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function nextTextSize(current: TextSize, delta: -1 | 1): TextSize {
|
||||
const index = TEXT_SIZES.indexOf(current);
|
||||
const nextIndex = Math.min(TEXT_SIZES.length - 1, Math.max(0, index + delta));
|
||||
return TEXT_SIZES[nextIndex];
|
||||
}
|
||||
|
||||
export function getTextSize(): TextSize {
|
||||
const stored = typeof localStorage !== "undefined" ? localStorage.getItem(TEXT_SIZE_KEY) : null;
|
||||
return isTextSize(stored) ? stored : DEFAULT_TEXT_SIZE;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
text: string;
|
||||
level: "info" | "warn" | "error";
|
||||
}
|
||||
|
||||
export interface ToastContextValue {
|
||||
toasts: Toast[];
|
||||
showToast: (text: string, level?: Toast["level"]) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue>({ toasts: [], showToast: () => {} });
|
||||
|
||||
export function useToast() {
|
||||
return useContext(ToastContext);
|
||||
}
|
||||
|
||||
let nextId = 1;
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const timers = useRef(new Map<number, ReturnType<typeof setTimeout>>());
|
||||
|
||||
const showToast = useCallback((text: string, level: Toast["level"] = "info") => {
|
||||
const id = nextId++;
|
||||
setToasts((prev) => [...prev, { id, text, level }]);
|
||||
const timer = setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
timers.current.delete(id);
|
||||
}, 2500);
|
||||
timers.current.set(id, timer);
|
||||
}, []);
|
||||
|
||||
const dismissToast = useCallback((id: number) => {
|
||||
const timer = timers.current.get(id);
|
||||
if (timer) clearTimeout(timer);
|
||||
timers.current.delete(id);
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toasts, showToast }}>
|
||||
{children}
|
||||
<div className="toast-container" role="status" aria-live="polite">
|
||||
{toasts.map((t) => (
|
||||
<div key={t.id} className={`toast toast--${t.level}`} onClick={() => dismissToast(t.id)}>
|
||||
{t.level === "warn" && <span className="toast__icon">⚠️</span>}
|
||||
{t.level === "error" && <span className="toast__icon">❌</span>}
|
||||
<span className="toast__text">{t.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -241,6 +241,16 @@ export interface SessionMeta {
|
||||
topicTitle?: string;
|
||||
}
|
||||
|
||||
// SessionReference is a session selected via @ past:chats for context injection.
|
||||
export interface SessionReference {
|
||||
path: string;
|
||||
title: string;
|
||||
preview?: string;
|
||||
turns?: number;
|
||||
createdAt?: number;
|
||||
lastActivityAt?: number;
|
||||
}
|
||||
|
||||
export interface WorkspaceView {
|
||||
path: string;
|
||||
name: string;
|
||||
@@ -311,6 +321,19 @@ export interface WorkspaceChangesView {
|
||||
files: WorkspaceChangeView[];
|
||||
gitAvailable: boolean;
|
||||
gitErr?: string;
|
||||
gitBranch?: string;
|
||||
}
|
||||
|
||||
export interface GitCommitView {
|
||||
hash: string;
|
||||
author: string;
|
||||
date: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface GitCommitDetailView {
|
||||
diff?: string;
|
||||
files?: string[];
|
||||
}
|
||||
|
||||
export interface ComposerInsertRequest {
|
||||
@@ -510,6 +533,7 @@ export interface NetworkView {
|
||||
export interface AgentView {
|
||||
temperature: number;
|
||||
maxSteps: number;
|
||||
plannerMaxSteps: number;
|
||||
systemPrompt: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { asArray } from "./array";
|
||||
import { app, onEvent, onReady } from "./bridge";
|
||||
import { createRafBatch } from "./rafBatch";
|
||||
import { t } from "./i18n";
|
||||
import type {
|
||||
BalanceInfo,
|
||||
@@ -561,10 +562,18 @@ export function useController() {
|
||||
}, [dispatchTo, loadSessionDataForTab]);
|
||||
|
||||
useEffect(() => {
|
||||
const textBatch = createRafBatch<{ tabId: string; e: WireEvent }>((batch) => {
|
||||
for (const { tabId, e } of batch) dispatchTo(tabId, { type: "event", e });
|
||||
});
|
||||
const off = onEvent((e) => {
|
||||
const targetTabId = e.tabId || activeTabIdRef.current;
|
||||
if (!targetTabId) return;
|
||||
dispatchTo(targetTabId, { type: "event", e });
|
||||
if (e.kind === "text" || e.kind === "reasoning") {
|
||||
textBatch.push({ tabId: targetTabId, e });
|
||||
} else {
|
||||
textBatch.drain();
|
||||
dispatchTo(targetTabId, { type: "event", e });
|
||||
}
|
||||
if (e.kind === "turn_done") {
|
||||
app
|
||||
.ContextUsageForTab(targetTabId)
|
||||
@@ -590,7 +599,7 @@ export function useController() {
|
||||
|
||||
void syncActiveTabFromBackend();
|
||||
|
||||
return () => { off(); offReady(); };
|
||||
return () => { textBatch.drain(); off(); offReady(); };
|
||||
}, [dispatchTo, loadSessionDataForTab, refreshCheckpoints, syncActiveTabFromBackend]);
|
||||
|
||||
const send = useCallback((displayText: string, submitText = displayText) => {
|
||||
@@ -627,10 +636,10 @@ export function useController() {
|
||||
return undefined;
|
||||
}, [activeTabId, dispatchTo]);
|
||||
|
||||
const approve = useCallback((id: string, allow: boolean, session: boolean, persist: boolean) => {
|
||||
const approve = useCallback((id: string, allow: boolean, session: boolean, persist: boolean, scope = "") => {
|
||||
if (!activeTabId) return;
|
||||
dispatchTo(activeTabId, { type: "clearApproval" });
|
||||
app.ApproveTab(activeTabId, id, allow, session, persist).catch(() => {});
|
||||
app.ApproveTabWithScope(activeTabId, id, allow, session, persist, scope).catch(() => {});
|
||||
}, [activeTabId, dispatchTo]);
|
||||
|
||||
const answerQuestion = useCallback((id: string, answers: QuestionAnswer[]) => {
|
||||
@@ -662,8 +671,9 @@ export function useController() {
|
||||
const messages = asArray(
|
||||
await (tabId ? app.ResumeSessionForTab(tabId, path) : app.ResumeSession(path)).catch(() => [] as HistoryMessage[]),
|
||||
);
|
||||
if (messages.length === 0) return;
|
||||
dispatchTo(targetTabId, { type: "reset" });
|
||||
if (messages.length) dispatchTo(targetTabId, { type: "history", messages });
|
||||
dispatchTo(targetTabId, { type: "history", messages });
|
||||
app.ContextUsageForTab(targetTabId).then((context) => dispatchTo(targetTabId, { type: "context", context })).catch(() => {});
|
||||
void refreshCheckpoints(targetTabId);
|
||||
}, [activeTabId, dispatchTo, refreshCheckpoints, waitForTabReady]);
|
||||
@@ -773,22 +783,18 @@ export function useController() {
|
||||
} catch { /* ignore */ }
|
||||
}, [reconcileTabRuntime]);
|
||||
|
||||
const openProjectTab = useCallback(async (workspaceRoot: string, topicId: string): Promise<TabMeta | undefined> => {
|
||||
try {
|
||||
const meta = await app.OpenProjectTab(workspaceRoot, topicId);
|
||||
setActiveTabId(meta.id);
|
||||
await loadSessionDataForTab(meta.id, !statesRef.current.has(meta.id));
|
||||
return meta;
|
||||
} catch { return undefined; }
|
||||
const openProjectTab = useCallback(async (workspaceRoot: string, topicId: string): Promise<TabMeta> => {
|
||||
const meta = await app.OpenProjectTab(workspaceRoot, topicId);
|
||||
setActiveTabId(meta.id);
|
||||
await loadSessionDataForTab(meta.id, !statesRef.current.has(meta.id));
|
||||
return meta;
|
||||
}, [loadSessionDataForTab]);
|
||||
|
||||
const openGlobalTab = useCallback(async (topicId: string): Promise<TabMeta | undefined> => {
|
||||
try {
|
||||
const meta = await app.OpenGlobalTab(topicId);
|
||||
setActiveTabId(meta.id);
|
||||
await loadSessionDataForTab(meta.id, !statesRef.current.has(meta.id));
|
||||
return meta;
|
||||
} catch { return undefined; }
|
||||
const openGlobalTab = useCallback(async (topicId: string): Promise<TabMeta> => {
|
||||
const meta = await app.OpenGlobalTab(topicId);
|
||||
setActiveTabId(meta.id);
|
||||
await loadSessionDataForTab(meta.id, !statesRef.current.has(meta.id));
|
||||
return meta;
|
||||
}, [loadSessionDataForTab]);
|
||||
|
||||
const closeTab = useCallback(async (tabId: string) => {
|
||||
|
||||
@@ -300,8 +300,16 @@ export const en = {
|
||||
"approval.hideDetails": "Hide",
|
||||
"approval.deny": "Deny",
|
||||
"approval.allowOnce": "Allow once",
|
||||
"approval.allowSession": "Allow for session",
|
||||
"approval.allowPersistent": "Always allow (save)",
|
||||
"approval.allowSession": "Allow tool for session",
|
||||
"approval.allowPersistent": "Always allow tool (save)",
|
||||
"approval.allowCommandSession": "Allow command for session",
|
||||
"approval.allowCommandPersistent": "Always allow command (save)",
|
||||
"approval.allowCommandPrefixSession": "Allow command prefix for session",
|
||||
"approval.allowCommandPrefixPersistent": "Always allow command prefix (save)",
|
||||
"approval.allowRuleSession": "Allow for session",
|
||||
"approval.allowRulePersistent": "Always allow (save)",
|
||||
"approval.allowEditsSession": "Allow edits for session",
|
||||
"approval.allowFilePersistent": "Always allow this file (save)",
|
||||
|
||||
// ask card
|
||||
"ask.title": "Need your decision",
|
||||
@@ -356,6 +364,9 @@ export const en = {
|
||||
"history.yesterday": "Yesterday",
|
||||
"history.older": "Older",
|
||||
"history.openSession": "Open session",
|
||||
"history.failedOpenProject": "Cannot open session, \"{name}\" does not exist: {path}",
|
||||
"history.failedOpenSession": "Cannot open session",
|
||||
"history.failedOpenGlobal": "Cannot open global session",
|
||||
"history.selectSession": "Select a session to preview.",
|
||||
"history.trashSessionActions": "Trash session actions",
|
||||
"history.historySessionActions": "History session actions",
|
||||
@@ -457,7 +468,7 @@ export const en = {
|
||||
"settings.tab.appearance": "Appearance",
|
||||
"settings.tab.updates": "Updates",
|
||||
"settings.tabSub.general": "Preferences & startup",
|
||||
"settings.tabSub.models": "Defaults & subagents",
|
||||
"settings.tabSub.models": "Defaults & runtime",
|
||||
"settings.tabSub.providers": "Connections & auth",
|
||||
"settings.tabSub.capabilities": "Servers, tools & skills",
|
||||
"settings.tabSub.mcp": "Servers & tools",
|
||||
@@ -509,6 +520,15 @@ export const en = {
|
||||
"settings.autoPlan": "Automatic plan mode",
|
||||
"settings.autoPlan.off": "Off",
|
||||
"settings.autoPlan.on": "On",
|
||||
"settings.agentRuntime": "Agent runtime",
|
||||
"settings.agentRuntimeHint": "Saved as user defaults. A project's ./reasonix.toml can override them; use 0 for no limit.",
|
||||
"settings.executorMaxSteps": "Executor step limit",
|
||||
"settings.executorMaxStepsHint": "Caps the main agent's tool-call rounds per turn. Unlimited fits larger code tasks.",
|
||||
"settings.plannerMaxSteps": "Planner step limit",
|
||||
"settings.plannerMaxStepsHint": "Caps the dedicated planner's read-only file and code search rounds before execution.",
|
||||
"settings.plannerMaxStepsDisabledHint": "Only applies when a dedicated planner model is selected on the Models page.",
|
||||
"settings.stepLimit.unlimited": "Unlimited",
|
||||
"settings.stepLimit.custom": "Custom",
|
||||
"settings.keySet": "key set",
|
||||
"settings.noKey": "no key",
|
||||
"settings.cantDeleteDefault": "Can't delete the default provider",
|
||||
@@ -605,7 +625,7 @@ export const en = {
|
||||
"settings.fontFamilyPingFang": "PingFang",
|
||||
"settings.fontFamilyNoto": "Noto Sans",
|
||||
"settings.pageDesc.general": "Language, close behavior, and planning defaults.",
|
||||
"settings.pageDesc.models": "Default model, planner model, and provider overview.",
|
||||
"settings.pageDesc.models": "Default model, planner model, runtime limits, and provider overview.",
|
||||
"settings.pageDesc.providers": "Manage model providers, API endpoints, keys, and available models.",
|
||||
"settings.pageDesc.mcp": "Manage MCP server connections and tool discovery.",
|
||||
"settings.pageDesc.skills": "Browse, enable, and manage agent skills and skill sources.",
|
||||
@@ -710,7 +730,6 @@ export const en = {
|
||||
// todo bar
|
||||
"todo.title": "To-dos",
|
||||
"todo.dismiss": "Dismiss the task list",
|
||||
"todo.stale": "Progress may be stale",
|
||||
|
||||
// slash menu tags
|
||||
"slash.project": "project",
|
||||
@@ -722,6 +741,11 @@ export const en = {
|
||||
"msg.thinkingRunning": "thinking…",
|
||||
"msg.thinkingDone": "done",
|
||||
"msg.copy": "Copy",
|
||||
"msg.attachments": "Attachments",
|
||||
"msg.fileAttachment": "File",
|
||||
"msg.imageAttachment": "Image",
|
||||
"msg.workspaceReference": "Workspace reference",
|
||||
"msg.folderReference": "Folder · Workspace reference",
|
||||
"turnActions.summary": "Summary",
|
||||
"turnActions.rewind": "Rewind",
|
||||
"transcript.showEarlierHistory": "Show {n} earlier turns",
|
||||
|
||||
@@ -301,8 +301,16 @@ export const zh: Record<DictKey, string> = {
|
||||
"approval.hideDetails": "收起",
|
||||
"approval.deny": "拒绝",
|
||||
"approval.allowOnce": "允许一次",
|
||||
"approval.allowSession": "本会话内允许",
|
||||
"approval.allowPersistent": "总是允许(保存)",
|
||||
"approval.allowSession": "本会话允许此工具",
|
||||
"approval.allowPersistent": "总是允许此工具(保存)",
|
||||
"approval.allowCommandSession": "本会话允许此命令",
|
||||
"approval.allowCommandPersistent": "总是允许此命令(保存)",
|
||||
"approval.allowCommandPrefixSession": "本会话允许命令前缀",
|
||||
"approval.allowCommandPrefixPersistent": "总是允许命令前缀(保存)",
|
||||
"approval.allowRuleSession": "本会话允许",
|
||||
"approval.allowRulePersistent": "总是允许(保存)",
|
||||
"approval.allowEditsSession": "本会话允许编辑",
|
||||
"approval.allowFilePersistent": "总是允许此文件(保存)",
|
||||
|
||||
// 提问卡片
|
||||
"ask.title": "需要你决定",
|
||||
@@ -357,6 +365,9 @@ export const zh: Record<DictKey, string> = {
|
||||
"history.yesterday": "昨天",
|
||||
"history.older": "更早",
|
||||
"history.openSession": "打开会话",
|
||||
"history.failedOpenProject": "无法打开会话,{name}不存在:{path}",
|
||||
"history.failedOpenSession": "无法打开会话",
|
||||
"history.failedOpenGlobal": "无法打开全局会话",
|
||||
"history.selectSession": "选择一个会话以预览。",
|
||||
"history.trashSessionActions": "回收站会话操作",
|
||||
"history.historySessionActions": "历史会话操作",
|
||||
@@ -459,7 +470,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.tab.appearance": "外观",
|
||||
"settings.tab.updates": "更新",
|
||||
"settings.tabSub.general": "偏好与启动",
|
||||
"settings.tabSub.models": "默认与子代理",
|
||||
"settings.tabSub.models": "默认与运行",
|
||||
"settings.tabSub.providers": "连接与授权",
|
||||
"settings.tabSub.capabilities": "服务器、工具与技能",
|
||||
"settings.tabSub.mcp": "服务器与工具",
|
||||
@@ -511,6 +522,15 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.autoPlan": "自动计划模式",
|
||||
"settings.autoPlan.off": "关闭",
|
||||
"settings.autoPlan.on": "开启",
|
||||
"settings.agentRuntime": "Agent 运行",
|
||||
"settings.agentRuntimeHint": "保存为用户级默认值。项目的 ./reasonix.toml 可以覆盖;设为 0 表示不限。",
|
||||
"settings.executorMaxSteps": "执行轮数上限",
|
||||
"settings.executorMaxStepsHint": "限制主 agent 每轮回复最多调用多少轮工具。复杂代码任务适合不限。",
|
||||
"settings.plannerMaxSteps": "规划轮数上限",
|
||||
"settings.plannerMaxStepsHint": "限制独立规划器在执行前读取文件和搜索代码的只读轮数。",
|
||||
"settings.plannerMaxStepsDisabledHint": "仅在“模型”页选择独立规划模型后生效。",
|
||||
"settings.stepLimit.unlimited": "不限",
|
||||
"settings.stepLimit.custom": "自定义",
|
||||
"settings.keySet": "已设密钥",
|
||||
"settings.noKey": "无密钥",
|
||||
"settings.cantDeleteDefault": "无法删除默认模型服务",
|
||||
@@ -607,7 +627,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.fontFamilyPingFang": "苹方",
|
||||
"settings.fontFamilyNoto": "思源黑体",
|
||||
"settings.pageDesc.general": "语言、关闭行为和计划默认设置。",
|
||||
"settings.pageDesc.models": "默认模型、规划模型与接入概览。",
|
||||
"settings.pageDesc.models": "默认模型、规划模型、运行上限与接入概览。",
|
||||
"settings.pageDesc.providers": "管理模型供应商、API 地址、密钥与可用模型。",
|
||||
"settings.pageDesc.mcp": "管理 MCP 服务器连接和工具发现。",
|
||||
"settings.pageDesc.skills": "浏览、启用和管理智能体技能与来源。",
|
||||
@@ -712,7 +732,6 @@ export const zh: Record<DictKey, string> = {
|
||||
// 待办栏
|
||||
"todo.title": "待办",
|
||||
"todo.dismiss": "关闭待办列表",
|
||||
"todo.stale": "进度可能未同步",
|
||||
|
||||
// 斜杠菜单标签
|
||||
"slash.project": "项目",
|
||||
@@ -724,6 +743,11 @@ export const zh: Record<DictKey, string> = {
|
||||
"msg.thinkingRunning": "思考中…",
|
||||
"msg.thinkingDone": "已完成",
|
||||
"msg.copy": "复制",
|
||||
"msg.attachments": "附件",
|
||||
"msg.fileAttachment": "文件",
|
||||
"msg.imageAttachment": "图片",
|
||||
"msg.workspaceReference": "工作区引用",
|
||||
"msg.folderReference": "文件夹 · 工作区引用",
|
||||
"turnActions.summary": "总结",
|
||||
"turnActions.rewind": "回溯",
|
||||
"transcript.showEarlierHistory": "展开前 {n} 轮对话",
|
||||
|
||||
@@ -4,6 +4,7 @@ import App from "./App";
|
||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||
import { installGlobalCrashHandlers } from "./lib/crash";
|
||||
import { LocaleProvider } from "./lib/i18n";
|
||||
import { ToastProvider } from "./lib/toast";
|
||||
import { initFontFamily } from "./lib/fontFamily";
|
||||
import { initTextSize } from "./lib/textSize";
|
||||
import { initTheme } from "./lib/theme";
|
||||
@@ -53,7 +54,9 @@ createRoot(root).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
<LocaleProvider>
|
||||
<App />
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</LocaleProvider>
|
||||
</ErrorBoundary>
|
||||
</StrictMode>,
|
||||
|
||||
+663
-34
@@ -88,6 +88,7 @@
|
||||
--z-modal: 1200;
|
||||
--z-overlay-backdrop: 1300;
|
||||
--z-popover: 1301;
|
||||
--z-toast: 1302;
|
||||
--z-tooltip: 1302;
|
||||
--z-onboarding: 9999;
|
||||
--z-crash-overlay: 99999;
|
||||
@@ -644,8 +645,9 @@ a[href] {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
transform: translateX(-50%);
|
||||
background: var(--border-soft);
|
||||
transition: background 0.12s, box-shadow 0.12s;
|
||||
}
|
||||
@@ -744,7 +746,7 @@ a[href] {
|
||||
font-weight: 600;
|
||||
}
|
||||
.topbar__model {
|
||||
font-family: var(--mono);
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
color: var(--fg-dim);
|
||||
overflow: hidden;
|
||||
@@ -1454,6 +1456,70 @@ a[href] {
|
||||
font-weight: 450;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.msg-attachments {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
min-width: min(280px, 100%);
|
||||
}
|
||||
.msg__text + .msg-attachments {
|
||||
padding-top: 2px;
|
||||
}
|
||||
.msg-attachment {
|
||||
display: grid;
|
||||
grid-template-columns: 28px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
max-width: 420px;
|
||||
min-height: 38px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--bg-soft) 78%, transparent);
|
||||
}
|
||||
.msg-attachment__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 7px;
|
||||
color: var(--fg-dim);
|
||||
background: var(--bg-elev);
|
||||
}
|
||||
.msg-attachment__icon--image {
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 13%, var(--bg-elev));
|
||||
}
|
||||
.msg-attachment__icon--folder {
|
||||
color: var(--warn);
|
||||
background: color-mix(in srgb, var(--warn) 13%, var(--bg-elev));
|
||||
}
|
||||
.msg-attachment__main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
}
|
||||
.msg-attachment__name,
|
||||
.msg-attachment__meta {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.msg-attachment__name {
|
||||
color: var(--fg);
|
||||
font-size: 13px;
|
||||
font-weight: 560;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.msg-attachment__meta {
|
||||
color: var(--fg-faint);
|
||||
font-size: 11px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.msg--assistant {
|
||||
color: var(--fg);
|
||||
display: flex;
|
||||
@@ -2159,7 +2225,7 @@ a[href] {
|
||||
opacity: 0.72;
|
||||
transition: background 0.12s, box-shadow 0.12s, opacity 0.12s;
|
||||
}
|
||||
.composer-card:hover .composer-resize-handle::before,
|
||||
.composer-resize-handle:hover::before,
|
||||
.composer-resize-handle:focus-visible::before,
|
||||
.composer-card--resizing .composer-resize-handle::before {
|
||||
background: var(--accent);
|
||||
@@ -2196,6 +2262,16 @@ a[href] {
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4);
|
||||
z-index: var(--z-inline-sticky);
|
||||
}
|
||||
.slashmenu__sizer {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
.slashmenu__row {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.slashmenu__item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
@@ -2329,6 +2405,7 @@ a[href] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-self: flex-end;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
@@ -2364,7 +2441,7 @@ a[href] {
|
||||
padding: 5px 14px 6px;
|
||||
overflow: hidden;
|
||||
color: var(--fg-dim);
|
||||
font-family: var(--mono);
|
||||
font-family: var(--sans);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -2741,8 +2818,9 @@ a[href] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--mono);
|
||||
font-family: var(--sans);
|
||||
font-size: var(--font-status);
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.45;
|
||||
color: var(--fg-faint);
|
||||
/* One line, always: without this a narrow window squeezes the flex items and
|
||||
@@ -2826,7 +2904,7 @@ a[href] {
|
||||
border: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-family: var(--mono);
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
@@ -2883,7 +2961,7 @@ a[href] {
|
||||
border: none;
|
||||
color: var(--fg-dim);
|
||||
font: inherit;
|
||||
font-family: var(--mono);
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
padding: 0;
|
||||
white-space: nowrap;
|
||||
@@ -2948,7 +3026,7 @@ a[href] {
|
||||
border-radius: 6px;
|
||||
color: var(--fg-dim);
|
||||
font: inherit;
|
||||
font-family: var(--mono);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
@@ -3291,6 +3369,15 @@ a[href] {
|
||||
line-height: 1.4;
|
||||
padding-block: 1px;
|
||||
}
|
||||
.prompt-action__label:has(.approval-rule-label) {
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
}
|
||||
.prompt-action:has(.approval-rule-label) {
|
||||
height: auto;
|
||||
padding-block: 5px;
|
||||
}
|
||||
.prompt-detail-toggle {
|
||||
flex: 0 0 auto;
|
||||
max-width: 92px;
|
||||
@@ -3348,6 +3435,21 @@ a[href] {
|
||||
max-height: 150px;
|
||||
overflow: auto;
|
||||
}
|
||||
.approval-rule-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
.approval-rule-label code {
|
||||
min-width: 0;
|
||||
max-width: 24ch;
|
||||
overflow-wrap: anywhere;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
/* ── ask tool: decision details ───────────────────────────────────────────── */
|
||||
.ask-shelf__crumbs {
|
||||
@@ -4012,6 +4114,42 @@ a[href] {
|
||||
border-right: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
/* past:chats hover preview (PR-C2). Inherits colors from the .tooltip container
|
||||
(--fg / --fg-dim / --fg-faint / --border / --bg-elev) and only adjusts layout. */
|
||||
.past-chat-hover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
max-width: 300px;
|
||||
}
|
||||
.past-chat-hover__title {
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
word-break: break-word;
|
||||
}
|
||||
.past-chat-hover__preview {
|
||||
color: var(--fg-dim);
|
||||
word-break: break-word;
|
||||
max-height: 96px;
|
||||
overflow: hidden;
|
||||
/* fade the cut so long previews don't look amputated */
|
||||
-webkit-mask-image: linear-gradient(to bottom, #000 70%, transparent 100%);
|
||||
mask-image: linear-gradient(to bottom, #000 70%, transparent 100%);
|
||||
}
|
||||
.past-chat-hover__meta {
|
||||
color: var(--fg-faint);
|
||||
font-size: 11px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.past-chat-hover__path {
|
||||
color: var(--fg-faint);
|
||||
font-size: 11px;
|
||||
word-break: break-all;
|
||||
max-height: 32px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.workspace-empty {
|
||||
padding: 18px 0;
|
||||
color: var(--fg-faint);
|
||||
@@ -4258,6 +4396,99 @@ a[href] {
|
||||
color: var(--accent);
|
||||
border-color: color-mix(in srgb, var(--accent) 32%, var(--border-soft));
|
||||
}
|
||||
.workspace-git-history {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.workspace-git-history__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.workspace-git-history__item {
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-soft);
|
||||
overflow: hidden;
|
||||
}
|
||||
.workspace-git-history__head {
|
||||
--wails-draggable: no-drag;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
}
|
||||
.workspace-git-history__head-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.workspace-git-history__head-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-left: 22px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.workspace-git-history__head:hover {
|
||||
background: var(--sidebar-hover);
|
||||
}
|
||||
.workspace-git-history__message {
|
||||
flex: 1;
|
||||
font-weight: 560;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.workspace-git-history__author {
|
||||
color: var(--fg-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
.workspace-git-history__date {
|
||||
color: var(--fg-faint);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.workspace-git-history__hash {
|
||||
font-family: var(--mono);
|
||||
margin-left: 6px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.workspace-git-history__detail {
|
||||
padding: 12px;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
background: var(--bg);
|
||||
}
|
||||
.workspace-git-history__files {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.workspace-git-history__file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--fg-dim);
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.workspace-git-history__file:hover {
|
||||
background: var(--sidebar-hover);
|
||||
color: var(--fg);
|
||||
}
|
||||
.workspace-tree__chev {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
@@ -5701,10 +5932,27 @@ a[href] {
|
||||
overflow: visible;
|
||||
}
|
||||
.cap-skill-card__more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin: 7px 0 0 33px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
line-height: 1.3;
|
||||
text-align: left;
|
||||
}
|
||||
.cap-skill-card__more:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--accent) 52%, transparent);
|
||||
outline-offset: 3px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.cap-skill-card__more:hover {
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
.cap-dot {
|
||||
flex-shrink: 0;
|
||||
@@ -5910,7 +6158,7 @@ a[href] {
|
||||
.history-content {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(340px, 0.38fr) minmax(0, 0.62fr);
|
||||
grid-template-columns: minmax(0, 0.38fr) minmax(0, 0.62fr);
|
||||
overflow: hidden;
|
||||
}
|
||||
.history-list {
|
||||
@@ -6137,7 +6385,10 @@ a[href] {
|
||||
max-width: 100%;
|
||||
}
|
||||
.history-content {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.history-list {
|
||||
max-height: 40vh;
|
||||
}
|
||||
.history-preview {
|
||||
min-height: 260px;
|
||||
@@ -6255,7 +6506,7 @@ a[href] {
|
||||
.settings-center__navitem small {
|
||||
color: var(--fg-dim);
|
||||
font-size: var(--text-2xs);
|
||||
font-family: var(--mono);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.settings-center__content {
|
||||
@@ -6423,6 +6674,27 @@ a[href] {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.step-limit-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.step-limit-control .set-seg {
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.step-limit-control__custom {
|
||||
width: 82px;
|
||||
flex: 0 0 82px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.settings-page .settings-model-current {
|
||||
margin-top: 14px;
|
||||
}
|
||||
@@ -6619,6 +6891,14 @@ a[href] {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.step-limit-control {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.step-limit-control .set-seg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.settings-subtabs {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
@@ -6671,7 +6951,7 @@ a[href] {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
color: var(--fg);
|
||||
font-family: var(--mono);
|
||||
font-family: inherit;
|
||||
}
|
||||
.settings-model-current__meta {
|
||||
display: flex;
|
||||
@@ -6687,7 +6967,7 @@ a[href] {
|
||||
border-radius: 999px;
|
||||
background: var(--bg-elev-2);
|
||||
color: var(--fg-muted);
|
||||
font-family: var(--mono);
|
||||
font-family: inherit;
|
||||
font-size: var(--text-2xs);
|
||||
line-height: 1;
|
||||
padding: 5px 8px;
|
||||
@@ -6743,7 +7023,7 @@ a[href] {
|
||||
}
|
||||
.settings-model-picker__selected span {
|
||||
color: var(--fg);
|
||||
font-family: var(--mono);
|
||||
font-family: inherit;
|
||||
font-size: var(--font-control);
|
||||
font-weight: 650;
|
||||
}
|
||||
@@ -6869,7 +7149,6 @@ a[href] {
|
||||
}
|
||||
.settings-model-picker__option strong {
|
||||
color: var(--fg);
|
||||
font-family: var(--mono);
|
||||
font-size: var(--font-control-small);
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -7485,6 +7764,7 @@ a[href] {
|
||||
font-size: var(--font-control-small);
|
||||
padding: 5px 13px;
|
||||
text-transform: capitalize;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.set-seg__btn--on {
|
||||
background: var(--accent-soft);
|
||||
@@ -7670,33 +7950,19 @@ a[href] {
|
||||
background: var(--bg-elev);
|
||||
}
|
||||
.todobar__title {
|
||||
font-family: var(--mono);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--fg-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.todobar__count {
|
||||
font-family: var(--mono);
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--fg-faint);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.todobar__stale {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
color: var(--fg-faint);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.todobar__stale > svg {
|
||||
flex: 0 0 auto;
|
||||
opacity: 0.72;
|
||||
}
|
||||
.todobar__current {
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
@@ -7863,7 +8129,7 @@ a[href] {
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--accent) 9%, var(--bg-elev));
|
||||
color: var(--accent);
|
||||
font-family: var(--mono);
|
||||
font-family: var(--sans);
|
||||
font-size: 12px;
|
||||
box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 6%, transparent);
|
||||
}
|
||||
@@ -7990,7 +8256,34 @@ a[href] {
|
||||
color: var(--fg);
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
.composer-context__item--session {
|
||||
border-color: color-mix(in srgb, var(--accent) 24%, var(--border));
|
||||
background: color-mix(in srgb, var(--accent) 7%, var(--panel));
|
||||
}
|
||||
.composer-context__item--session .composer-context__label > svg {
|
||||
color: var(--accent);
|
||||
}
|
||||
.slashmenu__item--special {
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--accent) 5%, var(--panel));
|
||||
}
|
||||
.slashmenu__item--special:hover {
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--panel));
|
||||
}
|
||||
.slashmenu__item--back {
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
.slashmenu__item--back:hover {
|
||||
background: var(--hover);
|
||||
}
|
||||
.slashmenu__item--empty {
|
||||
color: var(--fg-faint);
|
||||
cursor: default;
|
||||
}
|
||||
.slashmenu__item--empty:hover {
|
||||
background: transparent;
|
||||
}
|
||||
.composer__pasted {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -10323,6 +10616,277 @@ a[href] {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
/* ── Branch indicator (changed-files view) ──────────────────────────── */
|
||||
.workspace-branch-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--fg-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.workspace-branch-indicator > svg {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.workspace-branch-indicator > .workspace-branch-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.workspace-branch-indicator > .workspace-branch-name:hover {
|
||||
background: var(--bg-hover, rgba(128, 128, 128, 0.08));
|
||||
}
|
||||
|
||||
.workspace-branch-indicator > .workspace-branch-name span {
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workspace-branch-refresh {
|
||||
margin-left: auto;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--fg-muted);
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.workspace-branch-refresh:hover {
|
||||
color: var(--fg);
|
||||
background: var(--bg-hover, rgba(128, 128, 128, 0.08));
|
||||
}
|
||||
|
||||
.workspace-branch-refresh:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.workspace-branch-refresh .spinning {
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.workspace-branch-menu {
|
||||
min-width: 140px;
|
||||
max-width: 260px;
|
||||
max-height: 300px;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workspace-branch-menu__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
background: var(--bg-elev);
|
||||
}
|
||||
|
||||
.workspace-branch-menu__loading {
|
||||
padding: 10px 12px;
|
||||
color: var(--fg-muted);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.workspace-branch-menu__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.workspace-branch-menu__item:hover {
|
||||
background: var(--bg-hover, rgba(128, 128, 128, 0.08));
|
||||
}
|
||||
|
||||
.workspace-branch-menu__item--active {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.workspace-branch-menu__item svg {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.workspace-branch-menu__item span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workspace-branch-menu__item:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ── Command palette (⌘K) ─────────────────────────────────────────── */
|
||||
.drawer-backdrop:has(.palette) {
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding-top: 12vh;
|
||||
}
|
||||
|
||||
.palette {
|
||||
position: relative;
|
||||
width: min(520px, 90vw);
|
||||
max-height: 60vh;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.36);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: palette-in 0.1s ease;
|
||||
}
|
||||
|
||||
@keyframes palette-in {
|
||||
from { opacity: 0; transform: translateY(-12px) scale(0.97); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
.palette__inputrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.palette__input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.palette__input::placeholder {
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
.palette__esc {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--fg-muted);
|
||||
background: var(--bg-base);
|
||||
font-family: inherit;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.palette__list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.palette__empty {
|
||||
padding: 24px 14px;
|
||||
color: var(--fg-muted);
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.palette__group-title {
|
||||
padding: 8px 14px 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
.palette__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
padding: 8px 14px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.palette__item--on {
|
||||
background: var(--accent-bg, rgba(77, 151, 255, 0.12));
|
||||
}
|
||||
|
||||
.palette__item:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.palette__title {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.palette__hint {
|
||||
font-size: 11px;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
.palette__foot {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 7px 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 11px;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
.palette__foot kbd {
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-base);
|
||||
font-family: inherit;
|
||||
font-size: 10px;
|
||||
line-height: 1.3;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
/* Respect the OS reduced-motion preference: squash all animations and force
|
||||
instant scrolling so the UI feels immediate rather than web-like. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -10335,3 +10899,68 @@ a[href] {
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Toast ──────────────────────────────────────────────────────────── */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: var(--z-toast);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
color: var(--fg);
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2);
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
animation: toast-in 0.2s ease-out;
|
||||
user-select: none;
|
||||
}
|
||||
.toast--warn {
|
||||
border-color: #eab308;
|
||||
background: #fefce8;
|
||||
color: #713f12;
|
||||
}
|
||||
.toast--error {
|
||||
border-color: #ef4444;
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.toast--warn {
|
||||
background: #422006;
|
||||
border-color: #a16207;
|
||||
color: #fde68a;
|
||||
}
|
||||
.toast--error {
|
||||
background: #450a0a;
|
||||
border-color: #dc2626;
|
||||
color: #fca5a5;
|
||||
}
|
||||
}
|
||||
.toast__icon {
|
||||
flex: 0 0 auto;
|
||||
line-height: 1;
|
||||
font-size: 14px;
|
||||
}
|
||||
.toast__text {
|
||||
line-height: 1.4;
|
||||
}
|
||||
@keyframes toast-in {
|
||||
from { opacity: 0; transform: translateY(-8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@@ -36,6 +36,11 @@ var assets embed.FS
|
||||
// prompts to update.
|
||||
var version = "dev"
|
||||
|
||||
// channel selects which updater pointer this build polls, injected via
|
||||
// `-X main.channel=canary`. Default "stable" tracks the public release; "canary"
|
||||
// tracks the opt-in pre-release line and never crosses over to stable.
|
||||
var channel = "stable"
|
||||
|
||||
func main() {
|
||||
app := NewApp()
|
||||
|
||||
|
||||
+64
-19
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -72,9 +73,10 @@ type NetworkView struct {
|
||||
}
|
||||
|
||||
type AgentView struct {
|
||||
Temperature float64 `json:"temperature"`
|
||||
MaxSteps int `json:"maxSteps"`
|
||||
SystemPrompt string `json:"systemPrompt"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
MaxSteps int `json:"maxSteps"`
|
||||
PlannerMaxSteps int `json:"plannerMaxSteps"`
|
||||
SystemPrompt string `json:"systemPrompt"`
|
||||
}
|
||||
|
||||
// SettingsView is the whole Settings panel payload.
|
||||
@@ -132,15 +134,35 @@ func desktopModelRefsProvider(c *config.Config, ref, name string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func builtInProviderNames() map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, p := range config.Default().Providers {
|
||||
out[p.Name] = true
|
||||
func officialProviderHost(baseURL string) string {
|
||||
u, err := url.Parse(strings.TrimSpace(baseURL))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, name := range []string{"deepseek", "deepseek-flash", "mimo-api", "mimo-token-plan", "mimo-pro"} {
|
||||
out[name] = true
|
||||
return strings.ToLower(u.Hostname())
|
||||
}
|
||||
|
||||
func officialProviderKindFromEntry(p config.ProviderEntry) string {
|
||||
host := officialProviderHost(p.BaseURL)
|
||||
switch config.CanonicalDesktopOfficialProviderName(p.Name) {
|
||||
case "deepseek":
|
||||
if host == "api.deepseek.com" {
|
||||
return "deepseek"
|
||||
}
|
||||
case "mimo-api":
|
||||
if host == "api.xiaomimimo.com" {
|
||||
return "mimo-api"
|
||||
}
|
||||
case "mimo-token-plan":
|
||||
if host == "token-plan-cn.xiaomimimo.com" {
|
||||
return "mimo-token-plan"
|
||||
}
|
||||
}
|
||||
return out
|
||||
return ""
|
||||
}
|
||||
|
||||
func isOfficialBuiltInProvider(p config.ProviderEntry) bool {
|
||||
return officialProviderKindFromEntry(p) != ""
|
||||
}
|
||||
|
||||
func providerAccessSet(names []string) map[string]bool {
|
||||
@@ -183,7 +205,7 @@ func removeProviderAccess(c *config.Config, names ...string) {
|
||||
func providerViewFromEntry(p config.ProviderEntry, builtIn, added bool) ProviderView {
|
||||
return ProviderView{
|
||||
Name: p.Name, BuiltIn: builtIn, Added: added, Kind: p.Kind, BaseURL: p.BaseURL,
|
||||
Models: nonNil(p.ModelList()), ModelsURL: p.ModelsURL, Default: p.DefaultModel(),
|
||||
Models: nonNil(p.ChatModelList()), ModelsURL: p.ModelsURL, Default: p.DefaultModel(),
|
||||
APIKeyEnv: p.APIKeyEnv,
|
||||
KeySet: p.APIKeyEnv != "" && os.Getenv(p.APIKeyEnv) != "",
|
||||
BalanceURL: p.BalanceURL,
|
||||
@@ -208,6 +230,24 @@ func officialProviderViews(added map[string]bool) []ProviderView {
|
||||
return out
|
||||
}
|
||||
|
||||
func officialProviderAddedSet(cfg *config.Config) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
if cfg == nil {
|
||||
return out
|
||||
}
|
||||
access := providerAccessSet(cfg.Desktop.ProviderAccess)
|
||||
for i := range cfg.Providers {
|
||||
p := cfg.Providers[i]
|
||||
if !access[p.Name] {
|
||||
continue
|
||||
}
|
||||
if kind := officialProviderKindFromEntry(p); kind != "" {
|
||||
out[kind] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Settings returns the current configuration for the Settings panel.
|
||||
func (a *App) Settings() SettingsView {
|
||||
cfg, cfgPath, err := a.loadDesktopUserConfigForEdit()
|
||||
@@ -223,6 +263,7 @@ func (a *App) Settings() SettingsView {
|
||||
Deny: []string{},
|
||||
},
|
||||
Sandbox: SandboxView{Bash: "enforce", AllowWrite: []string{}},
|
||||
Agent: AgentView{PlannerMaxSteps: 12},
|
||||
AutoPlan: "off",
|
||||
DesktopTheme: "dark",
|
||||
DesktopThemeStyle: "graphite",
|
||||
@@ -264,7 +305,7 @@ func (a *App) Settings() SettingsView {
|
||||
Password: cfg.Network.Proxy.Password,
|
||||
},
|
||||
},
|
||||
Agent: AgentView{Temperature: cfg.Agent.Temperature, MaxSteps: cfg.Agent.MaxSteps, SystemPrompt: cfg.Agent.SystemPrompt},
|
||||
Agent: AgentView{Temperature: cfg.Agent.Temperature, MaxSteps: cfg.Agent.MaxSteps, PlannerMaxSteps: cfg.Agent.PlannerMaxSteps, SystemPrompt: cfg.Agent.SystemPrompt},
|
||||
DesktopLanguage: cfg.DesktopLanguage(),
|
||||
DesktopTheme: cfg.DesktopTheme(),
|
||||
DesktopThemeStyle: cfg.DesktopThemeStyle(),
|
||||
@@ -273,12 +314,11 @@ func (a *App) Settings() SettingsView {
|
||||
ProviderKinds: nonNil(provider.Kinds()),
|
||||
Bypass: ctrl != nil && ctrl.Bypass(),
|
||||
}
|
||||
builtIns := builtInProviderNames()
|
||||
added := providerAccessSet(cfg.Desktop.ProviderAccess)
|
||||
v.OfficialProviders = officialProviderViews(added)
|
||||
v.OfficialProviders = officialProviderViews(officialProviderAddedSet(cfg))
|
||||
for i := range cfg.Providers {
|
||||
p := &cfg.Providers[i]
|
||||
v.Providers = append(v.Providers, providerViewFromEntry(*p, builtIns[p.Name], added[p.Name]))
|
||||
v.Providers = append(v.Providers, providerViewFromEntry(*p, isOfficialBuiltInProvider(*p), added[p.Name]))
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -740,7 +780,11 @@ func (a *App) RemoveProviderAccess(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("remove provider access: empty provider name")
|
||||
}
|
||||
if builtInProviderNames()[name] {
|
||||
cfg, _, err := a.loadDesktopUserConfigForEdit()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p, ok := cfg.Provider(name); ok && isOfficialBuiltInProvider(*p) {
|
||||
return a.removeBuiltInProviderAccessAndRetargetTabs(name)
|
||||
}
|
||||
return a.deleteProviderAndRetargetTabs(name)
|
||||
@@ -1060,12 +1104,13 @@ func (a *App) MigrateDesktopPreferences(language, theme, style string) error {
|
||||
})
|
||||
}
|
||||
|
||||
// SetAgentParams updates sampling temperature, the optional max-steps guard, and
|
||||
// the base system prompt.
|
||||
func (a *App) SetAgentParams(temperature float64, maxSteps int, systemPrompt string) error {
|
||||
// SetAgentParams updates sampling temperature, optional step guards, and the
|
||||
// base system prompt.
|
||||
func (a *App) SetAgentParams(temperature float64, maxSteps int, plannerMaxSteps int, systemPrompt string) error {
|
||||
return a.applyConfigChange(func(c *config.Config) error {
|
||||
c.Agent.Temperature = temperature
|
||||
c.Agent.MaxSteps = maxSteps
|
||||
c.Agent.PlannerMaxSteps = plannerMaxSteps
|
||||
c.Agent.SystemPrompt = systemPrompt
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
@@ -38,3 +40,44 @@ func TestWithFreshSystemPromptPrependsMissingSystemMessage(t *testing.T) {
|
||||
t.Fatalf("existing user message changed: %+v", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderViewFromEntry_FiltersNonChatModels(t *testing.T) {
|
||||
p := config.ProviderEntry{
|
||||
Name: "mimo-api",
|
||||
Models: []string{
|
||||
"mimo-v2", "mimo-v2-pro",
|
||||
"mimo-v2-asr", "mimo-v2-tts",
|
||||
"mimo-v2-tts-voiceclone", "mimo-v2-tts-voicedesign",
|
||||
},
|
||||
}
|
||||
view := providerViewFromEntry(p, true, false)
|
||||
want := []string{"mimo-v2", "mimo-v2-pro"}
|
||||
if !reflect.DeepEqual(view.Models, want) {
|
||||
t.Errorf("ProviderView.Models = %v, want %v", view.Models, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAgentParamsPersistsStepLimitsToUserConfig(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
app := NewApp()
|
||||
if err := app.SetAgentParams(0.35, 37, 9, "custom system"); err != nil {
|
||||
t.Fatalf("SetAgentParams: %v", err)
|
||||
}
|
||||
|
||||
view := app.Settings()
|
||||
if view.Agent.MaxSteps != 37 || view.Agent.PlannerMaxSteps != 9 {
|
||||
t.Fatalf("Settings().Agent = %+v, want maxSteps=37 plannerMaxSteps=9", view.Agent)
|
||||
}
|
||||
if view.Agent.Temperature != 0.35 || view.Agent.SystemPrompt != "custom system" {
|
||||
t.Fatalf("Settings().Agent did not preserve other agent params: %+v", view.Agent)
|
||||
}
|
||||
|
||||
cfg := config.LoadForEdit(config.UserConfigPath())
|
||||
if cfg.Agent.MaxSteps != 37 || cfg.Agent.PlannerMaxSteps != 9 {
|
||||
t.Fatalf("saved config agent steps = max:%d planner:%d, want 37/9", cfg.Agent.MaxSteps, cfg.Agent.PlannerMaxSteps)
|
||||
}
|
||||
if cfg.Agent.Temperature != 0.35 || cfg.Agent.SystemPrompt != "custom system" {
|
||||
t.Fatalf("saved config did not preserve other agent params: %+v", cfg.Agent)
|
||||
}
|
||||
}
|
||||
@@ -165,7 +165,10 @@ func TestSaveTabsPersistsModelAndEffort(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveTabsDoesNotPersistYoloMode(t *testing.T) {
|
||||
// TestSaveTabsPersistsYoloMode is the regression for #3517: yolo used to be
|
||||
// dropped on save, so relaunching reverted to normal. It now round-trips through
|
||||
// the real saveTabsLocked/loadTabsFile path.
|
||||
func TestSaveTabsPersistsYoloMode(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
app := NewApp()
|
||||
@@ -184,8 +187,8 @@ func TestSaveTabsDoesNotPersistYoloMode(t *testing.T) {
|
||||
if len(got.Tabs) != 1 {
|
||||
t.Fatalf("tabs len = %d, want 1", len(got.Tabs))
|
||||
}
|
||||
if got.Tabs[0].Mode != "" {
|
||||
t.Fatalf("saved yolo mode = %q, want empty", got.Tabs[0].Mode)
|
||||
if got.Tabs[0].Mode != "yolo" {
|
||||
t.Fatalf("saved yolo mode = %q, want yolo (#3517)", got.Tabs[0].Mode)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
-2
@@ -300,6 +300,8 @@ func (a *App) OpenProjectTab(workspaceRoot, topicID string) (TabMeta, error) {
|
||||
if abs, err := filepath.Abs(workspaceRoot); err == nil {
|
||||
workspaceRoot = abs
|
||||
}
|
||||
saveWorkspace(workspaceRoot)
|
||||
_ = addProject(workspaceRoot, "")
|
||||
|
||||
a.mu.Lock()
|
||||
// If already open, just activate.
|
||||
@@ -333,6 +335,7 @@ func (a *App) OpenProjectTab(workspaceRoot, topicID string) (TabMeta, error) {
|
||||
a.mu.Unlock()
|
||||
|
||||
a.startTabControllerBuild(tab)
|
||||
a.emitProjectTreeChanged()
|
||||
return a.tabMeta(tab, true), nil
|
||||
}
|
||||
|
||||
@@ -2299,9 +2302,13 @@ func currentTabMode(tab *WorkspaceTab) string {
|
||||
return normalizeTabMode(tab.mode)
|
||||
}
|
||||
|
||||
// persistedTabMode is the composer mode saved with a tab so it survives reload
|
||||
// and app relaunch. plan and yolo are both remembered (a restored yolo tab keeps
|
||||
// its status-bar indicator); "normal" is the default and isn't persisted. (#3517)
|
||||
func persistedTabMode(mode string) string {
|
||||
if normalizeTabMode(mode) == "plan" {
|
||||
return "plan"
|
||||
switch normalizeTabMode(mode) {
|
||||
case "plan", "yolo":
|
||||
return normalizeTabMode(mode)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
+1
-5
@@ -92,11 +92,7 @@ func (a *App) trayLocale() string {
|
||||
}
|
||||
|
||||
func (a *App) showFromTray() {
|
||||
ctx := a.ctx
|
||||
if ctx == nil {
|
||||
return
|
||||
}
|
||||
showFromBackground(ctx)
|
||||
a.showMainWindow()
|
||||
}
|
||||
|
||||
func (a *App) quitFromTray() {
|
||||
|
||||
+30
-7
@@ -32,14 +32,37 @@ import (
|
||||
// the thin Wails binding that wires these into App methods and progress events.
|
||||
|
||||
// Manifest endpoints — R2 CDN first (fast, especially in CN), GitHub releases as
|
||||
// fallback. Mirrors the v1 desktop's two-endpoint scheme.
|
||||
// fallback. The build channel picks the rolling pointer so a canary build polls
|
||||
// the canary line and a stable build polls latest; the two never cross.
|
||||
const (
|
||||
manifestPrimary = "https://pub-147fb53b9c1e4bbf891a257968619ea7.r2.dev/latest/latest.json"
|
||||
manifestFallback = "https://github.com/esengine/reasonix/releases/latest/download/latest.json"
|
||||
defaultDownloadPage = "https://github.com/esengine/reasonix/releases/latest"
|
||||
httpTimeout = 15 * time.Second
|
||||
r2Base = "https://pub-147fb53b9c1e4bbf891a257968619ea7.r2.dev"
|
||||
ghReleasesBase = "https://github.com/esengine/reasonix/releases"
|
||||
httpTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
// manifestEndpoints returns the primary (R2) then fallback (GitHub) manifest URLs
|
||||
// for the running build's channel.
|
||||
func manifestEndpoints() []string {
|
||||
if channel == "canary" {
|
||||
// Canary publishes only to R2 (no GitHub release), so there is no
|
||||
// GitHub fallback for this channel.
|
||||
return []string{r2Base + "/canary/latest.json"}
|
||||
}
|
||||
return []string{
|
||||
r2Base + "/latest/latest.json",
|
||||
ghReleasesBase + "/latest/download/latest.json",
|
||||
}
|
||||
}
|
||||
|
||||
// downloadPage is the human-facing releases page shown when self-update is
|
||||
// unavailable (macOS) or the manifest omits its own link.
|
||||
func downloadPage() string {
|
||||
if channel == "canary" {
|
||||
return ghReleasesBase // lists pre-releases too
|
||||
}
|
||||
return ghReleasesBase + "/latest"
|
||||
}
|
||||
|
||||
// UpdateInfo is the CheckUpdate result that drives the frontend's update banner.
|
||||
type UpdateInfo struct {
|
||||
Available bool `json:"available"`
|
||||
@@ -95,7 +118,7 @@ func normalizeVersion(v string) (string, bool) {
|
||||
// and decodes it.
|
||||
func fetchManifest(ctx context.Context, c *http.Client) (*update.Manifest, error) {
|
||||
var lastErr error
|
||||
for _, url := range []string{manifestPrimary, manifestFallback} {
|
||||
for _, url := range manifestEndpoints() {
|
||||
b, err := fetchBytes(ctx, c, url)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
@@ -116,7 +139,7 @@ func fetchManifest(ctx context.Context, c *http.Client) (*update.Manifest, error
|
||||
func evaluate(current string, m *update.Manifest) UpdateInfo {
|
||||
page := m.DownloadPage
|
||||
if page == "" {
|
||||
page = defaultDownloadPage
|
||||
page = downloadPage()
|
||||
}
|
||||
info := UpdateInfo{
|
||||
Current: current,
|
||||
|
||||
@@ -30,7 +30,7 @@ func (a *App) CheckUpdate() (*UpdateInfo, error) {
|
||||
return &UpdateInfo{
|
||||
Current: version,
|
||||
CanSelfUpdate: canSelfUpdate(),
|
||||
DownloadURL: defaultDownloadPage,
|
||||
DownloadURL: downloadPage(),
|
||||
Err: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
@@ -41,7 +41,7 @@ func (a *App) CheckUpdate() (*UpdateInfo, error) {
|
||||
return &UpdateInfo{
|
||||
Current: version,
|
||||
CanSelfUpdate: canSelfUpdate(),
|
||||
DownloadURL: defaultDownloadPage,
|
||||
DownloadURL: downloadPage(),
|
||||
Err: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
@@ -52,7 +52,7 @@ func (a *App) CheckUpdate() (*UpdateInfo, error) {
|
||||
// OpenDownloadPage opens the releases page in the browser — the macOS manual-update
|
||||
// path and a fallback link elsewhere.
|
||||
func (a *App) OpenDownloadPage() {
|
||||
page := defaultDownloadPage
|
||||
page := downloadPage()
|
||||
if c, err := httpClient(); err == nil {
|
||||
ctx, cancel := context.WithTimeout(a.reqCtx(), httpTimeout)
|
||||
defer cancel()
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/desktop/internal/update"
|
||||
@@ -69,6 +70,36 @@ func TestEvaluate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelSelectsDistinctPointers(t *testing.T) {
|
||||
orig := channel
|
||||
t.Cleanup(func() { channel = orig })
|
||||
|
||||
channel = "stable"
|
||||
stable := manifestEndpoints()
|
||||
channel = "canary"
|
||||
canary := manifestEndpoints()
|
||||
|
||||
for _, u := range stable {
|
||||
if strings.Contains(u, "canary") {
|
||||
t.Errorf("stable endpoint leaks into canary: %q", u)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(stable[0], "/latest/latest.json") {
|
||||
t.Errorf("stable primary = %q, want the latest/ pointer", stable[0])
|
||||
}
|
||||
for _, u := range canary {
|
||||
if strings.Contains(u, "/latest/") {
|
||||
t.Errorf("canary endpoint hits the stable latest/ pointer: %q", u)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(canary[0], "/canary/latest.json") {
|
||||
t.Errorf("canary primary = %q, want the canary/ pointer", canary[0])
|
||||
}
|
||||
if downloadPage() == (ghReleasesBase + "/latest") {
|
||||
t.Error("canary download page should not be the stable latest releases page")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSHA256(t *testing.T) {
|
||||
data := []byte("hello world")
|
||||
// echo -n "hello world" | shasum -a 256
|
||||
|
||||
@@ -46,6 +46,16 @@ func saveWorkspace(dir string) {
|
||||
_ = os.WriteFile(p, []byte(dir), 0o644)
|
||||
}
|
||||
|
||||
// clearWorkspace removes the active workspace pointer file so that no stale
|
||||
// reference remains after the current workspace is deleted.
|
||||
func clearWorkspace() {
|
||||
p := workspaceStatePath()
|
||||
if p == "" {
|
||||
return
|
||||
}
|
||||
_ = os.Remove(p)
|
||||
}
|
||||
|
||||
// loadWorkspace returns the remembered working folder, or "" if none.
|
||||
func loadWorkspace() string {
|
||||
p := workspaceStatePath()
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -31,6 +32,8 @@ func (a *App) WorkspaceChanges() WorkspaceChangesView {
|
||||
return out
|
||||
}
|
||||
|
||||
out.GitBranch = workspaceGitBranch(base)
|
||||
|
||||
changes := map[string]*workspaceChangeAccumulator{}
|
||||
add := func(path string) *workspaceChangeAccumulator {
|
||||
path = normalizeWorkspaceRelPath(base, path)
|
||||
@@ -177,3 +180,145 @@ func workspaceRelPathFromGitStatus(repoRoot, base, path string) string {
|
||||
}
|
||||
return normalizeWorkspaceRelPath(base, path)
|
||||
}
|
||||
|
||||
// workspaceGitBranch returns the current git branch name for the repo rooted
|
||||
// at base, or an empty string when base is not inside a git repository or when
|
||||
// git is unavailable.
|
||||
func workspaceGitBranch(base string) string {
|
||||
cmd := exec.Command("git", "-C", base, "branch", "--show-current")
|
||||
proc.HideWindowDetached(cmd)
|
||||
raw, err := cmd.Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if branch := strings.TrimSpace(string(raw)); branch != "" {
|
||||
return branch
|
||||
}
|
||||
|
||||
headCmd := exec.Command("git", "-C", base, "rev-parse", "--short", "HEAD")
|
||||
proc.HideWindowDetached(headCmd)
|
||||
raw, err = headCmd.Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
short := strings.TrimSpace(string(raw))
|
||||
if short == "" {
|
||||
return ""
|
||||
}
|
||||
return "@" + short
|
||||
}
|
||||
|
||||
// GitBranches returns all local git branches for the active workspace's repo.
|
||||
func (a *App) GitBranches() ([]string, error) {
|
||||
base, err := a.activeWorkspaceBase()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cmd := exec.Command("git", "-C", base, "branch", "--format=%(refname:short)")
|
||||
proc.HideWindowDetached(cmd)
|
||||
raw, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
branches := strings.FieldsFunc(strings.TrimSpace(string(raw)), func(r rune) bool { return r == '\n' })
|
||||
return branches, nil
|
||||
}
|
||||
|
||||
// GitCheckout switches the active workspace's git branch and returns the
|
||||
// current branch name, or an error when git is unavailable.
|
||||
func (a *App) GitCheckout(branch string) error {
|
||||
base, err := a.activeWorkspaceBase()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := exec.Command("git", "-C", base, "checkout", branch)
|
||||
proc.HideWindowDetached(cmd)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
if len(out) > 0 {
|
||||
return fmt.Errorf("git checkout: %s", strings.TrimSpace(string(out)))
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type GitCommitView struct {
|
||||
Hash string `json:"hash"`
|
||||
Author string `json:"author"`
|
||||
Date string `json:"date"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type GitCommitDetailView struct {
|
||||
Diff *string `json:"diff,omitempty"`
|
||||
Files []string `json:"files,omitempty"`
|
||||
}
|
||||
|
||||
func (a *App) WorkspaceGitHistory(path string) ([]GitCommitView, error) {
|
||||
base, err := a.activeWorkspaceBase()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
args := []string{"-C", base, "log", "--pretty=format:%H%x00%an%x00%ad%x00%s", "-z", "-n", "100"}
|
||||
if path != "" {
|
||||
args = append(args, "--", path)
|
||||
}
|
||||
|
||||
cmd := exec.Command("git", args...)
|
||||
proc.HideWindowDetached(cmd)
|
||||
raw, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parts := bytes.Split(raw, []byte{0})
|
||||
var out []GitCommitView
|
||||
// 4 parts per commit: hash, author, date, message
|
||||
for i := 0; i+3 < len(parts); i += 4 {
|
||||
out = append(out, GitCommitView{
|
||||
Hash: string(parts[i]),
|
||||
Author: string(parts[i+1]),
|
||||
Date: string(parts[i+2]),
|
||||
Message: string(parts[i+3]),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *App) WorkspaceGitCommitDetail(hash string, path string) (GitCommitDetailView, error) {
|
||||
base, err := a.activeWorkspaceBase()
|
||||
if err != nil {
|
||||
return GitCommitDetailView{}, err
|
||||
}
|
||||
|
||||
if path != "" {
|
||||
// Single file diff
|
||||
cmd := exec.Command("git", "-C", base, "show", "--relative", "--pretty=format:", "--patch", hash, "--", path)
|
||||
proc.HideWindowDetached(cmd)
|
||||
raw, err := cmd.Output()
|
||||
if err != nil {
|
||||
return GitCommitDetailView{}, err
|
||||
}
|
||||
diffStr := strings.TrimSpace(string(raw))
|
||||
return GitCommitDetailView{Diff: &diffStr}, nil
|
||||
}
|
||||
|
||||
// Project level: list of files changed
|
||||
cmd := exec.Command("git", "-C", base, "diff-tree", "--relative", "--no-commit-id", "--name-only", "-r", hash)
|
||||
proc.HideWindowDetached(cmd)
|
||||
raw, err := cmd.Output()
|
||||
if err != nil {
|
||||
return GitCommitDetailView{}, err
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
|
||||
var files []string
|
||||
for _, line := range lines {
|
||||
if line != "" {
|
||||
files = append(files, line)
|
||||
}
|
||||
}
|
||||
return GitCommitDetailView{Files: files}, nil
|
||||
}
|
||||
@@ -551,6 +551,97 @@ func TestReadFileGB18030(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- RemoveWorkspace cleanup of active pointer ---
|
||||
|
||||
func TestRemoveWorkspaceClearsActivePointerWhenRemovingCurrentWorkspace(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
if workspaceStatePath() == "" {
|
||||
t.Fatal("workspaceStatePath() is empty after isolating")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
saveWorkspace(dir)
|
||||
if got := loadWorkspace(); got != dir {
|
||||
t.Fatalf("precondition: loadWorkspace = %q, want %q", got, dir)
|
||||
}
|
||||
|
||||
// Simulate RemoveWorkspace's cleanup logic:
|
||||
// When the removed workspace equals the active one, clearWorkspace should fire.
|
||||
if loadWorkspace() == dir {
|
||||
clearWorkspace()
|
||||
}
|
||||
|
||||
if got := loadWorkspace(); got != "" {
|
||||
t.Errorf("loadWorkspace = %q after clearWorkspace, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveWorkspaceFallsBackToRemainingProject(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
// Set up two projects and make the first one active.
|
||||
first := t.TempDir()
|
||||
second := t.TempDir()
|
||||
saveWorkspace(first)
|
||||
|
||||
// Simulate: remove the active workspace, fall back to the other.
|
||||
if loadWorkspace() == first {
|
||||
// In the real code, loadProjectsFile() would return remaining projects.
|
||||
// Here we simulate falling back to the second project.
|
||||
saveWorkspace(second)
|
||||
}
|
||||
|
||||
if got := loadWorkspace(); got != second {
|
||||
t.Errorf("loadWorkspace = %q, want fallback to %q", got, second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearWorkspace(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
if workspaceStatePath() == "" {
|
||||
t.Fatal("workspaceStatePath() is empty after isolating")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
saveWorkspace(dir)
|
||||
if got := loadWorkspace(); got != dir {
|
||||
t.Fatalf("precondition failed: loadWorkspace = %q, want %q", got, dir)
|
||||
}
|
||||
|
||||
clearWorkspace()
|
||||
if got := loadWorkspace(); got != "" {
|
||||
t.Errorf("loadWorkspace after clearWorkspace = %q, want empty", got)
|
||||
}
|
||||
// Also verify the file is actually removed.
|
||||
if _, err := os.Stat(workspaceStatePath()); !os.IsNotExist(err) {
|
||||
t.Errorf("desktop-workspace file should be removed, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- OpenProjectTab updates active workspace pointer ---
|
||||
|
||||
func TestOpenProjectTabUpdatesActiveWorkspacePointer(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
if workspaceStatePath() == "" {
|
||||
t.Fatal("workspaceStatePath() is empty after isolating")
|
||||
}
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
app := NewApp()
|
||||
topic, err := app.CreateTopic("project", projectRoot, "")
|
||||
if err != nil {
|
||||
t.Fatalf("create topic: %v", err)
|
||||
}
|
||||
|
||||
if _, err := app.OpenProjectTab(projectRoot, topic.ID); err != nil {
|
||||
t.Fatalf("open project tab: %v", err)
|
||||
}
|
||||
|
||||
if got := loadWorkspace(); got != projectRoot {
|
||||
t.Errorf("loadWorkspace = %q after OpenProjectTab, want %q", got, projectRoot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsOpenWorkspacePathAvoidsCmdShell(t *testing.T) {
|
||||
src, err := os.ReadFile("open_workspace_windows.go")
|
||||
if err != nil {
|
||||
@@ -612,6 +703,7 @@ func TestWorkspaceChangesGitStatus(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runGit(t, "init")
|
||||
runGit(t, "checkout", "-b", "feature/test")
|
||||
if err := os.WriteFile("tracked.txt", []byte("v1\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -627,6 +719,9 @@ func TestWorkspaceChangesGitStatus(t *testing.T) {
|
||||
if !got.GitAvailable {
|
||||
t.Fatalf("git unavailable: %s", got.GitErr)
|
||||
}
|
||||
if got.GitBranch != "feature/test" {
|
||||
t.Fatalf("git branch = %q, want feature/test", got.GitBranch)
|
||||
}
|
||||
byPath := map[string]WorkspaceChangeView{}
|
||||
for _, file := range got.Files {
|
||||
byPath[file.Path] = file
|
||||
@@ -725,6 +820,150 @@ func TestWorkspaceChangesUntrackedDirectoryListsFiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceChangesGitBranchDetachedHead(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git not installed")
|
||||
}
|
||||
orig, _ := os.Getwd()
|
||||
defer os.Chdir(orig)
|
||||
|
||||
dir := t.TempDir()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runGit(t, "init")
|
||||
runGit(t, "config", "user.email", "[email protected]")
|
||||
runGit(t, "config", "user.name", "Test User")
|
||||
if err := os.WriteFile("tracked.txt", []byte("v1\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runGit(t, "add", "tracked.txt")
|
||||
runGit(t, "commit", "-m", "init")
|
||||
short := gitOutput(t, "rev-parse", "--short", "HEAD")
|
||||
runGit(t, "checkout", "--detach", "HEAD")
|
||||
|
||||
got := (&App{}).WorkspaceChanges()
|
||||
if !got.GitAvailable {
|
||||
t.Fatalf("git unavailable: %s", got.GitErr)
|
||||
}
|
||||
if got.GitBranch != "@"+short {
|
||||
t.Fatalf("git branch = %q, want @%s", got.GitBranch, short)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceGitHistory(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git not installed")
|
||||
}
|
||||
orig, _ := os.Getwd()
|
||||
defer os.Chdir(orig)
|
||||
|
||||
dir := t.TempDir()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
runGit(t, "init")
|
||||
runGit(t, "config", "user.email", "[email protected]")
|
||||
runGit(t, "config", "user.name", "Test User")
|
||||
|
||||
if err := os.WriteFile("file1.txt", []byte("v1\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runGit(t, "add", "file1.txt")
|
||||
runGit(t, "commit", "-m", "init file1")
|
||||
|
||||
if err := os.WriteFile("file2.txt", []byte("v1\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runGit(t, "add", "file2.txt")
|
||||
runGit(t, "commit", "-m", "init file2")
|
||||
|
||||
app := &App{}
|
||||
history, err := app.WorkspaceGitHistory("")
|
||||
if err != nil {
|
||||
t.Fatalf("WorkspaceGitHistory err = %v", err)
|
||||
}
|
||||
if len(history) != 2 {
|
||||
t.Fatalf("expected 2 commits, got %d", len(history))
|
||||
}
|
||||
if history[0].Message != "init file2" {
|
||||
t.Errorf("expected latest commit message 'init file2', got %q", history[0].Message)
|
||||
}
|
||||
if history[1].Message != "init file1" {
|
||||
t.Errorf("expected older commit message 'init file1', got %q", history[1].Message)
|
||||
}
|
||||
|
||||
// Test history for specific file
|
||||
history, err = app.WorkspaceGitHistory("file1.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("WorkspaceGitHistory err = %v", err)
|
||||
}
|
||||
if len(history) != 1 {
|
||||
t.Fatalf("expected 1 commit for file1.txt, got %d", len(history))
|
||||
}
|
||||
if history[0].Message != "init file1" {
|
||||
t.Errorf("expected commit message 'init file1', got %q", history[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceGitCommitDetail(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git not installed")
|
||||
}
|
||||
orig, _ := os.Getwd()
|
||||
defer os.Chdir(orig)
|
||||
|
||||
dir := t.TempDir()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
runGit(t, "init")
|
||||
runGit(t, "config", "user.email", "[email protected]")
|
||||
runGit(t, "config", "user.name", "Test User")
|
||||
|
||||
if err := os.WriteFile("file1.txt", []byte("v1\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runGit(t, "add", "file1.txt")
|
||||
runGit(t, "commit", "-m", "init file1")
|
||||
|
||||
if err := os.WriteFile("file1.txt", []byte("v2\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runGit(t, "add", "file1.txt")
|
||||
runGit(t, "commit", "-m", "update file1")
|
||||
|
||||
hash := gitOutput(t, "rev-parse", "HEAD")
|
||||
|
||||
app := &App{}
|
||||
|
||||
// Test project level detail
|
||||
detail, err := app.WorkspaceGitCommitDetail(hash, "")
|
||||
if err != nil {
|
||||
t.Fatalf("WorkspaceGitCommitDetail err = %v", err)
|
||||
}
|
||||
if len(detail.Files) != 1 || detail.Files[0] != "file1.txt" {
|
||||
t.Fatalf("expected files [file1.txt], got %v", detail.Files)
|
||||
}
|
||||
if detail.Diff != nil {
|
||||
t.Fatal("expected nil diff for project level")
|
||||
}
|
||||
|
||||
// Test file level detail
|
||||
detail, err = app.WorkspaceGitCommitDetail(hash, "file1.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("WorkspaceGitCommitDetail err = %v", err)
|
||||
}
|
||||
if len(detail.Files) != 0 {
|
||||
t.Fatalf("expected no files for file level, got %v", detail.Files)
|
||||
}
|
||||
if detail.Diff == nil || !strings.Contains(*detail.Diff, "+v2") {
|
||||
t.Fatalf("expected diff to contain '+v2', got %v", detail.Diff)
|
||||
}
|
||||
}
|
||||
|
||||
func runGit(t *testing.T, args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
@@ -734,6 +973,16 @@ func runGit(t *testing.T, args ...string) {
|
||||
}
|
||||
}
|
||||
|
||||
func gitOutput(t *testing.T, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
// --- settings_app.go helpers ---
|
||||
// These are unexported but in the same package, so we can test them.
|
||||
|
||||
|
||||
+2
-2
@@ -77,8 +77,8 @@ and DeepSeek prefix-cache–oriented design.
|
||||
- **Code intelligence**: embedding semantic search is replaced by **CodeGraph**
|
||||
(`codegraph_*` tools) — a tree-sitter symbol/call graph, no embedding service or
|
||||
API cost. New (first-run) configs start with it off; existing configs keep it
|
||||
on across upgrades. Toggle `[codegraph]` in the MCP manager or config, and set
|
||||
`[codegraph].tier` to choose lazy, background, or eager startup.
|
||||
on across upgrades. Toggle `[codegraph]` in the MCP manager or config; when
|
||||
enabled it starts in the background so chat startup is never blocked.
|
||||
- **Plan mode** + `complete_step` (evidence-backed step sign-off).
|
||||
- **No web dashboard** — the v2 line is terminal + desktop (Wails), by design.
|
||||
- Some granular v1 tools are intentionally consolidated (e.g. file-management ops
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# Releasing
|
||||
|
||||
How Reasonix ships, who can ship what, and the canary-before-stable flow.
|
||||
|
||||
## Branch model: trunk + tags
|
||||
|
||||
- **`main-v2`** is the single development line (the v2 / 1.x trunk). Every PR merges here.
|
||||
- **Production is a tag, not a branch.** A release is a tagged snapshot of `main-v2`:
|
||||
`v1.4.0` (CLI), `npm-v1.4.0` (npm), `desktop-v1.4.0` (desktop).
|
||||
- **`v1`** is the archived 1.0/legacy line — maintenance only.
|
||||
- **Hotfix** an already-released version by branching from its tag, fixing, and tagging again.
|
||||
|
||||
There is no separate "production" or "develop" branch by design — the canary channel
|
||||
provides the pre-release buffer instead of a long-lived branch.
|
||||
|
||||
## Channels
|
||||
|
||||
| Surface | Stable | Pre-release buffer |
|
||||
|---|---|---|
|
||||
| npm | `latest` (0.x), `next` (1.x) | `canary` (`npm i reasonix@canary`) |
|
||||
| Desktop | R2 `latest/` pointer | R2 `canary/` pointer (R2-only — never on the GitHub releases page) |
|
||||
|
||||
A canary build is isolated: it **never** moves `latest` / `next` / desktop `latest/`.
|
||||
Testers opt in explicitly. (Desktop builds carry `-X main.channel=canary`; npm versions
|
||||
ending in `-canary.N` publish under the `canary` dist-tag.)
|
||||
|
||||
## Who can release what
|
||||
|
||||
| Action | Who | Mechanism |
|
||||
|---|---|---|
|
||||
| **Cut a canary** | any maintainer (write access) | `workflow_dispatch`, runs free (open `canary` environment) |
|
||||
| **Ship `next` / stable** | **esengine only** | stable publish jobs gate on the `release` environment — esengine must approve before anything goes public |
|
||||
|
||||
So a maintainer can dispatch a canary anytime, but a stable release — even one a
|
||||
maintainer starts by pushing a tag — pauses in the Actions UI until **esengine approves**
|
||||
the `release` environment deployment.
|
||||
|
||||
> Repo settings backing this: Environments → `release` has esengine as a required
|
||||
> reviewer; `canary` has none. (Optional hardening: a tag ruleset restricting
|
||||
> `v*`/`npm-v*`/`desktop-v*` creation to esengine, so maintainers can't even start a
|
||||
> stable release.)
|
||||
|
||||
## The release loop
|
||||
|
||||
1. **Develop** — PRs land on `main-v2` (branch auto-deletes on merge).
|
||||
2. **Cut a canary** before the intended release (e.g. heading for `1.4.0`):
|
||||
- Desktop: Actions → **Release desktop** → `channel: canary`, `base_version: 1.4.0`
|
||||
- CLI: Actions → **Release npm** → `base_version: 1.4.0`
|
||||
- Publishes `1.4.0-canary.N` to the desktop R2 `canary/` pointer (no GitHub release) and npm `@canary`.
|
||||
3. **Test** — testers install `reasonix@canary` (CLI) or grab the desktop canary
|
||||
build from its R2 link, and report bugs.
|
||||
4. **Fix** on `main-v2` via PRs; re-cut the canary as needed (`canary.N` bumps).
|
||||
5. **Ship stable** when the canary is clean — push the three tags:
|
||||
```sh
|
||||
git tag v1.4.0 && git push origin v1.4.0 # CLI binaries + Homebrew
|
||||
git tag npm-v1.4.0 && git push origin npm-v1.4.0 # npm -> next
|
||||
git tag desktop-v1.4.0 && git push origin desktop-v1.4.0 # desktop -> R2 latest/
|
||||
```
|
||||
Each stable run **waits for esengine to approve the `release` environment** before publishing.
|
||||
6. **Promote to default install** (optional, when 1.x should become the bare `npm i` target):
|
||||
```sh
|
||||
npm dist-tag add reasonix@1.4.0 latest
|
||||
```
|
||||
7. **Next cycle** — the canary rolls on toward `1.5.0`.
|
||||
|
||||
## Notes
|
||||
|
||||
- Canary version numbers use the workflow `run_number`, so the desktop and CLI canary
|
||||
numbers differ (e.g. `canary.11` vs `canary.2`). Only monotonicity per channel matters.
|
||||
- A stable `-rc` tag (e.g. `npm-v1.4.0-rc.1`) still ships under `next`, not `canary`.
|
||||
- macOS canary self-update is manual (no notarization); testers download the canary
|
||||
build from its R2 link (canary is not on the GitHub releases page).
|
||||
@@ -0,0 +1,370 @@
|
||||
# 会话引用功能架构文档
|
||||
|
||||
> GitHub Issue: https://github.com/esengine/DeepSeek-Reasonix/issues/3185
|
||||
|
||||
## 1. 功能需求
|
||||
|
||||
在当前会话中使用 `@` 引用其他会话的聊天记录作为内容发送给 AI。
|
||||
|
||||
### 1.1 用户场景
|
||||
|
||||
- 用户在当前会话中想引用之前会话的对话内容
|
||||
- 用户想让 AI 参考之前的对话上下文
|
||||
- 用户想合并多个会话的讨论结果
|
||||
|
||||
### 1.2 预期行为(P0-MVP)
|
||||
|
||||
```
|
||||
输入 @
|
||||
→ 显示原有菜单(文件/目录列表)
|
||||
→ 菜单顶部新增 "past:chats" 选项
|
||||
→ 选择 past:chats
|
||||
→ 切换到历史会话列表
|
||||
→ 选择一个会话
|
||||
→ Composer 上方显示"已引用会话"
|
||||
→ 发送时把该会话历史内容附加到当前 prompt/context
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 现有代码结构分析
|
||||
|
||||
### 2.1 核心文件
|
||||
|
||||
| 文件 | 作用 |
|
||||
|------|------|
|
||||
| `desktop/frontend/src/components/Composer.tsx` | 输入框组件,包含 @ 功能 |
|
||||
| `desktop/frontend/src/components/FileMenu.tsx` | @ 文件菜单组件 |
|
||||
| `desktop/frontend/src/components/HistoryPanel.tsx` | 历史会话面板 |
|
||||
| `desktop/frontend/src/lib/bridge.ts` | 前后端通信接口 |
|
||||
| `desktop/frontend/src/lib/types.ts` | 类型定义 |
|
||||
|
||||
### 2.2 现有 @ 功能实现
|
||||
|
||||
**Composer.tsx 第 257-337 行** 实现了文件引用功能:
|
||||
|
||||
```typescript
|
||||
// --- @ file references ---
|
||||
const atRaw = useMemo(() => {
|
||||
const m = /(?:^|\s)@([^\s]*)$/.exec(text);
|
||||
return m ? m[1] : null;
|
||||
}, [text]);
|
||||
|
||||
// 文件匹配结果
|
||||
const atMatches = useMemo(() => {
|
||||
// 过滤本地目录和搜索结果
|
||||
}, [atRaw, atFrag, entries, searchEntries]);
|
||||
|
||||
// 菜单模式判断
|
||||
const menuMode: "slash" | "slasharg" | "at" | null = ...;
|
||||
|
||||
// 渲染文件菜单
|
||||
{menuMode === "at" && <FileMenu items={atMatches} ... />}
|
||||
```
|
||||
|
||||
### 2.3 已有的会话 API(可复用)
|
||||
|
||||
```typescript
|
||||
interface AppBindings {
|
||||
// 会话列表
|
||||
ListSessions(): Promise<SessionMeta[]>;
|
||||
|
||||
// 会话操作(可复用读取历史)
|
||||
PreviewSession(path: string): Promise<HistoryMessage[]>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. P0-MVP 实施方案
|
||||
|
||||
### 3.1 设计思路
|
||||
|
||||
在现有的 `@` 菜单中添加 "past:chats" 选项,而不是创建新的 `@session:` 语法。
|
||||
|
||||
**菜单结构:**
|
||||
```
|
||||
@
|
||||
├── 📁 past:chats ← 新增:选择后显示历史会话列表
|
||||
├── 📁 src/
|
||||
├── 📁 docs/
|
||||
├── 📄 README.md
|
||||
└── ...
|
||||
```
|
||||
|
||||
### 3.2 实施路线
|
||||
|
||||
```
|
||||
第一步:后端加搜索接口
|
||||
↓
|
||||
第二步:前端 bridge.ts 暴露接口
|
||||
↓
|
||||
第三步:在 @ 菜单中添加 "past:chats" 选项
|
||||
↓
|
||||
第四步:选择 past:chats 后切换到会话列表
|
||||
↓
|
||||
第五步:选择会话后添加到引用区域
|
||||
↓
|
||||
第六步:发送时附加会话上下文
|
||||
```
|
||||
|
||||
### 3.3 最小改动文件清单
|
||||
|
||||
```
|
||||
desktop/frontend/src/lib/types.ts — 添加 SessionReference 类型
|
||||
desktop/frontend/src/lib/bridge.ts — 添加 SearchSessions API
|
||||
desktop/frontend/src/components/Composer.tsx — 扩展 @ 菜单逻辑
|
||||
desktop/frontend/src/components/FileMenu.tsx — 扩展菜单支持会话项
|
||||
desktop/app.go — 添加 SearchSessions 方法
|
||||
desktop/sessions.go — 实现会话搜索逻辑
|
||||
```
|
||||
|
||||
### 3.4 类型定义
|
||||
|
||||
```typescript
|
||||
// types.ts
|
||||
export interface SessionReference {
|
||||
path: string;
|
||||
title: string;
|
||||
preview?: string;
|
||||
turns?: number;
|
||||
createdAt?: number;
|
||||
lastActivityAt?: number;
|
||||
messages?: HistoryMessage[]; // P0 先不存,发送时再拉取
|
||||
}
|
||||
```
|
||||
|
||||
### 3.5 API 设计
|
||||
|
||||
```typescript
|
||||
// bridge.ts
|
||||
interface AppBindings {
|
||||
// 新增:搜索会话
|
||||
SearchSessions(query: string): Promise<SessionMeta[]>;
|
||||
|
||||
// 已有:读取会话历史(复用)
|
||||
PreviewSession(path: string): Promise<HistoryMessage[]>;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.6 前端逻辑修改
|
||||
|
||||
**Composer.tsx 修改:**
|
||||
|
||||
```typescript
|
||||
// 1. 添加状态
|
||||
const [showPastChats, setShowPastChats] = useState(false);
|
||||
const [pastChats, setPastChats] = useState<SessionMeta[]>([]);
|
||||
const [sessionRefs, setSessionRefs] = useState<SessionReference[]>([]);
|
||||
|
||||
// 2. 修改 @ 菜单渲染
|
||||
{menuMode === "at" && (
|
||||
showPastChats ? (
|
||||
// 显示会话列表
|
||||
<SessionMenu
|
||||
items={pastChats}
|
||||
activeIndex={active}
|
||||
onPick={pickSession}
|
||||
onHover={setActive}
|
||||
/>
|
||||
) : (
|
||||
// 显示文件列表(原有逻辑)
|
||||
<>
|
||||
<button
|
||||
className="slashmenu__item slashmenu__item--special"
|
||||
onMouseDown={() => {
|
||||
setShowPastChats(true);
|
||||
app.ListSessions().then(setPastChats);
|
||||
}}
|
||||
>
|
||||
<MessageSquare size={13} />
|
||||
<span className="slashmenu__name">past:chats</span>
|
||||
<span className="slashmenu__desc">引用历史会话</span>
|
||||
</button>
|
||||
<FileMenu items={atMatches} ... />
|
||||
</>
|
||||
)
|
||||
)}
|
||||
|
||||
// 3. 选择会话后的处理
|
||||
const pickSession = (session: SessionMeta) => {
|
||||
// 添加到引用区域
|
||||
setSessionRefs(prev => [...prev, {
|
||||
path: session.path,
|
||||
title: session.title || session.preview || "Untitled",
|
||||
preview: session.preview,
|
||||
turns: session.turns,
|
||||
createdAt: session.createdAt,
|
||||
lastActivityAt: session.lastActivityAt,
|
||||
}]);
|
||||
|
||||
// 重置状态
|
||||
setShowPastChats(false);
|
||||
setText(""); // 清空输入框
|
||||
};
|
||||
|
||||
// 4. 发送时附加会话上下文
|
||||
const handleSubmit = async () => {
|
||||
let context = "";
|
||||
|
||||
if (sessionRefs.length > 0) {
|
||||
context = "以下是用户引用的历史会话上下文:\n\n";
|
||||
for (const ref of sessionRefs) {
|
||||
const messages = await app.PreviewSession(ref.path);
|
||||
const limited = limitMessages(messages, 30, 20000);
|
||||
context += formatSessionContext(ref.title, limited);
|
||||
}
|
||||
context += "\n\n当前用户问题:\n";
|
||||
}
|
||||
|
||||
onSubmit(context + text);
|
||||
};
|
||||
```
|
||||
|
||||
### 3.7 限制策略
|
||||
|
||||
```
|
||||
最多引用最近 30 条消息
|
||||
或最多 20k 字符
|
||||
超出部分截断,并提示"已截断"
|
||||
```
|
||||
|
||||
### 3.8 发送时的消息格式
|
||||
|
||||
```
|
||||
以下是用户引用的历史会话上下文:
|
||||
|
||||
[会话:修复登录 bug]
|
||||
用户:...
|
||||
助手:...
|
||||
用户:...
|
||||
|
||||
当前用户问题:
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 架构图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Composer.tsx │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ @ 菜单逻辑 │ │
|
||||
│ │ - 原有:文件/目录列表 │ │
|
||||
│ │ - 新增:past:chats 选项(在菜单顶部) │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ 菜单渲染 │ │
|
||||
│ │ - showPastChats=false → FileMenu + past:chats 按钮 │ │
|
||||
│ │ - showPastChats=true → SessionMenu (会话列表) │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ bridge.ts │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ 新增 API: │ │
|
||||
│ │ - SearchSessions(query): Promise<SessionMeta[]> │ │
|
||||
│ │ │ │
|
||||
│ │ 复用 API: │ │
|
||||
│ │ - ListSessions(): Promise<SessionMeta[]> │ │
|
||||
│ │ - PreviewSession(path): Promise<HistoryMessage[]> │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ desktop/app.go │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ 新增方法: │ │
|
||||
│ │ - SearchSessions(query string) []SessionMeta │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. UI 设计
|
||||
|
||||
### 5.1 @ 菜单(showPastChats=false)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ @ ← 用户输入 │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ 💬 past:chats 引用历史会话 │ │
|
||||
│ ├─────────────────────────────────────────────────────┤ │
|
||||
│ │ 📁 src/ │ │
|
||||
│ │ 📁 docs/ │ │
|
||||
│ │ 📄 README.md │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [输入消息...] [发送] │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 5.2 会话列表(showPastChats=true)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ @past:chats ← 用户输入 │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ 💬 项目架构设计讨论 - 2026-06-04 │ │
|
||||
│ │ 💬 数据处理方案 - 2026-06-03 │ │
|
||||
│ │ 💬 API 接口设计 - 2026-06-02 │ │
|
||||
│ │ ← 返回文件列表 │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [输入消息...] [发送] │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 5.3 引用区域
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 📎 引用的会话: │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ 📄 项目架构设计讨论 (8 轮) [×] │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ [输入消息...] [发送] │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. P1 后续优化(暂不实施)
|
||||
|
||||
- 选择单条/多条消息
|
||||
- hover 预览会话详情
|
||||
- 截断/缓存优化
|
||||
- 国际化翻译
|
||||
- 搜索会话功能
|
||||
|
||||
---
|
||||
|
||||
## 7. 验收标准
|
||||
|
||||
### P0 验收
|
||||
|
||||
- [ ] 输入 `@` 显示菜单,包含 "past:chats" 选项
|
||||
- [ ] 选择 "past:chats" 显示历史会话列表
|
||||
- [ ] 选择会话后显示在引用区域
|
||||
- [ ] 可以删除引用的会话
|
||||
- [ ] 发送时正确附加会话上下文
|
||||
- [ ] 引用内容限制在 30 条消息或 20k 字符内
|
||||
- [ ] 超出部分截断并提示
|
||||
|
||||
### 测试用例
|
||||
|
||||
- [ ] 无历史会话时的行为
|
||||
- [ ] 引用超大会话时的截断
|
||||
- [ ] 与现有 @ 文件引用同时使用
|
||||
- [ ] 在不同主题下的显示效果
|
||||
+38
-16
@@ -168,7 +168,8 @@ prefix cache-stable:
|
||||
- The **planner** (low-frequency) runs in its own session with the same standing
|
||||
memory context plus a filtered read-only research tool set, then produces a
|
||||
concise plan. It can inspect files/docs before planning, but writer and
|
||||
workflow tools are not exposed to it.
|
||||
workflow tools are not exposed to it. `agent.planner_max_steps` bounds this
|
||||
read-only exploration independently from the executor's `agent.max_steps`.
|
||||
- The plan is handed off as structured text to the **executor** — a full
|
||||
tool-using `Agent` in its own session — which carries it out.
|
||||
- The sessions never mix, so neither model's prefix is disturbed by the other's
|
||||
@@ -215,18 +216,34 @@ type Policy struct { Mode Decision; Allow, Ask, Deny []Rule }
|
||||
func (p Policy) Decide(toolName string, readOnly bool, args json.RawMessage) Decision
|
||||
```
|
||||
|
||||
- **Rule syntax.** A rule is `ToolName` (matches any call to that tool) or
|
||||
`ToolName(glob)` (matches when the call's *subject* matches the glob, via
|
||||
`path.Match`). The subject is extracted generically from the call's JSON args
|
||||
by a small set of known keys — `command` (bash), `path` / `file_path`
|
||||
(file tools), `pattern` (grep/glob) — so tools need not change. A rule whose
|
||||
subject the args don't expose only matches in its bare `ToolName` form.
|
||||
- **Rule syntax.** A rule is `Tool` (matches any call in that tool family) or
|
||||
`Tool(specifier)` (matches when the call's *subject* matches the specifier).
|
||||
Bash and file mutation approvals use Claude Code-style families such as
|
||||
`Bash(npm run build)`, `Bash(npm run test:*)`, and `Edit(docs/**)`. Legacy
|
||||
lowercase tool IDs and `tool=literal` rules still load for compatibility. The
|
||||
`:*` suffix marks a Bash command-prefix approval; generated prefix rules also
|
||||
reject later commands that introduce shell operators, so `Bash(go test:*)`
|
||||
does not cover `go test ./... && rm -rf tmp`.
|
||||
Legacy `Bash(go test *)` prefix rules still load, but new rules are saved as
|
||||
`Bash(go test:*)`. The subject is extracted generically from the call's JSON
|
||||
args by a small set of
|
||||
known keys — `command` (bash), `path` / `file_path` (file tools), `pattern`
|
||||
(grep/glob) — so tools need not change. A rule whose subject the args don't
|
||||
expose only matches in its bare `Tool` form.
|
||||
- **Precedence.** `deny` > `ask` > `allow` > fallback. Fallback is `Allow` for
|
||||
read-only tools and `Mode` (default `Ask`) for writers. `deny` always wins, so
|
||||
a broad `allow = ["bash"]` can still be carved by `deny = ["bash(rm -rf*)"]`;
|
||||
a broad `allow = ["Bash"]` can still be carved by `deny = ["Bash(rm -rf*)"]`;
|
||||
conversely `ask` overrides a broad `allow` to force a prompt on a risky subset.
|
||||
- **Resolving `Ask`.** The interactive front-end (the chat TUI) prompts the user
|
||||
— allow once / always allow / deny — via an `Approver`. A non-interactive run
|
||||
— allow once / allow this approval scope for the session / always allow this
|
||||
approval scope / deny — via an `Approver`. For Bash, the default scope is the
|
||||
concrete command subject, and the user may choose a conservative command-prefix
|
||||
scope when available (for example `Bash(go test:*)`) so similar invocations in
|
||||
the same session or saved config do not prompt again. For file-mutation tools,
|
||||
a session grant covers editing for the rest of the session while a persisted
|
||||
grant is path-scoped when a path is available, stored as `Edit(<path>)` so all
|
||||
built-in file-mutating tools share it. A
|
||||
non-interactive run
|
||||
(`reasonix run`, a sub-agent, anything with no TTY / no approver) cannot prompt, so
|
||||
it resolves `Ask` to **allow** — preserving autonomous behaviour. A `Deny` is a
|
||||
hard block in *every* mode: the tool never executes and the model receives a
|
||||
@@ -243,8 +260,10 @@ each writer/bash call. `deny` rules harden both modes.
|
||||
|
||||
The chat TUI accepts `/command` input. Three kinds share one dispatch:
|
||||
|
||||
- **Built-in actions** (`/compact`, `/new`, `/effort`, `/mcp`, `/help`) manipulate session
|
||||
state locally and never reach the model.
|
||||
- **Built-in actions** (`/compact`, `/new`/`/clear`, `/effort`, `/mcp`, `/help`) manipulate session
|
||||
state locally and never reach the model. `/new` and its Claude Code-compatible
|
||||
alias `/clear` start a fresh model context while saving the previous transcript
|
||||
for resume/history; they do not delete persisted history or project memory.
|
||||
- **Custom commands** are Markdown files under `.reasonix/commands/` (project) and
|
||||
`~/.config/reasonix/commands/` (user); the project dir overrides the user dir on a
|
||||
name clash. A file `review.md` becomes `/review`; a subdirectory namespaces it
|
||||
@@ -344,7 +363,9 @@ type Chunk struct {
|
||||
Resolution order: **flag > project `./reasonix.toml` > user `~/.config/reasonix/config.toml`
|
||||
> built-in defaults**. Secrets come from the environment via `api_key_env` and
|
||||
are never stored in config files. A `.env` in the working directory is loaded if
|
||||
present.
|
||||
present. Step-limit preferences usually belong in the user config; project
|
||||
`reasonix.toml` should override them only when the repository needs shared
|
||||
runtime bounds.
|
||||
|
||||
```toml
|
||||
default_model = "deepseek" # provider name (→ its default model) or "provider/model"
|
||||
@@ -352,8 +373,9 @@ default_model = "deepseek" # provider name (→ its default model) or "provide
|
||||
|
||||
[agent]
|
||||
system_prompt = "You are Reasonix, a coding agent..." # or system_prompt_file = "..."
|
||||
max_steps = 25
|
||||
temperature = 0.0
|
||||
max_steps = 0 # executor tool-call rounds; 0 = no limit
|
||||
planner_max_steps = 12 # planner read-only tool-call rounds; 0 = no limit
|
||||
temperature = 0.0
|
||||
# planner_model = "mimo" # optional: two-model collaboration (low-frequency planner)
|
||||
# subagent_model = "deepseek-pro" # optional default for runAs=subagent skills
|
||||
# subagent_models = { review = "deepseek-pro", security_review = "deepseek-pro" }
|
||||
@@ -394,8 +416,8 @@ bash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local ca
|
||||
|
||||
[permissions]
|
||||
mode = "ask" # writer fallback when no rule matches: ask|allow|deny
|
||||
deny = ["bash(rm -rf*)", "bash(git push*)"] # hard-blocked in every mode
|
||||
allow = ["bash(go test*)", "bash(git status*)"] # never prompted
|
||||
deny = ["Bash(rm -rf*)", "Bash(git push*)"] # hard-blocked in every mode
|
||||
allow = ["Bash(go test:*)", "Bash(git status:*)"] # never prompted
|
||||
ask = [] # force a prompt even if otherwise allowed
|
||||
|
||||
[sandbox]
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1900" viewBox="0 0 1600 1900" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Reasonix approval authorization mock states</title>
|
||||
<desc id="desc">Mock UI states for exact command approval, command-prefix approval, file mutation approval, dangerous command approval, and persisted permission notices.</desc>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#101113"/>
|
||||
<stop offset="1" stop-color="#171a1d"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="card" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#202327"/>
|
||||
<stop offset="1" stop-color="#1a1d20"/>
|
||||
</linearGradient>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="18" stdDeviation="18" flood-color="#000000" flood-opacity="0.36"/>
|
||||
</filter>
|
||||
<style>
|
||||
.page-title{font:700 42px Inter,Arial,sans-serif;fill:#f5f7fa}
|
||||
.page-sub{font:400 20px Inter,Arial,sans-serif;fill:#a8b0ba}
|
||||
.section-title{font:700 24px Inter,Arial,sans-serif;fill:#f4f6f8}
|
||||
.muted{font:400 16px Inter,Arial,sans-serif;fill:#a5adb7}
|
||||
.tiny{font:500 13px Inter,Arial,sans-serif;fill:#8d96a2}
|
||||
.mono{font:500 15px "SFMono-Regular",Menlo,Consolas,monospace;fill:#dbe3ea}
|
||||
.mono-small{font:500 13px "SFMono-Regular",Menlo,Consolas,monospace;fill:#cdd6df}
|
||||
.label{font:600 15px Inter,Arial,sans-serif;fill:#f0f3f5}
|
||||
.btn-text{font:600 15px Inter,Arial,sans-serif;fill:#f6f8fa}
|
||||
.btn-rule{font:500 13px "SFMono-Regular",Menlo,Consolas,monospace;fill:#ffffff}
|
||||
.key{font:800 13px "SFMono-Regular",Menlo,Consolas,monospace;fill:#141516}
|
||||
.notice{font:600 17px Inter,Arial,sans-serif;fill:#eaf2ee}
|
||||
.notice-rule{font:600 15px "SFMono-Regular",Menlo,Consolas,monospace;fill:#ffffff}
|
||||
</style>
|
||||
</defs>
|
||||
|
||||
<rect width="1600" height="1900" fill="url(#bg)"/>
|
||||
<text x="80" y="88" class="page-title">Reasonix 授权交互 Mock</text>
|
||||
<text x="80" y="126" class="page-sub">展示规则可见化、命令前缀授权、文件编辑共享授权、危险命令降级、持久化 Notice 与去重覆盖提示。</text>
|
||||
|
||||
<!-- Mock 1 -->
|
||||
<g transform="translate(80 180)" filter="url(#shadow)">
|
||||
<rect width="720" height="480" rx="18" fill="url(#card)" stroke="#3b424b"/>
|
||||
<rect x="0" y="0" width="720" height="72" rx="18" fill="#252a30"/>
|
||||
<text x="28" y="44" class="section-title">1. Bash:精简为前缀授权</text>
|
||||
<text x="28" y="104" class="muted">Allow tool call</text>
|
||||
<rect x="28" y="124" width="664" height="58" rx="10" fill="#15181b" stroke="#333942"/>
|
||||
<text x="44" y="158" class="mono">bash — go test ./...</text>
|
||||
|
||||
<g transform="translate(28 210)">
|
||||
<rect width="142" height="54" rx="9" fill="#5f4630" stroke="#ca8b45"/>
|
||||
<circle cx="27" cy="27" r="13" fill="#f4a84b"/>
|
||||
<text x="23" y="32" class="key">1</text>
|
||||
<text x="52" y="32" class="btn-text">允许一次</text>
|
||||
</g>
|
||||
<g transform="translate(188 210)">
|
||||
<rect width="230" height="74" rx="9" fill="#263328" stroke="#62a66e"/>
|
||||
<circle cx="27" cy="28" r="13" fill="#76d083"/>
|
||||
<text x="23" y="33" class="key">2</text>
|
||||
<text x="52" y="26" class="btn-text">本会话允许</text>
|
||||
<text x="52" y="52" class="btn-rule">Bash(go test:*)</text>
|
||||
</g>
|
||||
<g transform="translate(438 210)">
|
||||
<rect width="264" height="74" rx="9" fill="#263328" stroke="#62a66e"/>
|
||||
<circle cx="27" cy="28" r="13" fill="#76d083"/>
|
||||
<text x="23" y="33" class="key">3</text>
|
||||
<text x="52" y="26" class="btn-text">总是允许(保存)</text>
|
||||
<text x="52" y="52" class="btn-rule">Bash(go test:*)</text>
|
||||
</g>
|
||||
<g transform="translate(28 312)">
|
||||
<rect width="118" height="54" rx="9" fill="#302328" stroke="#8a4c58"/>
|
||||
<circle cx="27" cy="27" r="13" fill="#ef6b7c"/>
|
||||
<text x="23" y="32" class="key">4</text>
|
||||
<text x="52" y="32" class="btn-text">拒绝</text>
|
||||
</g>
|
||||
<text x="28" y="438" class="tiny">效果:选择 2/3 后,后续 go test ./internal/control 不再重复弹审批。</text>
|
||||
</g>
|
||||
|
||||
<!-- Mock 2 -->
|
||||
<g transform="translate(840 180)" filter="url(#shadow)">
|
||||
<rect width="720" height="480" rx="18" fill="url(#card)" stroke="#3b424b"/>
|
||||
<rect x="0" y="0" width="720" height="72" rx="18" fill="#252a30"/>
|
||||
<text x="28" y="44" class="section-title">2. CLI:同样展示具体规则</text>
|
||||
<rect x="28" y="104" width="664" height="324" rx="12" fill="#0f1215" stroke="#343a42"/>
|
||||
<text x="50" y="140" class="mono-small">⏸ 需要你的许可</text>
|
||||
<text x="50" y="174" class="mono-small">将调用工具 bash go test ./...</text>
|
||||
<text x="50" y="216" class="mono-small">1. 本次允许</text>
|
||||
<text x="50" y="244" class="mono-small">2. 本会话允许 Bash(go test:*)</text>
|
||||
<text x="50" y="272" class="mono-small">3. 总是允许 Bash(go test:*)(保存到配置)</text>
|
||||
<text x="50" y="300" class="mono-small">4. 拒绝</text>
|
||||
<text x="50" y="342" class="mono-small">选择 [1/2/3/4](兼容 y/a/p/n)</text>
|
||||
<text x="28" y="456" class="tiny">效果:终端用户不需要猜“授权范围”到底是什么。</text>
|
||||
</g>
|
||||
|
||||
<!-- Mock 3 -->
|
||||
<g transform="translate(80 720)" filter="url(#shadow)">
|
||||
<rect width="720" height="480" rx="18" fill="url(#card)" stroke="#3b424b"/>
|
||||
<rect x="0" y="0" width="720" height="72" rx="18" fill="#252a30"/>
|
||||
<text x="28" y="44" class="section-title">3. 文件修改:session 共享,持久化按路径</text>
|
||||
<text x="28" y="104" class="muted">Allow tool call</text>
|
||||
<rect x="28" y="124" width="664" height="58" rx="10" fill="#15181b" stroke="#333942"/>
|
||||
<text x="44" y="158" class="mono">edit_file — src/app.go</text>
|
||||
<g transform="translate(28 218)">
|
||||
<rect width="142" height="54" rx="9" fill="#5f4630" stroke="#ca8b45"/>
|
||||
<circle cx="27" cy="27" r="13" fill="#f4a84b"/>
|
||||
<text x="23" y="32" class="key">1</text>
|
||||
<text x="52" y="32" class="btn-text">允许一次</text>
|
||||
</g>
|
||||
<g transform="translate(188 208)">
|
||||
<rect width="238" height="74" rx="9" fill="#20252b" stroke="#4a5360"/>
|
||||
<circle cx="27" cy="28" r="13" fill="#aeb7c2"/>
|
||||
<text x="23" y="33" class="key">2</text>
|
||||
<text x="52" y="26" class="btn-text">本会话允许</text>
|
||||
<text x="52" y="52" class="btn-rule">Edit</text>
|
||||
</g>
|
||||
<g transform="translate(448 208)">
|
||||
<rect width="244" height="74" rx="9" fill="#263328" stroke="#62a66e"/>
|
||||
<circle cx="27" cy="28" r="13" fill="#76d083"/>
|
||||
<text x="23" y="33" class="key">3</text>
|
||||
<text x="52" y="26" class="btn-text">总是允许(保存)</text>
|
||||
<text x="52" y="52" class="btn-rule">Edit(src/app.go)</text>
|
||||
</g>
|
||||
<g transform="translate(28 324)">
|
||||
<rect width="118" height="54" rx="9" fill="#302328" stroke="#8a4c58"/>
|
||||
<circle cx="27" cy="27" r="13" fill="#ef6b7c"/>
|
||||
<text x="23" y="32" class="key">4</text>
|
||||
<text x="52" y="32" class="btn-text">拒绝</text>
|
||||
</g>
|
||||
<text x="28" y="438" class="tiny">效果:本会话允许 edit/write/multi_edit 共享;保存时只允许这个路径。</text>
|
||||
</g>
|
||||
|
||||
<!-- Mock 4 -->
|
||||
<g transform="translate(840 720)" filter="url(#shadow)">
|
||||
<rect width="720" height="480" rx="18" fill="url(#card)" stroke="#3b424b"/>
|
||||
<rect x="0" y="0" width="720" height="72" rx="18" fill="#252a30"/>
|
||||
<text x="28" y="44" class="section-title">4. 危险 Bash:不提供前缀授权</text>
|
||||
<text x="28" y="104" class="muted">Allow tool call</text>
|
||||
<rect x="28" y="124" width="664" height="58" rx="10" fill="#15181b" stroke="#63363f"/>
|
||||
<text x="44" y="158" class="mono">bash — rm -rf /tmp/build</text>
|
||||
<g transform="translate(28 218)">
|
||||
<rect width="142" height="54" rx="9" fill="#5f4630" stroke="#ca8b45"/>
|
||||
<circle cx="27" cy="27" r="13" fill="#f4a84b"/>
|
||||
<text x="23" y="32" class="key">1</text>
|
||||
<text x="52" y="32" class="btn-text">允许一次</text>
|
||||
</g>
|
||||
<g transform="translate(188 208)">
|
||||
<rect width="258" height="74" rx="9" fill="#20252b" stroke="#4a5360"/>
|
||||
<circle cx="27" cy="28" r="13" fill="#aeb7c2"/>
|
||||
<text x="23" y="33" class="key">2</text>
|
||||
<text x="52" y="26" class="btn-text">本会话允许</text>
|
||||
<text x="52" y="52" class="btn-rule">Bash(rm -rf /tmp/build)</text>
|
||||
</g>
|
||||
<g transform="translate(468 208)">
|
||||
<rect width="224" height="74" rx="9" fill="#263328" stroke="#62a66e"/>
|
||||
<circle cx="27" cy="28" r="13" fill="#76d083"/>
|
||||
<text x="23" y="33" class="key">3</text>
|
||||
<text x="52" y="26" class="btn-text">总是允许(保存)</text>
|
||||
<text x="52" y="52" class="btn-rule">Bash(rm -rf /tmp/build)</text>
|
||||
</g>
|
||||
<g transform="translate(28 324)">
|
||||
<rect width="118" height="54" rx="9" fill="#302328" stroke="#8a4c58"/>
|
||||
<circle cx="27" cy="27" r="13" fill="#ef6b7c"/>
|
||||
<text x="23" y="32" class="key">4</text>
|
||||
<text x="52" y="32" class="btn-text">拒绝</text>
|
||||
</g>
|
||||
<text x="28" y="438" class="tiny">效果:危险命令不会出现 Bash(rm -rf:*) 这种宽授权。</text>
|
||||
</g>
|
||||
|
||||
<!-- Mock 5 -->
|
||||
<g transform="translate(80 1260)" filter="url(#shadow)">
|
||||
<rect width="720" height="420" rx="18" fill="url(#card)" stroke="#3b424b"/>
|
||||
<rect x="0" y="0" width="720" height="72" rx="18" fill="#252a30"/>
|
||||
<text x="28" y="44" class="section-title">5. 保存成功 Notice</text>
|
||||
<rect x="28" y="112" width="664" height="92" rx="12" fill="#15251b" stroke="#4a9860"/>
|
||||
<circle cx="60" cy="158" r="18" fill="#76d083"/>
|
||||
<text x="54" y="165" class="key">i</text>
|
||||
<text x="88" y="150" class="notice">授权已保存到 /workspace/reasonix.toml</text>
|
||||
<text x="88" y="178" class="notice-rule">Bash(go test:*)</text>
|
||||
<rect x="28" y="246" width="664" height="84" rx="12" fill="#101316" stroke="#333942"/>
|
||||
<text x="48" y="280" class="mono-small">[permissions]</text>
|
||||
<text x="48" y="308" class="mono-small">allow = ["Bash(go test:*)"]</text>
|
||||
<text x="28" y="374" class="tiny">效果:用户知道写入的是项目配置,而不是用户全局配置。</text>
|
||||
</g>
|
||||
|
||||
<!-- Mock 6 -->
|
||||
<g transform="translate(840 1260)" filter="url(#shadow)">
|
||||
<rect width="720" height="420" rx="18" fill="url(#card)" stroke="#3b424b"/>
|
||||
<rect x="0" y="0" width="720" height="72" rx="18" fill="#252a30"/>
|
||||
<text x="28" y="44" class="section-title">6. 已覆盖 / 去重 Notice</text>
|
||||
<rect x="28" y="112" width="664" height="92" rx="12" fill="#17212a" stroke="#4b708f"/>
|
||||
<circle cx="60" cy="158" r="18" fill="#80b7e8"/>
|
||||
<text x="54" y="165" class="key">i</text>
|
||||
<text x="88" y="150" class="notice">授权已由 /workspace/reasonix.toml 中的规则覆盖</text>
|
||||
<text x="88" y="178" class="notice-rule">Bash(go test:*)</text>
|
||||
<rect x="28" y="246" width="664" height="84" rx="12" fill="#101316" stroke="#333942"/>
|
||||
<text x="48" y="280" class="mono-small">新请求:Bash(go test ./internal/control)</text>
|
||||
<text x="48" y="308" class="mono-small">已有规则:Bash(go test:*) → 不重复写入</text>
|
||||
<text x="28" y="374" class="tiny">效果:配置不会因为多次“总是允许”越来越乱。</text>
|
||||
</g>
|
||||
|
||||
<text x="80" y="1774" class="page-sub">这些 mock 对应当前实现:按钮显示具体 rule;prefix scope 只在安全可推导时出现;持久化后通过 Notice 显示保存路径或覆盖规则。</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,168 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Reasonix Approval Mock 1</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #101113;
|
||||
--panel: #1b1e22;
|
||||
--panel-2: #242930;
|
||||
--border: #3b424b;
|
||||
--muted: #a8b0ba;
|
||||
--text: #f5f7fa;
|
||||
--mono: "SFMono-Regular", Menlo, Consolas, monospace;
|
||||
--sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 36px;
|
||||
background:
|
||||
radial-gradient(circle at 20% 20%, rgba(244, 168, 75, .12), transparent 32%),
|
||||
linear-gradient(135deg, #101113, #171a1d);
|
||||
color: var(--text);
|
||||
font-family: var(--sans);
|
||||
}
|
||||
.mock {
|
||||
width: min(880px, 100%);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(#202327, #1a1d20);
|
||||
box-shadow: 0 28px 80px rgba(0,0,0,.42);
|
||||
overflow: hidden;
|
||||
}
|
||||
.mock__head {
|
||||
padding: 22px 28px;
|
||||
background: var(--panel-2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.mock__title {
|
||||
margin: 0;
|
||||
font-size: 25px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.mock__body { padding: 30px 28px 28px; }
|
||||
.eyebrow {
|
||||
margin: 0 0 14px;
|
||||
color: var(--muted);
|
||||
font-size: 15px;
|
||||
}
|
||||
.subject {
|
||||
margin-bottom: 28px;
|
||||
padding: 17px 18px;
|
||||
border: 1px solid #333942;
|
||||
border-radius: 10px;
|
||||
background: #15181b;
|
||||
font: 500 16px/1.5 var(--mono);
|
||||
color: #dbe3ea;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
.action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
min-height: 64px;
|
||||
padding: 12px 17px;
|
||||
border: 1px solid #4a5360;
|
||||
border-radius: 10px;
|
||||
background: #20252b;
|
||||
color: #f6f8fa;
|
||||
}
|
||||
.action--once {
|
||||
background: #5f4630;
|
||||
border-color: #ca8b45;
|
||||
}
|
||||
.action--prefix {
|
||||
background: #263328;
|
||||
border-color: #62a66e;
|
||||
}
|
||||
.action--deny {
|
||||
min-height: 54px;
|
||||
background: #302328;
|
||||
border-color: #8a4c58;
|
||||
}
|
||||
.key {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
background: #aeb7c2;
|
||||
color: #141516;
|
||||
font: 800 13px/1 var(--mono);
|
||||
}
|
||||
.action--once .key { background: #f4a84b; }
|
||||
.action--prefix .key { background: #76d083; }
|
||||
.action--deny .key { background: #ef6b7c; }
|
||||
.copy {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
.label {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
}
|
||||
code {
|
||||
font: 600 13px/1.35 var(--mono);
|
||||
color: #fff;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.note {
|
||||
margin: 26px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="mock" aria-label="Reasonix approval mock 1">
|
||||
<header class="mock__head">
|
||||
<h1 class="mock__title">1. Bash:精简为前缀授权</h1>
|
||||
</header>
|
||||
<section class="mock__body">
|
||||
<p class="eyebrow">Allow tool call</p>
|
||||
<div class="subject">bash - go test ./...</div>
|
||||
<div class="actions">
|
||||
<button class="action action--once">
|
||||
<span class="key">1</span>
|
||||
<span class="copy"><span class="label">允许一次</span></span>
|
||||
</button>
|
||||
<button class="action">
|
||||
<span class="key">2</span>
|
||||
<span class="copy">
|
||||
<span class="label">本会话允许</span>
|
||||
<code>Bash(go test:*)</code>
|
||||
</span>
|
||||
</button>
|
||||
<button class="action action--prefix">
|
||||
<span class="key">3</span>
|
||||
<span class="copy">
|
||||
<span class="label">总是允许(保存)</span>
|
||||
<code>Bash(go test:*)</code>
|
||||
</span>
|
||||
</button>
|
||||
<button class="action action--deny">
|
||||
<span class="key">4</span>
|
||||
<span class="copy"><span class="label">拒绝</span></span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="note">效果:选择 2/3 后,后续 <code>go test ./internal/control</code> 这类相似命令不再重复弹审批;选择 1 只允许本次。</p>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"unicode/utf8"
|
||||
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/permission"
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
@@ -45,7 +46,7 @@ const maxResultChars = 8000
|
||||
type updateSink struct {
|
||||
conn notifier
|
||||
sessionID string
|
||||
approve func(id string, allow, session, persist bool)
|
||||
approve func(id string, allow, session, persist bool, scope string)
|
||||
mu sync.Mutex
|
||||
turnCtx context.Context
|
||||
}
|
||||
@@ -57,6 +58,16 @@ func newUpdateSink(conn notifier, sessionID string) *updateSink {
|
||||
// bindApprove installs the controller's Approve callback, called by the service
|
||||
// once the controller exists (the sink is built first, to hand to the Factory).
|
||||
func (s *updateSink) bindApprove(fn func(id string, allow, session, persist bool)) {
|
||||
if fn == nil {
|
||||
s.approve = nil
|
||||
return
|
||||
}
|
||||
s.approve = func(id string, allow, session, persist bool, _ string) {
|
||||
fn(id, allow, session, persist)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *updateSink) bindApproveWithScope(fn func(id string, allow, session, persist bool, scope string)) {
|
||||
s.approve = fn
|
||||
}
|
||||
|
||||
@@ -211,6 +222,7 @@ func (s *updateSink) requestPermission(ctx context.Context, a event.Approval) {
|
||||
if a.Subject != "" {
|
||||
title = a.Tool + " " + a.Subject
|
||||
}
|
||||
options := approvalOptions(a.Tool, a.Subject)
|
||||
params := PermissionRequestParams{
|
||||
SessionID: s.sessionID,
|
||||
ToolCall: PermissionToolCall{
|
||||
@@ -219,15 +231,11 @@ func (s *updateSink) requestPermission(ctx context.Context, a event.Approval) {
|
||||
Kind: toolKindFor(a.Tool),
|
||||
Status: "pending",
|
||||
},
|
||||
Options: []PermissionOption{
|
||||
{OptionID: string(OptAllowOnce), Name: "Allow", Kind: OptAllowOnce},
|
||||
{OptionID: string(OptAllowAlways), Name: "Allow for this session", Kind: OptAllowAlways},
|
||||
{OptionID: string(OptAllowPersistent), Name: "Always allow (save to config)", Kind: OptAllowPersistent},
|
||||
{OptionID: string(OptRejectOnce), Name: "Reject", Kind: OptRejectOnce},
|
||||
},
|
||||
Options: options,
|
||||
}
|
||||
|
||||
allow, session, persist := false, false, false
|
||||
scope := permission.ApprovalScopeExact
|
||||
if raw, err := s.conn.Request(ctx, "session/request_permission", params); err == nil {
|
||||
var res PermissionRequestResult
|
||||
if json.Unmarshal(raw, &res) == nil && res.Outcome.Outcome == "selected" {
|
||||
@@ -238,10 +246,53 @@ func (s *updateSink) requestPermission(ctx context.Context, a event.Approval) {
|
||||
allow, session = true, true
|
||||
case OptAllowPersistent:
|
||||
allow, session, persist = true, true, true
|
||||
case OptAllowPrefix:
|
||||
allow, session = true, true
|
||||
scope = permission.ApprovalScopePrefix
|
||||
case OptPersistPrefix:
|
||||
allow, session, persist = true, true, true
|
||||
scope = permission.ApprovalScopePrefix
|
||||
}
|
||||
}
|
||||
}
|
||||
s.approve(a.ID, allow, session, persist)
|
||||
s.approve(a.ID, allow, session, persist, scope)
|
||||
}
|
||||
|
||||
func approvalOptionNames(tool, subject string) (session, persistent string) {
|
||||
sessionRule := permission.SessionGrantRuleForScope(tool, subject, permission.ApprovalScopeExact)
|
||||
persistentRule := permission.RememberRuleForScope(tool, subject, permission.ApprovalScopeExact)
|
||||
switch {
|
||||
case tool == "bash" && strings.TrimSpace(subject) != "":
|
||||
return "Allow " + sessionRule + " for this session", "Always allow " + persistentRule + " (save to config)"
|
||||
case permission.IsFileMutationTool(tool):
|
||||
if strings.TrimSpace(subject) != "" {
|
||||
return "Allow " + sessionRule + " for this session", "Always allow " + persistentRule + " (save to config)"
|
||||
}
|
||||
return "Allow " + sessionRule + " for this session", "Always allow " + persistentRule + " (save to config)"
|
||||
default:
|
||||
return "Allow " + sessionRule + " for this session", "Always allow " + persistentRule + " (save to config)"
|
||||
}
|
||||
}
|
||||
|
||||
func approvalOptions(tool, subject string) []PermissionOption {
|
||||
allowSessionName, allowPersistentName := approvalOptionNames(tool, subject)
|
||||
options := []PermissionOption{
|
||||
{OptionID: string(OptAllowOnce), Name: "Allow", Kind: OptAllowOnce},
|
||||
}
|
||||
if tool == "bash" && permission.BashCommandPrefix(subject) != "" {
|
||||
prefixRule := permission.RememberRuleForScope(tool, subject, permission.ApprovalScopePrefix)
|
||||
options = append(options,
|
||||
PermissionOption{OptionID: string(OptAllowPrefix), Name: "Allow " + prefixRule + " for this session", Kind: OptAllowAlways},
|
||||
PermissionOption{OptionID: string(OptPersistPrefix), Name: "Always allow " + prefixRule + " (save to config)", Kind: OptAllowPersistent},
|
||||
)
|
||||
} else {
|
||||
options = append(options,
|
||||
PermissionOption{OptionID: string(OptAllowAlways), Name: allowSessionName, Kind: OptAllowAlways},
|
||||
PermissionOption{OptionID: string(OptAllowPersistent), Name: allowPersistentName, Kind: OptAllowPersistent},
|
||||
)
|
||||
}
|
||||
options = append(options, PermissionOption{OptionID: string(OptRejectOnce), Name: "Reject", Kind: OptRejectOnce})
|
||||
return options
|
||||
}
|
||||
|
||||
// textBlock builds a text content block.
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"unicode/utf8"
|
||||
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/permission"
|
||||
)
|
||||
|
||||
// fakeNotifier captures Notify calls and answers Request via an injectable hook,
|
||||
@@ -229,6 +230,59 @@ func TestUpdateSinkApprovalAllowAlways(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSinkApprovalBashPrefix(t *testing.T) {
|
||||
fn := &fakeNotifier{onReq: func(_ string, params any) (json.RawMessage, error) {
|
||||
raw, _ := json.Marshal(params)
|
||||
var p PermissionRequestParams
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
t.Fatalf("permission params: %v", err)
|
||||
}
|
||||
var hasExactSession, hasPrefix, hasPersistPrefix bool
|
||||
for _, opt := range p.Options {
|
||||
hasExactSession = hasExactSession || opt.OptionID == string(OptAllowAlways)
|
||||
hasPrefix = hasPrefix || opt.OptionID == string(OptAllowPrefix)
|
||||
hasPersistPrefix = hasPersistPrefix || opt.OptionID == string(OptPersistPrefix)
|
||||
}
|
||||
if hasExactSession {
|
||||
t.Fatalf("options = %+v, exact bash session choice should be omitted when a prefix is available", p.Options)
|
||||
}
|
||||
if !hasPrefix || !hasPersistPrefix {
|
||||
t.Fatalf("options = %+v, want bash prefix choices", p.Options)
|
||||
}
|
||||
if len(p.Options) != 4 {
|
||||
t.Fatalf("options = %+v, want allow once, prefix session, prefix persistent, reject", p.Options)
|
||||
}
|
||||
res, _ := json.Marshal(PermissionRequestResult{
|
||||
Outcome: PermissionOutcome{Outcome: "selected", OptionID: string(OptPersistPrefix)},
|
||||
})
|
||||
return res, nil
|
||||
}}
|
||||
sink := newUpdateSink(fn, "sess-1")
|
||||
type scopedApproveCall struct {
|
||||
id string
|
||||
allow bool
|
||||
session bool
|
||||
persist bool
|
||||
scope string
|
||||
}
|
||||
got := make(chan scopedApproveCall, 1)
|
||||
sink.bindApproveWithScope(func(id string, allow, session, persist bool, scope string) {
|
||||
got <- scopedApproveCall{id, allow, session, persist, scope}
|
||||
})
|
||||
|
||||
sink.Emit(event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "10", Tool: "bash", Subject: "go test ./..."}})
|
||||
|
||||
select {
|
||||
case c := <-got:
|
||||
want := scopedApproveCall{id: "10", allow: true, session: true, persist: true, scope: permission.ApprovalScopePrefix}
|
||||
if c != want {
|
||||
t.Errorf("approve = %+v, want %+v", c, want)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("approve was never called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSinkApprovalDenied(t *testing.T) {
|
||||
// Both a "cancelled" outcome and a transport error must deny the call.
|
||||
for _, tc := range []struct {
|
||||
|
||||
@@ -358,6 +358,8 @@ const (
|
||||
OptAllowOnce PermissionOptionKind = "allow_once"
|
||||
OptAllowAlways PermissionOptionKind = "allow_always"
|
||||
OptAllowPersistent PermissionOptionKind = "allow_persistent"
|
||||
OptAllowPrefix PermissionOptionKind = "allow_prefix"
|
||||
OptPersistPrefix PermissionOptionKind = "allow_prefix_persistent"
|
||||
OptRejectOnce PermissionOptionKind = "reject_once"
|
||||
OptRejectAlways PermissionOptionKind = "reject_always"
|
||||
)
|
||||
|
||||
@@ -243,7 +243,7 @@ func (s *service) sessionNew(ctx context.Context, raw json.RawMessage) (any, err
|
||||
return nil, &RPCError{Code: ErrInternal, Message: "session/new: " + err.Error()}
|
||||
}
|
||||
ctrl.EnableInteractiveApproval()
|
||||
sink.bindApprove(ctrl.Approve)
|
||||
sink.bindApproveWithScope(ctrl.ApproveWithScope)
|
||||
|
||||
now := time.Now().UTC()
|
||||
sess := &acpSession{id: id, ctrl: ctrl, sink: sink, cwd: cwd, createdAt: now, updatedAt: now}
|
||||
@@ -321,7 +321,7 @@ func (s *service) openExistingSession(ctx context.Context, method, id, cwdParam
|
||||
return &RPCError{Code: ErrInternal, Message: method + ": " + err.Error()}
|
||||
}
|
||||
ctrl.EnableInteractiveApproval()
|
||||
sink.bindApprove(ctrl.Approve)
|
||||
sink.bindApproveWithScope(ctrl.ApproveWithScope)
|
||||
|
||||
dir := ctrl.SessionDir()
|
||||
if dir == "" {
|
||||
|
||||
+37
-10
@@ -31,6 +31,7 @@ const maxToolOutputBytes = 32 * 1024
|
||||
const maxFinalReadinessBlocks = 3
|
||||
const maxEmptyFinalBlocks = 3
|
||||
const maxStreamRecoveries = 1
|
||||
const maxExecutorHandoffNudges = 1
|
||||
|
||||
// Renderer redraws the assistant's final-answer text as styled output. It is
|
||||
// applied only after a turn's text stream completes, so the user sees raw
|
||||
@@ -121,8 +122,12 @@ type Agent struct {
|
||||
session *Session
|
||||
sessMu sync.Mutex // guards the session pointer for external Session()/SetSession
|
||||
maxSteps int
|
||||
temperature float64
|
||||
pricing *provider.Pricing
|
||||
maxStepsKey string
|
||||
// executorHandoffGuard is enabled by Coordinator for the executor agent. The
|
||||
// per-turn marker check in Run keeps ordinary single-model turns unaffected.
|
||||
executorHandoffGuard bool
|
||||
temperature float64
|
||||
pricing *provider.Pricing
|
||||
|
||||
// sink receives the turn's typed event stream (reasoning/text deltas, tool
|
||||
// dispatch/results, usage, notices). The agent no longer formats output
|
||||
@@ -309,7 +314,10 @@ func (a *Agent) CompactNow(ctx context.Context, instructions string) error {
|
||||
|
||||
// Options configures an Agent.
|
||||
type Options struct {
|
||||
MaxSteps int
|
||||
MaxSteps int
|
||||
// MaxStepsKey names the configuration knob shown when the MaxSteps guard is
|
||||
// hit. Empty defaults to agent.max_steps.
|
||||
MaxStepsKey string
|
||||
Temperature float64
|
||||
Pricing *provider.Pricing // optional, for per-turn cost display
|
||||
|
||||
@@ -364,11 +372,16 @@ func New(prov provider.Provider, tools *tool.Registry, session *Session, opts Op
|
||||
if nilutil.IsNil(hooks) {
|
||||
hooks = nil
|
||||
}
|
||||
maxStepsKey := opts.MaxStepsKey
|
||||
if strings.TrimSpace(maxStepsKey) == "" {
|
||||
maxStepsKey = "agent.max_steps"
|
||||
}
|
||||
return &Agent{
|
||||
prov: prov,
|
||||
tools: tools,
|
||||
session: session,
|
||||
maxSteps: opts.MaxSteps,
|
||||
maxStepsKey: maxStepsKey,
|
||||
temperature: opts.Temperature,
|
||||
pricing: opts.Pricing,
|
||||
sink: sink,
|
||||
@@ -402,7 +415,10 @@ func (a *Agent) Run(ctx context.Context, input string) error {
|
||||
|
||||
finalReadinessBlocks := 0
|
||||
emptyFinalBlocks := 0
|
||||
handoffNudges := 0
|
||||
usedAnyTool := false
|
||||
streamRecoveries := 0
|
||||
executorHandoff := a.executorHandoffGuard && strings.Contains(input, executorHandoffMarker)
|
||||
for step := 0; a.maxSteps <= 0 || step < a.maxSteps; step++ {
|
||||
schemas := a.tools.Schemas()
|
||||
prefixShape := a.capturePrefixShape(schemas)
|
||||
@@ -484,12 +500,20 @@ func (a *Agent) Run(ctx context.Context, input string) error {
|
||||
a.maybeCompact(ctx, usage)
|
||||
continue
|
||||
}
|
||||
if executorHandoff && !usedAnyTool && handoffNudges < maxExecutorHandoffNudges {
|
||||
handoffNudges++
|
||||
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "executor answered without taking any action; nudging it to use its tools"})
|
||||
a.session.Add(provider.Message{Role: provider.RoleUser, Content: executorHandoffRetryMessage()})
|
||||
a.maybeCompact(ctx, usage)
|
||||
continue
|
||||
}
|
||||
if readiness.applies {
|
||||
event.RecordReadinessAudit(a.sink, readiness.audit(evidence.ReadinessAllowed, finalReadinessBlocks > 0))
|
||||
}
|
||||
return nil // model gave a final answer
|
||||
}
|
||||
emptyFinalBlocks = 0
|
||||
usedAnyTool = true
|
||||
|
||||
results := a.executeBatch(ctx, calls)
|
||||
for i, call := range calls {
|
||||
@@ -508,7 +532,7 @@ func (a *Agent) Run(ctx context.Context, input string) error {
|
||||
// Only reached when a positive maxSteps guard is configured. The work so far
|
||||
// is already in the session, so the user can just send another message to pick
|
||||
// up where it left off.
|
||||
return fmt.Errorf("paused after %d tool-call rounds (agent.max_steps) — the work so far is saved; send another message to continue, or set max_steps higher or to 0 for no limit", a.maxSteps)
|
||||
return fmt.Errorf("paused after %d tool-call rounds (%s) — the work so far is saved; send another message to continue, or set %s higher or to 0 for no limit", a.maxSteps, a.maxStepsKey, a.maxStepsKey)
|
||||
}
|
||||
|
||||
func (a *Agent) finalReadinessFailure() string {
|
||||
@@ -519,7 +543,6 @@ type finalReadinessCheck struct {
|
||||
applies bool
|
||||
reason string
|
||||
missingProjectChecks int
|
||||
missingCompleteStep bool
|
||||
incompleteTodos int
|
||||
}
|
||||
|
||||
@@ -528,7 +551,6 @@ func (c finalReadinessCheck) audit(result evidence.ReadinessAuditResult, recover
|
||||
Result: result,
|
||||
Recovered: recovered,
|
||||
MissingProjectChecks: c.missingProjectChecks,
|
||||
MissingCompleteStep: c.missingCompleteStep,
|
||||
IncompleteTodos: c.incompleteTodos,
|
||||
CommandMismatchMissing: c.missingProjectChecks,
|
||||
}
|
||||
@@ -570,10 +592,7 @@ func (a *Agent) finalReadinessCheck() finalReadinessCheck {
|
||||
missing = append(missing, fmt.Sprintf("run %q from %s after the latest write", command, finalReadinessCheckSource(check)))
|
||||
}
|
||||
}
|
||||
if hasTodoReceipt && !a.evidence.HasSuccessfulCompleteStepAfter(writer) {
|
||||
out.missingCompleteStep = true
|
||||
missing = append(missing, "call complete_step after the latest write")
|
||||
}
|
||||
|
||||
if len(missing) == 0 {
|
||||
return out
|
||||
}
|
||||
@@ -608,6 +627,13 @@ func finalReadinessRetryMessage(reason string) string {
|
||||
return "Host final-answer readiness check failed. Before giving a final answer, address the missing host-observable receipts: " + reason + ". Run the required tool calls, then answer when readiness is satisfied."
|
||||
}
|
||||
|
||||
func executorHandoffRetryMessage() string {
|
||||
return `You are already in the executor phase. The planner's read-only limitations do not apply to you.
|
||||
|
||||
Do not answer as the planner and do not ask how to trigger the executor.
|
||||
Use your available tools now to carry out the task. If a write or command is blocked by permissions or workspace boundaries, state that specific blocker and ask for the needed approval/path.`
|
||||
}
|
||||
|
||||
func hasVisibleFinalAnswer(text string) bool {
|
||||
return strings.TrimSpace(text) != ""
|
||||
}
|
||||
@@ -1028,6 +1054,7 @@ func (a *Agent) executeOne(ctx context.Context, call provider.ToolCall) toolOutc
|
||||
cctx := withCallContext(ctx, call.ID, a.sink, a.asker)
|
||||
if a.evidence != nil {
|
||||
cctx = evidence.WithLedger(cctx, a.evidence)
|
||||
cctx = evidence.WithSessionMessages(cctx, a.session.Snapshot())
|
||||
}
|
||||
if len(a.projectChecks) > 0 {
|
||||
cctx = instruction.WithChecks(cctx, a.projectChecks)
|
||||
|
||||
@@ -23,20 +23,12 @@ Given a task, produce a concise, ordered plan for the executor model to carry ou
|
||||
Use the read-only tools available to you when the task needs context from the
|
||||
workspace, user rules, or docs; keep that research targeted and stop once you
|
||||
have enough evidence. Do not write full implementations or attempt side effects.
|
||||
Outline the steps, which files to touch, and the key decisions. Keep it short and
|
||||
Do not ask the user how to trigger the executor and do not say you are waiting
|
||||
for the executor. Output executor-ready instructions: what to do, which files or
|
||||
commands are relevant, expected blockers, and key decisions. Keep it short and
|
||||
actionable.`
|
||||
|
||||
const DefaultPlannerMaxSteps = 6
|
||||
|
||||
// PlannerMaxSteps bounds planner-side read-only exploration so two-model mode
|
||||
// gains context access without letting planning turns become long-running agent
|
||||
// sessions. A lower explicit agent.max_steps remains respected.
|
||||
func PlannerMaxSteps(configured int) int {
|
||||
if configured > 0 && configured < DefaultPlannerMaxSteps {
|
||||
return configured
|
||||
}
|
||||
return DefaultPlannerMaxSteps
|
||||
}
|
||||
const executorHandoffMarker = "Reasonix executor handoff"
|
||||
|
||||
// PlannerPromptWithContext appends cache-stable standing context, such as loaded
|
||||
// REASONIX.md / AGENTS.md memory, to the planner's smaller system prompt.
|
||||
@@ -80,6 +72,9 @@ func NewCoordinator(planner provider.Provider, plannerSession *Session, plannerP
|
||||
plannerOptions.Pricing = plannerPricing
|
||||
plannerAgent = New(planner, plannerTools, plannerSession, plannerOptions, plannerSink(sink))
|
||||
}
|
||||
if executor != nil {
|
||||
executor.executorHandoffGuard = true
|
||||
}
|
||||
return &Coordinator{
|
||||
planner: planner,
|
||||
plannerSess: plannerSession,
|
||||
@@ -178,5 +173,22 @@ func plannerSink(sink event.Sink) event.Sink {
|
||||
}
|
||||
|
||||
func formatHandoff(task, plan string) string {
|
||||
return fmt.Sprintf("Task: %s\n\nA planner proposed this approach:\n%s\n\nCarry it out, adapting as needed.", task, plan)
|
||||
return fmt.Sprintf(`# %s
|
||||
|
||||
You are the executor now. Use your available tools to execute the task.
|
||||
|
||||
Original task:
|
||||
%s
|
||||
|
||||
Planner output:
|
||||
%s
|
||||
|
||||
Executor instructions:
|
||||
- Treat the planner output as context, not as your role or capability set.
|
||||
- Ignore any planner statement such as "I cannot write", "I only have read-only tools", or "hand this to the executor"; those limitations apply to the planner, not to you.
|
||||
- Do not ask the user how to trigger the executor. You are already in the executor phase.
|
||||
- If the task requires changes, call the appropriate tools (for example write/edit/bash) instead of only restating the plan.
|
||||
- If a target path is outside the writable workspace or otherwise blocked, explain that specific blocker and ask for the needed path/approval.
|
||||
|
||||
Carry out the task, adapting the plan as needed.`, executorHandoffMarker, task, plan)
|
||||
}
|
||||
@@ -74,7 +74,7 @@ func TestCoordinatorHandsPlanToExecutor(t *testing.T) {
|
||||
if got := lastUser(planner.lastReq); !strings.Contains(got, "fix the bug") {
|
||||
t.Errorf("planner saw user %q, want it to contain the task", got)
|
||||
}
|
||||
if got := lastUser(exec.lastReq); !strings.Contains(got, "read main.go") || !strings.Contains(got, "fix the bug") {
|
||||
if got := lastUser(exec.requests[0]); !strings.Contains(got, "read main.go") || !strings.Contains(got, "fix the bug") || !strings.Contains(got, "You are the executor now") {
|
||||
t.Errorf("executor saw user %q, want task + plan", got)
|
||||
}
|
||||
// planner session must accumulate (system, user, assistant-plan) so its
|
||||
@@ -170,7 +170,7 @@ func TestCoordinatorPlannerUsesReadOnlyResearchTools(t *testing.T) {
|
||||
t.Fatalf("planner tools = %v, must not include %s", tools, forbidden)
|
||||
}
|
||||
}
|
||||
if got := lastUser(exec.lastReq); !strings.Contains(got, "follow the loaded rule") || !strings.Contains(got, "fix the bug") {
|
||||
if got := lastUser(exec.requests[0]); !strings.Contains(got, "follow the loaded rule") || !strings.Contains(got, "fix the bug") {
|
||||
t.Errorf("executor saw user %q, want task + planner plan", got)
|
||||
}
|
||||
if got := plannerSess.Messages[0].Content; !strings.Contains(got, "Rule: keep changes narrow.") {
|
||||
@@ -178,6 +178,155 @@ func TestCoordinatorPlannerUsesReadOnlyResearchTools(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoordinatorPlannerMaxStepsUsesPlannerConfigKey(t *testing.T) {
|
||||
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
|
||||
{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-1", Name: "read_file", Arguments: `{"path":"REASONIX.md"}`}},
|
||||
{Type: provider.ChunkDone},
|
||||
}}
|
||||
exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
|
||||
{Type: provider.ChunkText, Text: "Done."},
|
||||
{Type: provider.ChunkDone},
|
||||
}}
|
||||
|
||||
parentReg := tool.NewRegistry()
|
||||
parentReg.Add(coordinatorTestTool{name: "read_file", readOnly: true, output: "keep reading"})
|
||||
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
|
||||
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, PlannerToolRegistry(parentReg), Options{
|
||||
MaxSteps: 2,
|
||||
MaxStepsKey: "agent.planner_max_steps",
|
||||
}, executor, 0, event.Discard, nil)
|
||||
|
||||
err := coord.Run(context.Background(), "plan a change")
|
||||
if err == nil {
|
||||
t.Fatal("Run should pause when the planner reaches its configured step limit")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "planner: paused after 2 tool-call rounds (agent.planner_max_steps)") {
|
||||
t.Fatalf("pause error = %q, want planner_max_steps message", msg)
|
||||
}
|
||||
if strings.Contains(msg, "agent.max_steps") {
|
||||
t.Fatalf("planner pause should not point at agent.max_steps: %q", msg)
|
||||
}
|
||||
if got := len(planner.requests); got != 2 {
|
||||
t.Fatalf("planner requests = %d, want exactly the configured 2 rounds", got)
|
||||
}
|
||||
if len(exec.requests) != 0 {
|
||||
t.Fatal("executor should not run when planner pauses before producing a plan")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoordinatorPlannerMaxStepsZeroIsUnlimited(t *testing.T) {
|
||||
planner := &mockProvider{name: "planner", streams: [][]provider.Chunk{
|
||||
{
|
||||
{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-1", Name: "read_file", Arguments: `{"path":"a"}`}},
|
||||
{Type: provider.ChunkDone},
|
||||
},
|
||||
{
|
||||
{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-2", Name: "read_file", Arguments: `{"path":"b"}`}},
|
||||
{Type: provider.ChunkDone},
|
||||
},
|
||||
{
|
||||
{Type: provider.ChunkText, Text: "1. use both files"},
|
||||
{Type: provider.ChunkDone},
|
||||
},
|
||||
}}
|
||||
exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
|
||||
{Type: provider.ChunkText, Text: "Done."},
|
||||
{Type: provider.ChunkDone},
|
||||
}}
|
||||
|
||||
parentReg := tool.NewRegistry()
|
||||
parentReg.Add(coordinatorTestTool{name: "read_file", readOnly: true, output: "ok"})
|
||||
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
|
||||
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, PlannerToolRegistry(parentReg), Options{
|
||||
MaxSteps: 0,
|
||||
MaxStepsKey: "agent.planner_max_steps",
|
||||
}, executor, 0, event.Discard, nil)
|
||||
|
||||
if err := coord.Run(context.Background(), "plan a change"); err != nil {
|
||||
t.Fatalf("Run with planner max steps 0 should not pause: %v", err)
|
||||
}
|
||||
if got := len(planner.requests); got != 3 {
|
||||
t.Fatalf("planner requests = %d, want all 3 scripted planner turns", got)
|
||||
}
|
||||
if got := lastUser(exec.requests[0]); !strings.Contains(got, "use both files") {
|
||||
t.Fatalf("executor did not receive planner output: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoordinatorNudgesExecutorThatAnswersWithoutActing(t *testing.T) {
|
||||
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
|
||||
{Type: provider.ChunkText, Text: "Write the requested skill file."},
|
||||
{Type: provider.ChunkDone},
|
||||
}}
|
||||
// The first turn is a plain final answer with no tool call and no
|
||||
// planner-vocabulary — the nudge must fire on the missing action, not on words.
|
||||
exec := &mockProvider{name: "executor", streams: [][]provider.Chunk{
|
||||
{
|
||||
{Type: provider.ChunkText, Text: "这个计划看起来没问题,应该很好实现。"},
|
||||
{Type: provider.ChunkDone},
|
||||
},
|
||||
{
|
||||
{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-1", Name: "write_file", Arguments: `{"path":"kan-tu.md"}`}},
|
||||
{Type: provider.ChunkDone},
|
||||
},
|
||||
{
|
||||
{Type: provider.ChunkText, Text: "Done."},
|
||||
{Type: provider.ChunkDone},
|
||||
},
|
||||
}}
|
||||
|
||||
execReg := tool.NewRegistry()
|
||||
execReg.Add(coordinatorTestTool{name: "write_file", readOnly: false, output: "wrote file"})
|
||||
executor := New(exec, execReg, NewSession("exec-sys"), Options{}, event.Discard)
|
||||
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
|
||||
|
||||
if err := coord.Run(context.Background(), "install the skill"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if got := len(exec.requests); got != 3 {
|
||||
t.Fatalf("executor requests = %d, want answer-without-acting, nudge tool call, final answer", got)
|
||||
}
|
||||
if got := lastUser(exec.requests[1]); !strings.Contains(got, "Use your available tools now to carry out the task") {
|
||||
t.Fatalf("second executor request missing handoff nudge message: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoordinatorDoesNotNudgeExecutorThatActs(t *testing.T) {
|
||||
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
|
||||
{Type: provider.ChunkText, Text: "Write the requested skill file."},
|
||||
{Type: provider.ChunkDone},
|
||||
}}
|
||||
// Executor calls a tool on its first turn, then answers — no nudge expected.
|
||||
exec := &mockProvider{name: "executor", streams: [][]provider.Chunk{
|
||||
{
|
||||
{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-1", Name: "write_file", Arguments: `{"path":"kan-tu.md"}`}},
|
||||
{Type: provider.ChunkDone},
|
||||
},
|
||||
{
|
||||
{Type: provider.ChunkText, Text: "Done."},
|
||||
{Type: provider.ChunkDone},
|
||||
},
|
||||
}}
|
||||
|
||||
execReg := tool.NewRegistry()
|
||||
execReg.Add(coordinatorTestTool{name: "write_file", readOnly: false, output: "wrote file"})
|
||||
executor := New(exec, execReg, NewSession("exec-sys"), Options{}, event.Discard)
|
||||
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
|
||||
|
||||
if err := coord.Run(context.Background(), "install the skill"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if got := len(exec.requests); got != 2 {
|
||||
t.Fatalf("executor requests = %d, want tool call + final answer with no nudge", got)
|
||||
}
|
||||
for i, req := range exec.requests {
|
||||
if strings.Contains(lastUser(req), "Use your available tools now to carry out the task") {
|
||||
t.Fatalf("request %d unexpectedly received a handoff nudge", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toolSchemaNames(schemas []provider.ToolSchema) []string {
|
||||
out := make([]string, 0, len(schemas))
|
||||
for _, s := range schemas {
|
||||
|
||||
@@ -441,8 +441,8 @@ func TestFinalReadinessAuditRecordsTerminalError(t *testing.T) {
|
||||
t.Fatalf("readiness audit events = %d, want 3: %+v", len(sink.events), sink.events)
|
||||
}
|
||||
last := sink.events[len(sink.events)-1]
|
||||
if last.Result != evidence.ReadinessErrored || !last.MissingCompleteStep {
|
||||
t.Fatalf("terminal audit = %+v, want errored missing complete_step", last)
|
||||
if last.Result != evidence.ReadinessErrored || last.IncompleteTodos == 0 {
|
||||
t.Fatalf("terminal audit = %+v, want errored with incomplete todos", last)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ func TestFinalReadinessFailureBranches(t *testing.T) {
|
||||
{"writer without checks or todo never gates", nil, readinessLedger(writer), true, ""},
|
||||
{"missing project check after writer is reported", []instruction.VerifyCheck{check}, readinessLedger(checkAfter, writer), false, "go test ./..."},
|
||||
{"project check run after writer satisfies", []instruction.VerifyCheck{check}, readinessLedger(writer, checkAfter), true, ""},
|
||||
{"todo writer without complete_step is reported", nil, readinessLedger(writer, todo), false, "complete_step"},
|
||||
{"todo writer without complete_step is reported", nil, readinessLedger(writer, todo), false, "incomplete items"},
|
||||
{"complete_step without final todo update is reported", nil, readinessLedger(writer, todo, completeAfter), false, "latest successful todo_write"},
|
||||
{"todo writer with complete_step and completed todo satisfies", nil, readinessLedger(writer, todo, completeAfter, doneTodo), true, ""},
|
||||
}
|
||||
|
||||
+83
-39
@@ -87,12 +87,7 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
|
||||
if stderr == nil {
|
||||
stderr = os.Stderr
|
||||
}
|
||||
root := opts.WorkspaceRoot
|
||||
if root == "" {
|
||||
if wd, err := os.Getwd(); err == nil {
|
||||
root = wd
|
||||
}
|
||||
}
|
||||
root := resolveWorkspaceRoot(opts.WorkspaceRoot)
|
||||
// One-time import of v1/v0.5 legacy config — runs before Load so the freshly
|
||||
// written config + ~/.env are picked up this same boot. CLI Run also calls this
|
||||
// before config-only commands; this call stays as the shared frontend fallback.
|
||||
@@ -204,7 +199,7 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
|
||||
}
|
||||
searchSpec := builtin.ResolveSearch(cfg.Tools.Search.Engine, cfg.Tools.Search.RgPath, stderr)
|
||||
bashTimeout := time.Duration(cfg.BashTimeoutSeconds()) * time.Second
|
||||
addBuiltins(reg, cfg.Tools.Enabled, cfg.WriteRootsForRoot(root), bashSpec, bashTimeout, searchSpec, stderr, root)
|
||||
addBuiltins(reg, cfg.Tools.Enabled, cfg.WriteRootsForRoot(root), bashSpec, bashTimeout, searchSpec, stderr, root, proxySpec)
|
||||
// Always construct a host, even with no plugins configured, so the controller's
|
||||
// host pointer is stable for the session and `/mcp add` can hot-add into it.
|
||||
pluginHost := plugin.NewHost()
|
||||
@@ -245,11 +240,8 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
|
||||
// tools come online next session — otherwise point the user at the explicit
|
||||
// install command. A failed init or fetch is a notice, not fatal.
|
||||
//
|
||||
// CodeGraph follows the same user-selectable tier model as ordinary MCP
|
||||
// servers when a tier is set. EnsureInit only creates .codegraph/ (fast,
|
||||
// size-independent). With no explicit tier — an upgraded config that predates
|
||||
// the setting — it keeps the historical startup: warm projects eager so
|
||||
// symbol tools are ready on the first turn, cold projects in the background.
|
||||
// CodeGraph is fixed to background startup. Legacy tier values are ignored so
|
||||
// enabling it never blocks chat startup.
|
||||
if cfg.Codegraph.Enabled {
|
||||
bin, ok := codegraph.Resolve(cfg.Codegraph.Path)
|
||||
switch {
|
||||
@@ -274,24 +266,8 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
|
||||
Text: "codegraph: preparing code-intelligence tools in the background — tools will appear when ready"})
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(cfg.Codegraph.Tier) == "" {
|
||||
if warm {
|
||||
eagerSpecs = append(eagerSpecs, spec)
|
||||
} else {
|
||||
bgSpecs = append(bgSpecs, spec)
|
||||
bgNotice()
|
||||
}
|
||||
break
|
||||
}
|
||||
switch cfg.Codegraph.ResolvedTier() {
|
||||
case "eager":
|
||||
eagerSpecs = append(eagerSpecs, spec)
|
||||
case "background":
|
||||
bgSpecs = append(bgSpecs, spec)
|
||||
bgNotice()
|
||||
default:
|
||||
lazySpecs = append(lazySpecs, spec)
|
||||
}
|
||||
bgSpecs = append(bgSpecs, spec)
|
||||
bgNotice()
|
||||
case cfg.Codegraph.AutoInstall:
|
||||
notify := func(msg string) { sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: msg}) }
|
||||
notify("codegraph: fetching code-intelligence runtime in the background (one-time) — symbol-graph tools available next session")
|
||||
@@ -630,7 +606,8 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
|
||||
plannerSess := agent.NewSession(agent.PlannerPromptWithContext(mem.Block()))
|
||||
plannerTools := agent.PlannerToolRegistry(reg)
|
||||
runner = agent.NewCoordinator(plannerProv, plannerSess, pe.Price, plannerTools, agent.Options{
|
||||
MaxSteps: agent.PlannerMaxSteps(maxSteps),
|
||||
MaxSteps: cfg.Agent.PlannerMaxSteps,
|
||||
MaxStepsKey: "agent.planner_max_steps",
|
||||
Gate: headlessGate,
|
||||
ContextWindow: pe.ContextWindow,
|
||||
SoftCompactRatio: cfg.Agent.SoftCompactRatio,
|
||||
@@ -679,8 +656,8 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
|
||||
PluginCtx: ctx,
|
||||
WorkspaceRoot: root,
|
||||
AutoPlan: cfg.Agent.AutoPlan,
|
||||
OnRemember: func(rule string) {
|
||||
rememberPermissionRule(opts.WorkspaceRoot, rule)
|
||||
OnRemember: func(rule string) control.RememberResult {
|
||||
return rememberPermissionRule(root, rule)
|
||||
},
|
||||
}
|
||||
if classifier != nil {
|
||||
@@ -732,16 +709,27 @@ func migrateLegacySessionSources(sink event.Sink) {
|
||||
}
|
||||
}
|
||||
|
||||
func rememberPermissionRule(workspaceRoot, rule string) {
|
||||
func rememberPermissionRule(workspaceRoot, rule string) control.RememberResult {
|
||||
path := rememberPermissionConfigPath(workspaceRoot)
|
||||
edit := config.LoadForEdit(path)
|
||||
result := control.RememberResult{Rule: strings.TrimSpace(rule), Path: path}
|
||||
if coveredBy := coveredPermissionRule(edit.Permissions.Allow, result.Rule); coveredBy != "" {
|
||||
result.CoveredBy = coveredBy
|
||||
return result
|
||||
}
|
||||
edit.Permissions.Allow = pruneCoveredPermissionRules(edit.Permissions.Allow, result.Rule)
|
||||
if err := edit.AddPermissionRule("allow", rule); err != nil {
|
||||
slog.Warn("persist permission rule", "rule", rule, "err", err)
|
||||
return
|
||||
result.Err = err
|
||||
return result
|
||||
}
|
||||
if err := edit.SaveTo(path); err != nil {
|
||||
slog.Warn("save config after permission rule", "err", err)
|
||||
result.Err = err
|
||||
return result
|
||||
}
|
||||
result.Saved = true
|
||||
return result
|
||||
}
|
||||
|
||||
func rememberPermissionConfigPath(workspaceRoot string) string {
|
||||
@@ -756,6 +744,26 @@ func rememberPermissionConfigPath(workspaceRoot string) string {
|
||||
return path
|
||||
}
|
||||
|
||||
func coveredPermissionRule(rules []string, rule string) string {
|
||||
for _, existing := range rules {
|
||||
if permission.RuleCoversString(existing, rule) {
|
||||
return strings.TrimSpace(existing)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func pruneCoveredPermissionRules(rules []string, rule string) []string {
|
||||
out := rules[:0]
|
||||
for _, existing := range rules {
|
||||
if strings.TrimSpace(existing) == "" || permission.RuleCoversString(rule, existing) {
|
||||
continue
|
||||
}
|
||||
out = append(out, existing)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
@@ -826,6 +834,42 @@ func subagentModelKeys(name string) []string {
|
||||
return keys
|
||||
}
|
||||
|
||||
func resolveWorkspaceRoot(explicit string) string {
|
||||
if explicit != "" {
|
||||
return explicit
|
||||
}
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if root, ok := nearestGitRoot(wd); ok {
|
||||
return root
|
||||
}
|
||||
return wd
|
||||
}
|
||||
|
||||
func nearestGitRoot(start string) (string, bool) {
|
||||
dir, err := filepath.Abs(start)
|
||||
if err != nil {
|
||||
dir = filepath.Clean(start)
|
||||
}
|
||||
for {
|
||||
if isGitMarker(filepath.Join(dir, ".git")) {
|
||||
return dir, true
|
||||
}
|
||||
next := filepath.Dir(dir)
|
||||
if next == dir {
|
||||
return "", false
|
||||
}
|
||||
dir = next
|
||||
}
|
||||
}
|
||||
|
||||
func isGitMarker(path string) bool {
|
||||
fi, err := os.Stat(path)
|
||||
return err == nil && (fi.IsDir() || fi.Mode().IsRegular())
|
||||
}
|
||||
|
||||
// NewProvider builds a provider.Provider from a configured entry. Exported so
|
||||
// custom assemblers (e.g. the ACP per-session factory) can reuse it without
|
||||
// going through the full Build.
|
||||
@@ -860,12 +904,12 @@ func NewProviderWithProxy(e *config.ProviderEntry, proxy netclient.ProxySpec) (p
|
||||
// instance bound to writeRoots (preserving registry order).
|
||||
// When workDir is non-empty, tools resolve relative paths against it instead of
|
||||
// the process cwd, enabling concurrent multi-project sessions.
|
||||
func addBuiltins(reg *tool.Registry, enabled, writeRoots []string, bashSpec sandbox.Spec, bashTimeout time.Duration, searchSpec builtin.SearchSpec, stderr io.Writer, workDir string) {
|
||||
func addBuiltins(reg *tool.Registry, enabled, writeRoots []string, bashSpec sandbox.Spec, bashTimeout time.Duration, searchSpec builtin.SearchSpec, stderr io.Writer, workDir string, proxySpec netclient.ProxySpec) {
|
||||
// If a workspace directory is set, use workspace-bound tools that resolve
|
||||
// paths relative to that directory. Otherwise fall back to the process-cwd
|
||||
// compile-time builtins.
|
||||
if workDir != "" {
|
||||
ws := builtin.Workspace{Dir: workDir, WriteRoots: writeRoots, Bash: bashSpec, BashTimeout: bashTimeout, Search: searchSpec}
|
||||
ws := builtin.Workspace{Dir: workDir, WriteRoots: writeRoots, Bash: bashSpec, BashTimeout: bashTimeout, Search: searchSpec, ProxySpec: proxySpec}
|
||||
for _, t := range ws.Tools(enabled...) {
|
||||
reg.Add(t)
|
||||
}
|
||||
@@ -887,8 +931,8 @@ func addBuiltins(reg *tool.Registry, enabled, writeRoots []string, bashSpec sand
|
||||
}
|
||||
// Replace the unconfined defaults with confined instances (registry order is
|
||||
// preserved on replace): file-writers bound to the workspace, bash to the OS
|
||||
// sandbox. Only replace tools actually enabled/present.
|
||||
confined := append(builtin.ConfineWriters(writeRoots), builtin.ConfineBash(bashSpec, bashTimeout), builtin.ConfineSearch(searchSpec))
|
||||
// sandbox, web_fetch to the proxy. Only replace tools actually enabled/present.
|
||||
confined := append(builtin.ConfineWriters(writeRoots), builtin.ConfineBash(bashSpec, bashTimeout), builtin.ConfineSearch(searchSpec), builtin.ConfineWebFetch(proxySpec))
|
||||
for _, t := range confined {
|
||||
if _, ok := reg.Get(t.Name()); ok {
|
||||
reg.Add(t)
|
||||
|
||||
+157
-7
@@ -18,6 +18,7 @@ import (
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/netclient"
|
||||
"reasonix/internal/plugin"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/sandbox"
|
||||
@@ -216,7 +217,7 @@ api_key_env = "REASONIX_TEST_KEY_UNSET"
|
||||
func TestAddBuiltinsWithWorkspaceRootKeepsSessionTools(t *testing.T) {
|
||||
reg := tool.NewRegistry()
|
||||
var stderr bytes.Buffer
|
||||
addBuiltins(reg, nil, []string{robustTempDir(t)}, sandbox.Spec{}, 120*time.Second, builtin.SearchSpec{}, &stderr, robustTempDir(t))
|
||||
addBuiltins(reg, nil, []string{robustTempDir(t)}, sandbox.Spec{}, 120*time.Second, builtin.SearchSpec{}, &stderr, robustTempDir(t), netclient.ProxySpec{})
|
||||
for _, name := range []string{
|
||||
"todo_write",
|
||||
"complete_step",
|
||||
@@ -450,14 +451,14 @@ func TestRememberPermissionRuleUsesWorkspaceRoot(t *testing.T) {
|
||||
t.Chdir(cwd)
|
||||
writeFile(t, cwd, "reasonix.toml", `
|
||||
[permissions]
|
||||
allow = ["bash(cwd*)"]
|
||||
allow = ["Bash(cwd*)"]
|
||||
`)
|
||||
writeFile(t, workspace, "reasonix.toml", `
|
||||
[permissions]
|
||||
allow = ["bash(workspace*)"]
|
||||
allow = ["Bash(workspace*)"]
|
||||
`)
|
||||
|
||||
const rule = "bash=go test ./..."
|
||||
const rule = "Bash(go test ./...)"
|
||||
rememberPermissionRule(workspace, rule)
|
||||
|
||||
cwdCfg := config.LoadForEdit(filepath.Join(cwd, "reasonix.toml"))
|
||||
@@ -470,6 +471,36 @@ allow = ["bash(workspace*)"]
|
||||
}
|
||||
}
|
||||
|
||||
func TestRememberPermissionRuleCreatesWorkspaceConfigOverUserConfig(t *testing.T) {
|
||||
home := robustTempDir(t)
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
||||
t.Setenv("AppData", filepath.Join(home, "AppData"))
|
||||
|
||||
workspace := robustTempDir(t)
|
||||
userConfig := config.UserConfigPath()
|
||||
writeFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `
|
||||
[permissions]
|
||||
allow = ["Bash(user)"]
|
||||
`)
|
||||
|
||||
const rule = "Edit(src/app.go)"
|
||||
res := rememberPermissionRule(workspace, rule)
|
||||
if !res.Saved || res.Path != filepath.Join(workspace, "reasonix.toml") {
|
||||
t.Fatalf("remember result = %+v, want saved to workspace config", res)
|
||||
}
|
||||
|
||||
userCfg := config.LoadForEdit(userConfig)
|
||||
if hasPermissionRule(userCfg.Permissions.Allow, rule) {
|
||||
t.Fatalf("workspace rule was written to user config: %v", userCfg.Permissions.Allow)
|
||||
}
|
||||
workspaceCfg := config.LoadForEdit(filepath.Join(workspace, "reasonix.toml"))
|
||||
if !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {
|
||||
t.Fatalf("workspace rule missing from project config: %v", workspaceCfg.Permissions.Allow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRememberPermissionRuleEmptyRootUsesSourcePath(t *testing.T) {
|
||||
home := robustTempDir(t)
|
||||
t.Setenv("HOME", home)
|
||||
@@ -482,11 +513,14 @@ func TestRememberPermissionRuleEmptyRootUsesSourcePath(t *testing.T) {
|
||||
userConfig := config.UserConfigPath()
|
||||
writeFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `
|
||||
[permissions]
|
||||
allow = ["bash(user*)"]
|
||||
allow = ["Bash(user*)"]
|
||||
`)
|
||||
|
||||
const rule = "bash=go env"
|
||||
rememberPermissionRule("", rule)
|
||||
const rule = "Bash(go env)"
|
||||
res := rememberPermissionRule("", rule)
|
||||
if !res.Saved || res.Path != userConfig {
|
||||
t.Fatalf("remember result = %+v, want saved to user source config", res)
|
||||
}
|
||||
|
||||
userCfg := config.LoadForEdit(userConfig)
|
||||
if !hasPermissionRule(userCfg.Permissions.Allow, rule) {
|
||||
@@ -497,6 +531,43 @@ allow = ["bash(user*)"]
|
||||
}
|
||||
}
|
||||
|
||||
func TestRememberPermissionRuleSkipsRuleCoveredByExistingAllow(t *testing.T) {
|
||||
workspace := robustTempDir(t)
|
||||
writeFile(t, workspace, "reasonix.toml", `
|
||||
[permissions]
|
||||
allow = ["Bash(go test:*)"]
|
||||
`)
|
||||
|
||||
res := rememberPermissionRule(workspace, "Bash(go test ./...)")
|
||||
if res.Saved || res.CoveredBy != "Bash(go test:*)" {
|
||||
t.Fatalf("remember result = %+v, want already covered", res)
|
||||
}
|
||||
cfg := config.LoadForEdit(filepath.Join(workspace, "reasonix.toml"))
|
||||
if len(cfg.Permissions.Allow) != 1 || cfg.Permissions.Allow[0] != "Bash(go test:*)" {
|
||||
t.Fatalf("allow rules = %v, want only existing prefix", cfg.Permissions.Allow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRememberPermissionRulePrunesNarrowRulesWhenSavingBroaderRule(t *testing.T) {
|
||||
workspace := robustTempDir(t)
|
||||
writeFile(t, workspace, "reasonix.toml", `
|
||||
[permissions]
|
||||
allow = ["Bash(go test ./...)", "Bash(go build ./...)"]
|
||||
`)
|
||||
|
||||
res := rememberPermissionRule(workspace, "Bash(go test:*)")
|
||||
if !res.Saved || res.CoveredBy != "" {
|
||||
t.Fatalf("remember result = %+v, want saved broader rule", res)
|
||||
}
|
||||
cfg := config.LoadForEdit(filepath.Join(workspace, "reasonix.toml"))
|
||||
if hasPermissionRule(cfg.Permissions.Allow, "Bash(go test ./...)") {
|
||||
t.Fatalf("narrow go test rule should be pruned: %v", cfg.Permissions.Allow)
|
||||
}
|
||||
if !hasPermissionRule(cfg.Permissions.Allow, "Bash(go build ./...)") || !hasPermissionRule(cfg.Permissions.Allow, "Bash(go test:*)") {
|
||||
t.Fatalf("allow rules = %v, want unrelated exact plus prefix", cfg.Permissions.Allow)
|
||||
}
|
||||
}
|
||||
|
||||
func hasPermissionRule(rules []string, want string) bool {
|
||||
for _, rule := range rules {
|
||||
if rule == want {
|
||||
@@ -843,6 +914,85 @@ api_key_env = "REASONIX_TEST_KEY_UNSET"
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWarmCodegraphIgnoresLegacyEagerTier(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("fake launcher is a POSIX-sh script")
|
||||
}
|
||||
isolateConfigHome(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
if err := os.Mkdir(filepath.Join(dir, ".codegraph"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
launcher := filepath.Join(dir, "slow-codegraph")
|
||||
writeFile(t, dir, "slow-codegraph", "#!/bin/sh\nif [ \"$1\" = serve ]; then sleep 5; fi\n")
|
||||
if err := os.Chmod(launcher, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
writeFile(t, dir, "reasonix.toml", fmt.Sprintf(`
|
||||
default_model = "test-model"
|
||||
|
||||
[codegraph]
|
||||
enabled = true
|
||||
path = %q
|
||||
tier = "eager"
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE"
|
||||
|
||||
[[providers]]
|
||||
name = "test-model"
|
||||
kind = "openai"
|
||||
base_url = "https://example.invalid"
|
||||
model = "x"
|
||||
api_key_env = "REASONIX_TEST_KEY_UNSET"
|
||||
`, launcher))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancel()
|
||||
ctrl, err := Build(ctx, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("Build should not block on warm codegraph with legacy eager tier: %v", err)
|
||||
}
|
||||
defer ctrl.Close()
|
||||
}
|
||||
|
||||
func TestBuildDefaultsToNearestGitRoot(t *testing.T) {
|
||||
isolateConfigHome(t)
|
||||
root := robustTempDir(t)
|
||||
if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
subdir := filepath.Join(root, "cmd", "tool")
|
||||
if err := os.MkdirAll(subdir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFile(t, root, "reasonix.toml", `
|
||||
default_model = "root-model"
|
||||
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE"
|
||||
|
||||
[[providers]]
|
||||
name = "root-model"
|
||||
kind = "openai"
|
||||
base_url = "https://example.invalid"
|
||||
model = "x"
|
||||
api_key_env = "REASONIX_TEST_KEY_UNSET"
|
||||
`)
|
||||
t.Chdir(subdir)
|
||||
|
||||
ctrl, err := Build(context.Background(), Options{Model: "root-model"})
|
||||
if err != nil {
|
||||
t.Fatalf("Build should load config from nearest git root: %v", err)
|
||||
}
|
||||
defer ctrl.Close()
|
||||
}
|
||||
|
||||
func TestBuildMigratesLegacyEagerBeforeStatsDemotion(t *testing.T) {
|
||||
isolateConfigHome(t)
|
||||
dir := robustTempDir(t)
|
||||
|
||||
@@ -91,6 +91,7 @@ func acpBuiltinTools(cfg *config.Config, cwd string, writeRoots []string) []tool
|
||||
Bash: bashSpec,
|
||||
BashTimeout: time.Duration(cfg.BashTimeoutSeconds()) * time.Second,
|
||||
Search: builtin.ResolveSearch(cfg.Tools.Search.Engine, cfg.Tools.Search.RgPath, nil),
|
||||
ProxySpec: cfg.NetworkProxySpec(),
|
||||
}
|
||||
return ws.Tools(cfg.Tools.Enabled...)
|
||||
}
|
||||
|
||||
+271
-43
@@ -24,29 +24,25 @@ import (
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/command"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/hook"
|
||||
"reasonix/internal/i18n"
|
||||
"reasonix/internal/memory"
|
||||
"reasonix/internal/outputstyle"
|
||||
"reasonix/internal/permission"
|
||||
"reasonix/internal/plugin"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/sandbox"
|
||||
"reasonix/internal/skill"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
// chatTUI is a bubbletea Model that runs a chat session in the terminal's
|
||||
// normal buffer (no alt-screen). Finalized output — user bubbles, tool dispatch
|
||||
// lines, usage lines, reasoning, and the rendered assistant answer — is
|
||||
// committed to the native scrollback via tea.Println, so the wheel, scrollbar,
|
||||
// and copy all work like any CLI. The bubbletea-managed region is only the
|
||||
// bottom — input box, status line, an optional approval/plan banner, and the
|
||||
// autocomplete menu — and it is kept a stable height (it changes only on
|
||||
// discrete user actions, never per streamed token) so the renderer commits
|
||||
// scrollback cleanly without stranding the input box's border lines. This
|
||||
// mirrors how Claude Code uses Ink's <Static> to freeze finished output into
|
||||
// scrollback while re-rendering just the active prompt.
|
||||
// chatTUI is a bubbletea Model that normally owns the terminal with an
|
||||
// alt-screen transcript viewport. Termux is the exception: enabling mouse mode
|
||||
// prevents taps from raising the soft keyboard, so it stays in the normal buffer
|
||||
// and commits finalized output to native scrollback via tea.Println.
|
||||
type chatTUI struct {
|
||||
ctrl *control.Controller
|
||||
label string
|
||||
@@ -54,6 +50,9 @@ type chatTUI struct {
|
||||
|
||||
width int
|
||||
height int
|
||||
// nativeScrollback keeps Termux out of alt-screen/mouse mode so taps still
|
||||
// focus the textarea and raise the soft keyboard.
|
||||
nativeScrollback bool
|
||||
|
||||
input textarea.Model
|
||||
spinner spinner.Model
|
||||
@@ -117,6 +116,7 @@ type chatTUI struct {
|
||||
pendingCommit *[]string
|
||||
renderer *mdRenderer
|
||||
showReasoning bool // Ctrl+O / /verbose: show raw thinking text in the CLI
|
||||
cfg *config.Config
|
||||
// reasoningLineIdx is the transcript index of the live "▎ thinking…" marker
|
||||
// while a reasoning block streams; it's rewritten to "▎ thought for Ns" when
|
||||
// the block closes. -1 when no block is open. transcriptDirty forces a
|
||||
@@ -129,7 +129,10 @@ type chatTUI struct {
|
||||
// reasoningView is a bounded trailing window (≤ reasoningViewMax bytes) of the
|
||||
// streaming thought, rendered live; the full text stays in reasoning for verbose.
|
||||
reasoningView []byte
|
||||
thinkStart time.Time
|
||||
// reasoningNative is the Termux/native-scrollback path: reasoning is buffered
|
||||
// without a live transcript block, then appended once as a final summary.
|
||||
reasoningNative bool
|
||||
thinkStart time.Time
|
||||
// answerIdx is the transcript index of the streaming answer block (rewritten in
|
||||
// place as completed paragraphs arrive); -1 when none is open. answerFlushed is
|
||||
// how many bytes of pending have already been rendered into it, so a Text packet
|
||||
@@ -431,10 +434,12 @@ func newChatTUI(ctrl *control.Controller, missing string, eventCh chan event.Eve
|
||||
sp.Style = themeStyle(activeCLITheme.accent)
|
||||
|
||||
commitBuf := []string{}
|
||||
nativeScrollback := detectTermuxTerminal()
|
||||
return chatTUI{
|
||||
ctrl: ctrl,
|
||||
label: ctrl.Label(),
|
||||
missing: missing,
|
||||
nativeScrollback: nativeScrollback,
|
||||
input: ti,
|
||||
spinner: sp,
|
||||
submittedInputCursor: -1,
|
||||
@@ -448,6 +453,7 @@ func newChatTUI(ctrl *control.Controller, missing string, eventCh chan event.Eve
|
||||
pending: &strings.Builder{},
|
||||
pendingCommit: &commitBuf,
|
||||
renderer: newMarkdownRenderer(termW),
|
||||
showReasoning: nativeScrollback,
|
||||
shellOutputs: make(map[string]string),
|
||||
shellExpanded: make(map[string]bool),
|
||||
shellTranscriptIdx: make(map[string]int),
|
||||
@@ -479,6 +485,15 @@ func configureChatTextarea(ti *textarea.Model) {
|
||||
ti.Focus()
|
||||
}
|
||||
|
||||
func isTermuxTerminal() bool {
|
||||
if os.Getenv("TERMUX_VERSION") != "" || os.Getenv("TERMUX_APP_PID") != "" || os.Getenv("TERMUX__PREFIX") != "" {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(os.Getenv("PREFIX"), "/com.termux/")
|
||||
}
|
||||
|
||||
var detectTermuxTerminal = isTermuxTerminal
|
||||
|
||||
func (m *chatTUI) rememberSubmittedInput(input string) {
|
||||
if strings.TrimSpace(input) == "" {
|
||||
return
|
||||
@@ -646,7 +661,7 @@ func (m chatTUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// newest output) shifts the whole window. Some terminals (Warp) mishandle
|
||||
// the renderer's scroll/insert-line optimization and strand stale rows, so
|
||||
// force a full clear+redraw whenever the offset actually moved.
|
||||
if cm.viewport.YOffset() != prevYOff {
|
||||
if cm.viewport.YOffset() != prevYOff && !cm.nativeScrollback {
|
||||
return cm, tea.Batch(tea.ClearScreen, cmd)
|
||||
}
|
||||
return cm, cmd
|
||||
@@ -862,15 +877,12 @@ func (m chatTUI) update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.completion = completion{}
|
||||
break // fall through to regular Enter and submit the command
|
||||
}
|
||||
// When Enter is pressed and the completion has exactly one item
|
||||
// already fully present in the input, close the menu and let Enter
|
||||
// fall through to submit the command (/resume 3 → resume session 3).
|
||||
if msg.String() == "enter" && len(m.completion.items) == 1 {
|
||||
tok := m.input.Value()[m.completion.replaceFrom:]
|
||||
if tok == m.completion.items[0].insert {
|
||||
m.completion = completion{}
|
||||
break // fall through to regular Enter
|
||||
}
|
||||
// When Enter is pressed and the selected completion is already fully
|
||||
// present in the input, close the menu and submit instead of accepting
|
||||
// the same item again (/resume 1 still has /resume 10 as a prefix match).
|
||||
if msg.String() == "enter" && m.completionSelectedInsertPresent() {
|
||||
m.completion = completion{}
|
||||
break // fall through to regular Enter
|
||||
}
|
||||
m.acceptCompletion()
|
||||
return m, nil
|
||||
@@ -1005,6 +1017,7 @@ func (m chatTUI) update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.state == tuiRunning {
|
||||
line := strings.TrimSpace(m.input.Value())
|
||||
if line == "" {
|
||||
m.viewport.GotoBottom()
|
||||
return m, nil
|
||||
}
|
||||
if m.queueEditCursor >= 0 && m.queueEditCursor < len(m.pendingInterject) {
|
||||
@@ -1029,6 +1042,7 @@ func (m chatTUI) update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
line := strings.TrimSpace(m.input.Value())
|
||||
|
||||
if line == "" {
|
||||
m.viewport.GotoBottom()
|
||||
return m, nil
|
||||
}
|
||||
if line == "exit" || line == "quit" || line == ":q" {
|
||||
@@ -1290,13 +1304,58 @@ func (m chatTUI) update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, finalize(m, cmds)
|
||||
}
|
||||
|
||||
// finalize drains the committed-line queue (its content is already mirrored in
|
||||
// the transcript, which the viewport renders) and batches the turn's commands.
|
||||
// finalize drains the committed-line queue and batches the turn's commands. In
|
||||
// the default alt-screen path the queue is already mirrored in m.transcript. In
|
||||
// Termux we cannot enable mouse mode without breaking soft-keyboard focus, so
|
||||
// finalized lines are also emitted into the terminal's native scrollback.
|
||||
func finalize(m chatTUI, cmds []tea.Cmd) tea.Cmd {
|
||||
if m.nativeScrollback && len(*m.pendingCommit) > 0 {
|
||||
out := strings.TrimRight(clampWidth(strings.Join(*m.pendingCommit, "\n"), m.width), "\n")
|
||||
*m.pendingCommit = (*m.pendingCommit)[:0]
|
||||
var prints []tea.Cmd
|
||||
for _, chunk := range chunkLines(out, m.scrollChunkHeight()) {
|
||||
prints = append(prints, tea.Println(chunk))
|
||||
}
|
||||
cmds = append(cmds, tea.Sequence(prints...))
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
*m.pendingCommit = (*m.pendingCommit)[:0]
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// scrollChunkHeight is the largest block (in lines) finalize prints at once in
|
||||
// native-scrollback mode, leaving room for the pinned bottom frame.
|
||||
func (m chatTUI) scrollChunkHeight() int {
|
||||
if m.height <= 0 {
|
||||
return 100
|
||||
}
|
||||
if n := m.height - m.bottomRows(); n > 1 {
|
||||
return n
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// chunkLines splits s into blocks of at most n lines each, preserving order and
|
||||
// line content. A single block is returned when it already fits.
|
||||
func chunkLines(s string, n int) []string {
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
lines := strings.Split(s, "\n")
|
||||
if len(lines) <= n {
|
||||
return []string{s}
|
||||
}
|
||||
var out []string
|
||||
for i := 0; i < len(lines); i += n {
|
||||
end := i + n
|
||||
if end > len(lines) {
|
||||
end = len(lines)
|
||||
}
|
||||
out = append(out, strings.Join(lines[i:end], "\n"))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// clampWidth hard-breaks any line wider than width so no scrollback line wraps
|
||||
// in the terminal. bubbletea's inline renderer estimates how far to scroll for
|
||||
// each printed block from each line's width (insertAbove: offset += width/w); an
|
||||
@@ -1333,7 +1392,8 @@ func (m *chatTUI) commitSpacer() {
|
||||
// bottomRows is the terminal-row height of the pinned bottom region: any open
|
||||
// bottom panels (todo / approval / chooser / rewind / completion), the composer
|
||||
// when visible, and the two fixed status rows. Full-screen managers such as MCP
|
||||
// and skills render inside the main transcript area instead of the bottom rail.
|
||||
// and skills normally render inside the main transcript area; in native
|
||||
// scrollback mode they join the bottom rail because there is no main viewport.
|
||||
func (m chatTUI) bottomRows() int {
|
||||
rows := 0
|
||||
for _, s := range []string{
|
||||
@@ -1352,6 +1412,11 @@ func (m chatTUI) bottomRows() int {
|
||||
if m.state == tuiRunning {
|
||||
rows++ // the working spinner line above the box
|
||||
}
|
||||
if m.nativeScrollback {
|
||||
if main := m.renderMainManager(); main != "" {
|
||||
rows += strings.Count(main, "\n") + 1
|
||||
}
|
||||
}
|
||||
if footer := m.renderMainManagerFooter(); footer != "" {
|
||||
rows += strings.Count(footer, "\n") + 1
|
||||
}
|
||||
@@ -1474,10 +1539,10 @@ const reasoningTailLines = 12
|
||||
// bounded trailing view (mirrors streamToolOutput), so the chain of thought is
|
||||
// visible while the model works without re-rendering the whole thing per token.
|
||||
func (m *chatTUI) streamReasoning(chunk string) {
|
||||
m.reasoning.WriteString(chunk) // full text retained for verbose mode
|
||||
if m.reasoningTextIdx < 0 {
|
||||
return
|
||||
}
|
||||
m.reasoning.WriteString(chunk) // full text retained for verbose mode
|
||||
m.reasoningView = append(m.reasoningView, chunk...)
|
||||
if len(m.reasoningView) > reasoningViewMax {
|
||||
drop := len(m.reasoningView) - reasoningViewMax
|
||||
@@ -1537,8 +1602,12 @@ func (m *chatTUI) streamToolOutput(id, chunk string) {
|
||||
m.toolTail = m.toolTail[:0]
|
||||
m.toolPartial = ""
|
||||
m.toolLineCount = 0
|
||||
m.toolStreamIdx = len(m.transcript)
|
||||
m.commitLine("")
|
||||
if m.nativeScrollback {
|
||||
m.toolStreamIdx = -1
|
||||
} else {
|
||||
m.toolStreamIdx = len(m.transcript)
|
||||
m.commitLine("")
|
||||
}
|
||||
}
|
||||
// Accumulate full output for shell commands so Ctrl+B can expand it.
|
||||
if strings.HasPrefix(id, "shell-") {
|
||||
@@ -1560,6 +1629,9 @@ func (m *chatTUI) streamToolOutput(id, chunk string) {
|
||||
if m.toolPartial != "" {
|
||||
vis = append(append([]string{}, m.toolTail...), m.toolPartial)
|
||||
}
|
||||
if m.nativeScrollback {
|
||||
return
|
||||
}
|
||||
lines := make([]string, len(vis))
|
||||
for i, ln := range vis {
|
||||
lines[i] = dim(clampPlain(ln, m.width-len([]rune(connector))))
|
||||
@@ -1585,6 +1657,44 @@ func (m *chatTUI) pushToolLine(line string) {
|
||||
// prefix), it shows the first shellPreviewLines with a Ctrl+B hint instead.
|
||||
// No-op when id isn't streaming.
|
||||
func (m *chatTUI) collapseToolOutput(id string) {
|
||||
if m.nativeScrollback {
|
||||
if id == "" || m.toolStreamID != id {
|
||||
return
|
||||
}
|
||||
n := m.toolLineCount
|
||||
if m.toolPartial != "" {
|
||||
n++
|
||||
}
|
||||
if n > 0 {
|
||||
if full, ok := m.shellOutputs[id]; ok {
|
||||
lines := strings.Split(strings.TrimRight(full, "\n"), "\n")
|
||||
total := len(lines)
|
||||
if total > shellPreviewLines {
|
||||
preview := make([]string, shellPreviewLines+1)
|
||||
for i := 0; i < shellPreviewLines; i++ {
|
||||
preview[i] = dim(clampPlain(lines[i], m.width-len([]rune(connector))))
|
||||
}
|
||||
preview[shellPreviewLines] = dim(fmt.Sprintf("… %d more lines (Ctrl+B)", total-shellPreviewLines))
|
||||
m.commitLine(connectorBlock(preview))
|
||||
} else {
|
||||
rendered := make([]string, total)
|
||||
for i, ln := range lines {
|
||||
rendered[i] = dim(clampPlain(ln, m.width-len([]rune(connector))))
|
||||
}
|
||||
m.commitLine(connectorBlock(rendered))
|
||||
}
|
||||
m.shellTranscriptIdx[id] = len(m.transcript) - 1
|
||||
} else {
|
||||
m.commitLine(connectorBlock([]string{dim(fmt.Sprintf("%d lines", n))}))
|
||||
}
|
||||
}
|
||||
m.toolStreamIdx = -1
|
||||
m.toolStreamID = ""
|
||||
m.toolTail = m.toolTail[:0]
|
||||
m.toolPartial = ""
|
||||
m.toolLineCount = 0
|
||||
return
|
||||
}
|
||||
if m.toolStreamIdx < 0 || id == "" || m.toolStreamID != id {
|
||||
return
|
||||
}
|
||||
@@ -1683,6 +1793,9 @@ func (m *chatTUI) toggleShellOutput() {
|
||||
m.transcript[lastIdx] = connectorBlock(rendered)
|
||||
}
|
||||
m.transcriptDirty = true
|
||||
if m.nativeScrollback {
|
||||
m.commitLine(m.transcript[lastIdx])
|
||||
}
|
||||
}
|
||||
|
||||
// toolWorkingFrames is the braille spinner cycled once per second on the
|
||||
@@ -1703,6 +1816,10 @@ func (m *chatTUI) beginToolRunning(id string) {
|
||||
m.toolLineCount = 0
|
||||
m.toolStreamStart = time.Now()
|
||||
m.toolStreamFrame = 0
|
||||
if m.nativeScrollback {
|
||||
m.toolStreamIdx = -1
|
||||
return
|
||||
}
|
||||
m.toolStreamIdx = len(m.transcript)
|
||||
m.commitLine(connectorBlock([]string{dim(fmt.Sprintf(i18n.M.ChatToolWorkingFmt, toolWorkingFrames[0], 0))}))
|
||||
}
|
||||
@@ -1710,6 +1827,9 @@ func (m *chatTUI) beginToolRunning(id string) {
|
||||
// tickToolRunning re-renders the working line of a tool that's dispatched but
|
||||
// hasn't produced output yet. A no-op once output streams in or no tool runs.
|
||||
func (m *chatTUI) tickToolRunning() {
|
||||
if m.nativeScrollback {
|
||||
return
|
||||
}
|
||||
if m.toolStreamIdx < 0 || m.toolLineCount != 0 || m.toolPartial != "" {
|
||||
return
|
||||
}
|
||||
@@ -1725,6 +1845,21 @@ func (m *chatTUI) tickToolRunning() {
|
||||
// removed (collapsed) — kept only in verbose mode. The viewport re-wraps from
|
||||
// m.transcript, so the change is flagged via transcriptDirty.
|
||||
func (m *chatTUI) commitReasoning() {
|
||||
if m.reasoningNative {
|
||||
if strings.TrimSpace(m.reasoning.String()) != "" || !m.thinkStart.IsZero() {
|
||||
secs := int(time.Since(m.thinkStart).Seconds())
|
||||
m.commitSpacer()
|
||||
m.commitLine(dim(fmt.Sprintf(" ▎ "+i18n.M.ChatThoughtForFmt, secs)))
|
||||
if m.showReasoning && strings.TrimSpace(m.reasoning.String()) != "" {
|
||||
m.commitLine(reasoningBlock(m.reasoning.String(), m.width, 0))
|
||||
}
|
||||
}
|
||||
m.reasoning.Reset()
|
||||
m.reasoningView = m.reasoningView[:0]
|
||||
m.reasoningNative = false
|
||||
m.thinkStart = time.Time{}
|
||||
return
|
||||
}
|
||||
if m.reasoningLineIdx < 0 {
|
||||
return
|
||||
}
|
||||
@@ -1751,6 +1886,9 @@ func (m *chatTUI) commitReasoning() {
|
||||
// stays buffered (a half-written fence/list never renders early), and it only
|
||||
// re-renders when a new paragraph actually closes.
|
||||
func (m *chatTUI) streamAnswer() {
|
||||
if m.nativeScrollback {
|
||||
return
|
||||
}
|
||||
prefix := flushableMarkdownPrefix(m.pending.String())
|
||||
if len(prefix) <= m.answerFlushed {
|
||||
return
|
||||
@@ -1828,38 +1966,57 @@ func flushableMarkdownPrefix(buf string) string {
|
||||
const planApprovalTool = "exit_plan_mode"
|
||||
|
||||
// handleApprovalKey resolves a pending approval from a keystroke and re-arms the
|
||||
// listener. 1/y/Enter allows once, 2/a allows for the rest of the session,
|
||||
// 3/p writes an "always allow" rule to the config file, 4/n/Esc denies.
|
||||
// listener. 1/y/Enter allows once, 2/a allows for the rest of the session, and
|
||||
// n/Esc denies. Bash approvals with a safe prefix use 2/a for the prefix
|
||||
// session grant and 3/p for the persisted prefix. For standard approvals, 3/p
|
||||
// writes an "always allow" rule to the config file.
|
||||
// Ctrl-C cancels the whole turn via the run context. For a plan approval
|
||||
// (planApprovalTool), allowing also drops the local [plan] tag — the
|
||||
// controller turns plan mode off on its side.
|
||||
func (m chatTUI) handleApprovalKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||||
answer := func(allow, session, persist bool) (tea.Model, tea.Cmd) {
|
||||
answer := func(allow, session, persist bool, scope string) (tea.Model, tea.Cmd) {
|
||||
if allow && m.pendingApproval.Tool == planApprovalTool {
|
||||
m.planMode = false
|
||||
}
|
||||
m.ctrl.Approve(m.pendingApproval.ID, allow, session, persist)
|
||||
m.ctrl.ApproveWithScope(m.pendingApproval.ID, allow, session, persist, scope)
|
||||
m.pendingApproval = nil
|
||||
return m, nil // the next ApprovalRequest / event arrives on eventCh
|
||||
}
|
||||
hasBashPrefix := m.pendingApproval.Tool == "bash" && permission.BashCommandPrefix(m.pendingApproval.Subject) != ""
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
m.ctrl.Cancel() // cancels the run; the approver unblocks via ctx.Done()
|
||||
return answer(false, false, false)
|
||||
return answer(false, false, false, permission.ApprovalScopeExact)
|
||||
case "enter":
|
||||
return answer(true, false, false)
|
||||
return answer(true, false, false, permission.ApprovalScopeExact)
|
||||
case "esc":
|
||||
return answer(false, false, false)
|
||||
return answer(false, false, false, permission.ApprovalScopeExact)
|
||||
}
|
||||
switch strings.ToLower(msg.String()) {
|
||||
case "y", "1":
|
||||
return answer(true, false, false)
|
||||
return answer(true, false, false, permission.ApprovalScopeExact)
|
||||
case "a", "2":
|
||||
return answer(true, true, false) // session grant
|
||||
case "p", "3":
|
||||
return answer(true, true, true) // persist to config
|
||||
case "n", "4":
|
||||
return answer(false, false, false)
|
||||
if hasBashPrefix {
|
||||
return answer(true, true, false, permission.ApprovalScopePrefix) // prefix session grant
|
||||
}
|
||||
return answer(true, true, false, permission.ApprovalScopeExact) // session grant
|
||||
case "3":
|
||||
if hasBashPrefix {
|
||||
return answer(true, true, true, permission.ApprovalScopePrefix) // persist prefix to config
|
||||
}
|
||||
return answer(true, true, true, permission.ApprovalScopeExact) // persist to config
|
||||
case "p":
|
||||
if hasBashPrefix {
|
||||
return answer(true, true, true, permission.ApprovalScopePrefix) // persist prefix to config
|
||||
}
|
||||
return answer(true, true, true, permission.ApprovalScopeExact) // persist to config
|
||||
case "4":
|
||||
if hasBashPrefix {
|
||||
return answer(false, false, false, permission.ApprovalScopeExact)
|
||||
}
|
||||
return answer(false, false, false, permission.ApprovalScopeExact)
|
||||
case "n", "5":
|
||||
return answer(false, false, false, permission.ApprovalScopeExact)
|
||||
}
|
||||
return m, nil // ignore anything else while awaiting a decision
|
||||
}
|
||||
@@ -2032,6 +2189,12 @@ func (m chatTUI) View() tea.View {
|
||||
parts = append(parts, menu)
|
||||
rowsAboveBox += strings.Count(menu, "\n") + 1
|
||||
}
|
||||
if m.nativeScrollback {
|
||||
if card := m.renderMainManager(); card != "" {
|
||||
parts = append(parts, card)
|
||||
rowsAboveBox += strings.Count(card, "\n") + 1
|
||||
}
|
||||
}
|
||||
// Layout: the working spinner (when running), then the composer when visible,
|
||||
// then the two status rows (line 1 = mode + run config + worktree identity, line 2 = live run data).
|
||||
// Each row is clamped to width independently so neither wraps; padding to full
|
||||
@@ -2054,6 +2217,18 @@ func (m chatTUI) View() tea.View {
|
||||
}
|
||||
parts = append(parts, statusBlockStyle.Width(boxW).MaxWidth(boxW).Render(statusBlock))
|
||||
|
||||
if m.nativeScrollback {
|
||||
v := tea.NewView(strings.Join(parts, "\n"))
|
||||
if !hideComposer {
|
||||
if cur := m.input.Cursor(); cur != nil {
|
||||
cur.X += 1
|
||||
cur.Y += rowsAboveBox + 1
|
||||
v.Cursor = cur
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Full-screen frame: the transcript viewport on top (it pads to exactly its
|
||||
// height), the pinned bottom region beneath. Alt-screen owns the grid, so
|
||||
// resize repaints cleanly — no scrollback reflow, no ghost borders.
|
||||
@@ -2238,7 +2413,14 @@ func (m chatTUI) renderApprovalBanner() string {
|
||||
if subj != "" {
|
||||
subj = " " + truncateSubject(subj, w)
|
||||
}
|
||||
text := fmt.Sprintf(i18n.M.ToolApprovalPromptFmt, name, subj, detail)
|
||||
exactSessionRule := permission.SessionGrantRuleForScope(m.pendingApproval.Tool, m.pendingApproval.Subject, permission.ApprovalScopeExact)
|
||||
exactPersistentRule := permission.RememberRuleForScope(m.pendingApproval.Tool, m.pendingApproval.Subject, permission.ApprovalScopeExact)
|
||||
choices := fmt.Sprintf(i18n.M.ToolApprovalChoices, exactSessionRule, exactPersistentRule)
|
||||
if m.pendingApproval.Tool == "bash" && permission.BashCommandPrefix(m.pendingApproval.Subject) != "" {
|
||||
prefixRule := permission.RememberRuleForScope(m.pendingApproval.Tool, m.pendingApproval.Subject, permission.ApprovalScopePrefix)
|
||||
choices = fmt.Sprintf(i18n.M.BashPrefixChoices, prefixRule, prefixRule)
|
||||
}
|
||||
text := fmt.Sprintf(i18n.M.ToolApprovalPromptFmt, name, subj, detail, choices)
|
||||
return approvalBannerStyle.Width(w).Render("⏸ " + text)
|
||||
}
|
||||
|
||||
@@ -2783,6 +2965,14 @@ func (m *chatTUI) ingestEvent(e event.Event) {
|
||||
}
|
||||
switch e.Kind {
|
||||
case event.Reasoning:
|
||||
if m.nativeScrollback {
|
||||
if !m.reasoningNative {
|
||||
m.thinkStart = time.Now()
|
||||
m.reasoningNative = true
|
||||
}
|
||||
m.streamReasoning(e.Text)
|
||||
break
|
||||
}
|
||||
if m.reasoningLineIdx < 0 {
|
||||
// Show the marker plus a live text block the moment thinking starts; the
|
||||
// text streams in below it and the block collapses to "thought for Ns"
|
||||
@@ -2963,7 +3153,7 @@ func (m *chatTUI) runSlashCommand(input string) tea.Cmd {
|
||||
// guidance steering what the summary keeps.
|
||||
focus := strings.TrimSpace(strings.TrimPrefix(input, cmd))
|
||||
return func() tea.Msg { return compactDoneMsg{err: m.ctrl.Compact(context.Background(), focus)} }
|
||||
case "/new":
|
||||
case "/new", "/clear":
|
||||
m.echoLocalCommand(input)
|
||||
if err := m.ctrl.NewSession(); err != nil {
|
||||
m.notice(fmt.Sprintf("%s: %v", i18n.M.SlashNewFailed, err))
|
||||
@@ -2987,6 +3177,9 @@ func (m *chatTUI) runSlashCommand(input string) tea.Cmd {
|
||||
m.notice(i18n.M.SlashTodoCleared)
|
||||
case "/verbose":
|
||||
m.toggleVerboseReasoning(true)
|
||||
case "/sandbox":
|
||||
m.echoLocalCommand(input)
|
||||
m.showSandboxStatus()
|
||||
case "/effort":
|
||||
return m.runEffortCommand(input)
|
||||
case "/auto-plan":
|
||||
@@ -3090,6 +3283,41 @@ func (m *chatTUI) commandNames() string {
|
||||
return strings.Join(names, " · ")
|
||||
}
|
||||
|
||||
// showSandboxStatus displays the current sandbox configuration and whether
|
||||
// the OS sandbox backend is available. It reads from the stored config so
|
||||
// the user can inspect sandbox state without leaving the TUI (closes #3316).
|
||||
func (m *chatTUI) showSandboxStatus() {
|
||||
if m.cfg == nil {
|
||||
m.notice("sandbox: config not loaded")
|
||||
return
|
||||
}
|
||||
bash := m.cfg.BashMode()
|
||||
network := m.cfg.Sandbox.Network
|
||||
available := sandbox.Available()
|
||||
roots := m.cfg.WriteRoots()
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("sandbox\n")
|
||||
b.WriteString(" phase 0 file-writer confinement\n")
|
||||
if len(roots) > 0 {
|
||||
fmt.Fprintf(&b, " write_roots %s\n", strings.Join(roots, ", "))
|
||||
}
|
||||
if m.cfg.Sandbox.WorkspaceRoot != "" {
|
||||
fmt.Fprintf(&b, " workspace_root %s\n", m.cfg.Sandbox.WorkspaceRoot)
|
||||
}
|
||||
if len(m.cfg.Sandbox.AllowWrite) > 0 {
|
||||
fmt.Fprintf(&b, " allow_write %s\n", strings.Join(m.cfg.Sandbox.AllowWrite, ", "))
|
||||
}
|
||||
b.WriteString(" phase 1 OS bash sandbox\n")
|
||||
fmt.Fprintf(&b, " bash %s", bash)
|
||||
if bash == "enforce" && !available {
|
||||
b.WriteString(" (inactive: no OS sandbox on this host — bash runs unconfined)")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
fmt.Fprintf(&b, " network %v\n", network)
|
||||
m.notice(b.String())
|
||||
}
|
||||
|
||||
// runMCPSubcommand handles "/mcp" (status), "/mcp add …" (connect a server live
|
||||
// and persist it), and "/mcp remove <name>" (disconnect + drop from config). Add
|
||||
// connects synchronously — like /compact, an explicit command may briefly block
|
||||
|
||||
@@ -24,6 +24,14 @@ import (
|
||||
|
||||
type blockingTurnRunner struct{ started chan struct{} }
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
old := detectTermuxTerminal
|
||||
detectTermuxTerminal = func() bool { return false }
|
||||
code := m.Run()
|
||||
detectTermuxTerminal = old
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func (r *blockingTurnRunner) Run(ctx context.Context, _ string) error {
|
||||
close(r.started)
|
||||
<-ctx.Done()
|
||||
@@ -98,6 +106,45 @@ func TestTranscriptMirrorsCommits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTermuxNativeScrollbackCommitsFinalAnswer(t *testing.T) {
|
||||
m := newTestChatTUI()
|
||||
m.nativeScrollback = true
|
||||
m.pending.WriteString("first paragraph\n\nsecond paragraph")
|
||||
|
||||
m.streamAnswer()
|
||||
if len(*m.pendingCommit) != 0 {
|
||||
t.Fatalf("Termux native scrollback should not commit rewritten streaming blocks, got %v", *m.pendingCommit)
|
||||
}
|
||||
|
||||
m.commitPending()
|
||||
if got := strings.Join(*m.pendingCommit, "\n"); !strings.Contains(got, "first paragraph") || !strings.Contains(got, "second paragraph") {
|
||||
t.Fatalf("final answer was not committed to native scrollback: %v", *m.pendingCommit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTermuxNativeScrollbackDefaultsToExpandedReasoning(t *testing.T) {
|
||||
old := detectTermuxTerminal
|
||||
detectTermuxTerminal = func() bool { return true }
|
||||
t.Cleanup(func() { detectTermuxTerminal = old })
|
||||
|
||||
ctrl := control.New(control.Options{})
|
||||
m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
|
||||
if !m.nativeScrollback {
|
||||
t.Fatal("Termux should use native scrollback")
|
||||
}
|
||||
if !m.showReasoning {
|
||||
t.Fatal("Termux should expand reasoning by default because live viewport reasoning is unavailable")
|
||||
}
|
||||
m.width = 80
|
||||
|
||||
m.ingestEvent(event.Event{Kind: event.Reasoning, Text: "reasoning details"})
|
||||
m.ingestEvent(event.Event{Kind: event.Text, Text: "answer"})
|
||||
got := strings.Join(*m.pendingCommit, "\n")
|
||||
if !strings.Contains(got, "reasoning details") {
|
||||
t.Fatalf("Termux reasoning was not expanded into native scrollback: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTranscriptViewportSizing proves the viewport tracks the terminal size and
|
||||
// gets the rows left over after the pinned bottom region (input box + 2 status
|
||||
// rows = 5 with an empty 1-line composer), and is fed the committed transcript.
|
||||
@@ -992,6 +1039,7 @@ func TestQueueIndicatorHiddenWhenIdle(t *testing.T) {
|
||||
func TestViewAltScreenFillsHeight(t *testing.T) {
|
||||
ctrl := control.New(control.Options{})
|
||||
m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
|
||||
m.nativeScrollback = false
|
||||
m0, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
|
||||
v := m0.(chatTUI).View()
|
||||
|
||||
@@ -1006,6 +1054,24 @@ func TestViewAltScreenFillsHeight(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewTermuxUsesNativeScrollback(t *testing.T) {
|
||||
ctrl := control.New(control.Options{})
|
||||
m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
|
||||
m.nativeScrollback = true
|
||||
m0, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
|
||||
v := m0.(chatTUI).View()
|
||||
|
||||
if v.AltScreen {
|
||||
t.Error("Termux view must stay in the normal screen so native touch scrollback works")
|
||||
}
|
||||
if v.MouseMode != tea.MouseModeNone {
|
||||
t.Error("Termux view must not enable mouse mode because it prevents soft-keyboard focus")
|
||||
}
|
||||
if lines := strings.Count(v.Content, "\n") + 1; lines >= 24 {
|
||||
t.Errorf("Termux view should render only the pinned bottom frame, got %d full-screen lines", lines)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTranscriptTailFollow proves the viewport pins to newest output while the
|
||||
// user is at the bottom, and stops yanking once the user scrolls up.
|
||||
func TestTranscriptTailFollow(t *testing.T) {
|
||||
@@ -1035,6 +1101,54 @@ func TestTranscriptTailFollow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmptyEnterScrollsToBottom proves that pressing Enter with an empty composer
|
||||
// scrolls the viewport to the bottom in both idle and running states, so the user
|
||||
// can quickly tail-follow after scrolling up to read history.
|
||||
func TestEmptyEnterScrollsToBottom(t *testing.T) {
|
||||
ctrl := control.New(control.Options{})
|
||||
ch := make(chan event.Event, 1)
|
||||
notice := agentEventMsg(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "line"})
|
||||
adv := func(m chatTUI, msg tea.Msg) chatTUI {
|
||||
n, _ := m.Update(msg)
|
||||
return n.(chatTUI)
|
||||
}
|
||||
|
||||
// --- idle state ---
|
||||
t.Run("idle", func(t *testing.T) {
|
||||
cur := adv(newChatTUI(ctrl, "", ch, 80), tea.WindowSizeMsg{Width: 80, Height: 8})
|
||||
for i := 0; i < 12; i++ {
|
||||
cur = adv(cur, notice)
|
||||
}
|
||||
// Scroll up to leave the bottom.
|
||||
cur = adv(cur, tea.MouseWheelMsg{Button: tea.MouseWheelUp})
|
||||
if cur.viewport.AtBottom() {
|
||||
t.Fatal("wheel-up should break the bottom pin")
|
||||
}
|
||||
// Empty enter → should snap back to bottom.
|
||||
cur = adv(cur, tea.KeyPressMsg{Code: tea.KeyEnter})
|
||||
if !cur.viewport.AtBottom() {
|
||||
t.Error("empty enter while idle should scroll viewport to bottom")
|
||||
}
|
||||
})
|
||||
|
||||
// --- running state ---
|
||||
t.Run("running", func(t *testing.T) {
|
||||
cur := adv(newChatTUI(ctrl, "", ch, 80), tea.WindowSizeMsg{Width: 80, Height: 8})
|
||||
for i := 0; i < 12; i++ {
|
||||
cur = adv(cur, notice)
|
||||
}
|
||||
cur.state = tuiRunning
|
||||
cur = adv(cur, tea.MouseWheelMsg{Button: tea.MouseWheelUp})
|
||||
if cur.viewport.AtBottom() {
|
||||
t.Fatal("wheel-up should break the bottom pin")
|
||||
}
|
||||
cur = adv(cur, tea.KeyPressMsg{Code: tea.KeyEnter})
|
||||
if !cur.viewport.AtBottom() {
|
||||
t.Error("empty enter while running should scroll viewport to bottom")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFoldedPasteUsesPlaceholderAndExpandsOnSend(t *testing.T) {
|
||||
m := newTestChatTUI()
|
||||
pasted := "{\n \"a\": 1,\n \"b\": 2,\n \"c\": 3,\n \"d\": 4\n}"
|
||||
|
||||
@@ -157,10 +157,10 @@ func (m chatTUI) chooserActivate(row int) (tea.Model, tea.Cmd) {
|
||||
switch {
|
||||
case row < len(q.Options):
|
||||
if q.Multi {
|
||||
c.sel[c.tab][row] = !c.sel[c.tab][row]
|
||||
c.custom[c.tab] = ""
|
||||
c.cursor = row
|
||||
return m, nil
|
||||
// Space toggles; Enter confirms current selections and advances.
|
||||
// (Toggling is handled in handleChooserKey; we only arrive here
|
||||
// via Enter or number keys, both of which should commit.)
|
||||
return m.chooserAdvance()
|
||||
}
|
||||
c.sel[c.tab] = map[int]bool{row: true}
|
||||
c.custom[c.tab] = ""
|
||||
|
||||
+52
-3
@@ -93,6 +93,9 @@ func Run(args []string, version string) int {
|
||||
case "doctor":
|
||||
configureCLIThemeFromConfigNoProbe()
|
||||
return doctorCommand(rest, version)
|
||||
case "review":
|
||||
configureCLIThemeFromConfigNoProbe()
|
||||
return reviewCommand(rest)
|
||||
case "version", "--version", "-v":
|
||||
fmt.Println("reasonix", version)
|
||||
return 0
|
||||
@@ -202,6 +205,9 @@ func runAgent(args []string) int {
|
||||
showThinking := fs.Bool("show-thinking", false, "show thinking text instead of the collapsed thinking marker")
|
||||
metricsPath := fs.String("metrics", "", "write a JSON token/cache/cost summary of the run to this path")
|
||||
dir := fs.String("dir", "", "change to this directory first (project root); config, sandbox and file tools resolve from here")
|
||||
cont := fs.Bool("continue", false, "resume the most recent saved session")
|
||||
fs.BoolVar(cont, "c", false, "shorthand for --continue")
|
||||
resume := fs.String("resume", "", "resume a specific session file (non-interactive; takes precedence over --continue)")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
@@ -250,6 +256,38 @@ func runAgent(args []string) int {
|
||||
}
|
||||
defer ctrl.Close()
|
||||
|
||||
// --resume: load a specific session file (non-interactive, meant for
|
||||
// MCP/API callers that manage their own per-project session). Takes
|
||||
// precedence over --continue.
|
||||
// --continue: resume the most recent saved session.
|
||||
if *resume != "" {
|
||||
loaded, err := agent.LoadSession(*resume)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
|
||||
return 1
|
||||
}
|
||||
ctrl.Resume(loaded, *resume)
|
||||
} else if *cont {
|
||||
sessions, err := agent.ListSessions(config.SessionDir())
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
|
||||
return 1
|
||||
}
|
||||
if len(sessions) == 0 {
|
||||
fmt.Fprintln(os.Stderr, i18n.M.NoSessionToResume)
|
||||
return 1
|
||||
}
|
||||
loaded, err := agent.LoadSession(sessions[0].Path)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
|
||||
return 1
|
||||
}
|
||||
ctrl.Resume(loaded, sessions[0].Path)
|
||||
}
|
||||
if ctrl.SessionPath() == "" && ctrl.SessionDir() != "" {
|
||||
ctrl.SetSessionPath(agent.NewSessionPath(ctrl.SessionDir(), ctrl.Label()))
|
||||
}
|
||||
|
||||
runErr := ctrl.Run(ctx, prompt)
|
||||
if cfg != nil {
|
||||
notify.SendEvent(newNotificationSender(), cfg.Notifications, event.Event{Kind: event.TurnDone, Err: runErr})
|
||||
@@ -432,6 +470,7 @@ func chatREPL(args []string) int {
|
||||
if cfg, err := config.Load(); err == nil {
|
||||
m.outputStyle = cfg.Agent.OutputStyle // shown as the active entry in /output-style
|
||||
m.statuslineCmd = cfg.Statusline.Command // custom status-line command, "" = built-in row
|
||||
m.cfg = cfg
|
||||
}
|
||||
|
||||
// /model support: a pure builder the TUI calls to rebuild on a different
|
||||
@@ -468,9 +507,13 @@ func chatREPL(args []string) int {
|
||||
}
|
||||
m.refreshEffortStatus()
|
||||
|
||||
// No alt-screen: finalized transcript lines are committed to the terminal's
|
||||
// normal buffer (via tea.Println) so native scrollback, the wheel, and copy
|
||||
// all work — the bubbletea-managed region is just the bottom input/status.
|
||||
if m.nativeScrollback {
|
||||
reserveNativeScrollbackFrame(os.Stdout, m.bottomRows())
|
||||
}
|
||||
|
||||
// Non-Termux terminals use an alt-screen transcript viewport. Termux stays
|
||||
// in the normal buffer so native touch scrollback and soft-keyboard focus
|
||||
// keep working; finalized transcript lines are emitted via tea.Println.
|
||||
p := tea.NewProgram(m)
|
||||
final, runErr := p.Run()
|
||||
// Close the active controller plus any retired ones from /model switches.
|
||||
@@ -497,6 +540,12 @@ func chatREPL(args []string) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func reserveNativeScrollbackFrame(w io.Writer, rows int) {
|
||||
for i := 0; i < rows; i++ {
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
}
|
||||
|
||||
// setupTargets is where the wizard writes: the TOML config and the secrets file.
|
||||
// Keys always go to the reasonix-owned global credentials file so they never land
|
||||
// in a project's own .env; only the config location is project-local under --local.
|
||||
|
||||
@@ -49,6 +49,19 @@ func TestChdirTo(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReserveNativeScrollbackFrameWritesOnlyNewlines(t *testing.T) {
|
||||
var b bytes.Buffer
|
||||
reserveNativeScrollbackFrame(&b, 3)
|
||||
if got := b.String(); got != "\n\n\n" {
|
||||
t.Fatalf("reserveNativeScrollbackFrame wrote %q, want only three newlines", got)
|
||||
}
|
||||
|
||||
reserveNativeScrollbackFrame(&b, 0)
|
||||
if got := b.String(); got != "\n\n\n" {
|
||||
t.Fatalf("reserveNativeScrollbackFrame(0) changed output to %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func mustGetwd(t *testing.T) string {
|
||||
t.Helper()
|
||||
cwd, err := os.Getwd()
|
||||
@@ -92,9 +105,12 @@ func TestMetadataCommandsDoNotProbeTerminalTheme(t *testing.T) {
|
||||
t.Fatalf("help rc = %d, want 0", rc)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, "Usage:") {
|
||||
if !strings.Contains(out, "Usage:") && !strings.Contains(out, "用法:") {
|
||||
t.Fatalf("help output missing usage:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] <task>") {
|
||||
t.Fatalf("help output missing run resume flags:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDispatchesACPLongFlagAlias(t *testing.T) {
|
||||
|
||||
@@ -60,7 +60,7 @@ func codegraphStatus() int {
|
||||
}
|
||||
fmt.Printf("%-13s %v\n", "enabled:", cfg.Codegraph.Enabled)
|
||||
fmt.Printf("%-13s %v\n", "auto_install:", cfg.Codegraph.AutoInstall)
|
||||
fmt.Printf("%-13s %s\n", "tier:", cfg.Codegraph.ResolvedTier())
|
||||
fmt.Printf("%-13s %s\n", "startup:", cfg.Codegraph.ResolvedTier())
|
||||
fmt.Printf("%-13s %s\n", "version:", codegraph.Version)
|
||||
fmt.Printf("%-13s %s\n", "cache:", codegraph.CacheDir())
|
||||
if p, ok := codegraph.Resolve(cfg.Codegraph.Path); ok {
|
||||
|
||||
@@ -62,6 +62,7 @@ func (m *chatTUI) slashItems() []compItem {
|
||||
items := []compItem{
|
||||
{label: "/compact", insert: "/compact ", hint: i18n.M.CmdCompact},
|
||||
{label: "/new", insert: "/new ", hint: i18n.M.CmdNew},
|
||||
{label: "/clear", insert: "/clear", hint: i18n.M.CmdNew},
|
||||
{label: "/resume", insert: "/resume ", hint: i18n.M.CmdResume},
|
||||
{label: "/rewind", insert: "/rewind", hint: i18n.M.CmdRewind},
|
||||
{label: "/tree", insert: "/tree", hint: i18n.M.CmdTree},
|
||||
@@ -74,6 +75,7 @@ func (m *chatTUI) slashItems() []compItem {
|
||||
{label: "/paste-image", insert: "/paste-image", hint: i18n.M.CmdPasteImage},
|
||||
{label: "/output-style", insert: "/output-style", hint: i18n.M.CmdOutputStyle},
|
||||
{label: "/verbose", insert: "/verbose", hint: i18n.M.CmdVerbose},
|
||||
{label: "/sandbox", insert: "/sandbox", hint: i18n.M.CmdSandbox},
|
||||
{label: "/effort", insert: "/effort ", hint: i18n.M.CmdEffort, descend: true},
|
||||
{label: "/auto-plan", insert: "/auto-plan ", hint: i18n.M.CmdAutoPlan, descend: true},
|
||||
{label: "/theme", insert: "/theme ", hint: i18n.M.CmdTheme, descend: true},
|
||||
@@ -474,6 +476,17 @@ func (m *chatTUI) completionBareOverlayCommand() bool {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *chatTUI) completionSelectedInsertPresent() bool {
|
||||
if !m.completion.active || m.completion.sel >= len(m.completion.items) {
|
||||
return false
|
||||
}
|
||||
val := m.input.Value()
|
||||
if m.completion.replaceFrom > len(val) {
|
||||
return false
|
||||
}
|
||||
return val[m.completion.replaceFrom:] == m.completion.items[m.completion.sel].insert
|
||||
}
|
||||
|
||||
// acceptCompletion applies the selected item to the input, then recomputes the
|
||||
// menu from the new value: it re-opens one level deeper (a descended directory
|
||||
// or a freshly completed command's arguments) or closes when nothing applies.
|
||||
|
||||
@@ -330,6 +330,28 @@ func TestEnterOnMCPWithTrailingSpaceSubmitsManager(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnterOnExactSlashArgSubmitsWhenPrefixAlsoMatches(t *testing.T) {
|
||||
m := newTestChatTUI()
|
||||
m.ctrl = control.New(control.Options{SessionDir: t.TempDir()})
|
||||
m.input.SetValue("/resume 1")
|
||||
m.completion = completion{
|
||||
active: true,
|
||||
kind: compSlashArg,
|
||||
items: []compItem{{label: "1", insert: "1"}, {label: "10", insert: "10"}},
|
||||
sel: 0,
|
||||
replaceFrom: len("/resume "),
|
||||
}
|
||||
|
||||
got, _ := m.update(tea.KeyPressMsg{Code: tea.KeyEnter})
|
||||
next := got.(chatTUI)
|
||||
if next.completion.active {
|
||||
t.Fatalf("Enter on exact selected arg should close completion: %+v", next.completion)
|
||||
}
|
||||
if got := next.input.Value(); got != "" {
|
||||
t.Fatalf("Enter on exact selected arg should submit command, input=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlashArgCompletionRemoveNoHost proves "/mcp remove " stays closed when no
|
||||
// servers are connected (nothing to suggest), rather than showing an empty box.
|
||||
func TestSlashArgCompletionRemoveNoHost(t *testing.T) {
|
||||
|
||||
@@ -57,6 +57,7 @@ func builtinHelpItems() []compItem {
|
||||
return []compItem{
|
||||
{label: "/compact", hint: i18n.M.CmdCompact},
|
||||
{label: "/new", hint: i18n.M.CmdNew},
|
||||
{label: "/clear", hint: i18n.M.CmdNew},
|
||||
{label: "/rewind", hint: i18n.M.CmdRewind},
|
||||
{label: "/tree", hint: i18n.M.CmdTree},
|
||||
{label: "/branch", hint: i18n.M.CmdBranch},
|
||||
@@ -68,6 +69,7 @@ func builtinHelpItems() []compItem {
|
||||
{label: "/hooks", hint: i18n.M.CmdHooks},
|
||||
{label: "/memory", hint: i18n.M.CmdMemory},
|
||||
{label: "/output-style", hint: i18n.M.CmdOutputStyle},
|
||||
{label: "/sandbox", hint: i18n.M.CmdSandbox},
|
||||
{label: "/language", hint: i18n.M.CmdLanguage},
|
||||
{label: "/auto-plan", hint: i18n.M.CmdAutoPlan},
|
||||
{label: "/help", hint: i18n.M.CmdHelp},
|
||||
|
||||
@@ -364,8 +364,6 @@ func (m chatTUI) buildMCPSnapshot() mcpSnapshot {
|
||||
status := "initializing"
|
||||
if m.mcpDisabled["codegraph"] || !loadedCfg.Codegraph.Enabled {
|
||||
status = "disabled"
|
||||
} else if loadedCfg.Codegraph.ResolvedTier() == "lazy" {
|
||||
status = "deferred"
|
||||
}
|
||||
snap.servers = append(snap.servers, withMCPCodegraphConfig(mcpServerView{
|
||||
Name: "codegraph", Status: status,
|
||||
|
||||
@@ -171,7 +171,7 @@ func (m chatTUI) applyMCPMode(tier string) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
if v.BuiltIn && v.Name == "codegraph" {
|
||||
cfg.Codegraph.Enabled = true
|
||||
cfg.Codegraph.Tier = normalizeMCPTierForCLI(tier)
|
||||
cfg.Codegraph.Tier = ""
|
||||
if err := cfg.Save(); err != nil {
|
||||
m.notice("mcp mode: " + err.Error())
|
||||
return m, nil
|
||||
@@ -179,7 +179,7 @@ func (m chatTUI) applyMCPMode(tier string) (tea.Model, tea.Cmd) {
|
||||
if m.mcpDisabled != nil {
|
||||
delete(m.mcpDisabled, v.Name)
|
||||
}
|
||||
if tier != "lazy" && m.ctrl != nil && !mcpConnected(m.ctrl, v.Name) {
|
||||
if m.ctrl != nil && !mcpConnected(m.ctrl, v.Name) {
|
||||
if _, err := m.ctrl.ConnectConfiguredMCPServer(v.Name); err != nil {
|
||||
recordMCPModeCodegraphFailure(m.ctrl, cfg.Codegraph, err)
|
||||
m.notice("saved connection mode, but connect failed: " + err.Error())
|
||||
|
||||
@@ -459,7 +459,7 @@ func TestApplyMCPModeRecordsCodegraphConnectFailure(t *testing.T) {
|
||||
t.Setenv("REASONIX_CACHE_DIR", t.TempDir())
|
||||
cfg := config.Default()
|
||||
cfg.Codegraph.Enabled = false
|
||||
cfg.Codegraph.Tier = "lazy"
|
||||
cfg.Codegraph.Tier = "eager"
|
||||
if err := cfg.SaveTo("reasonix.toml"); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
@@ -472,11 +472,11 @@ func TestApplyMCPModeRecordsCodegraphConnectFailure(t *testing.T) {
|
||||
stage: mcpStageMode,
|
||||
name: "codegraph",
|
||||
snapshot: mcpSnapshot{configPath: "reasonix.toml", servers: []mcpServerView{{
|
||||
Name: "codegraph", Transport: "stdio", Status: "disabled", BuiltIn: true, Configured: true, Tier: "lazy",
|
||||
Name: "codegraph", Transport: "stdio", Status: "disabled", BuiltIn: true, Configured: true, Tier: "background",
|
||||
}}},
|
||||
}
|
||||
|
||||
_, _ = m.applyMCPMode("background")
|
||||
_, _ = m.applyMCPMode("eager")
|
||||
|
||||
failures := m.ctrl.Host().Failures()
|
||||
if len(failures) != 1 || failures[0].Name != "codegraph" {
|
||||
|
||||
@@ -87,7 +87,7 @@ func (m *chatTUI) showModels() {
|
||||
if !p.Configured() {
|
||||
continue
|
||||
}
|
||||
for _, model := range p.ModelList() {
|
||||
for _, model := range p.ChatModelList() {
|
||||
refs = append(refs, p.Name+"/"+model)
|
||||
}
|
||||
}
|
||||
@@ -106,7 +106,7 @@ func modelRefs() []string {
|
||||
if !p.Configured() {
|
||||
continue
|
||||
}
|
||||
for _, model := range p.ModelList() {
|
||||
for _, model := range p.ChatModelList() {
|
||||
out = append(out, p.Name+"/"+model)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/boot"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/skill"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
func reviewCommand(args []string) int {
|
||||
fs := flag.NewFlagSet("review", flag.ContinueOnError)
|
||||
base := fs.String("base", "", "base branch/commit to diff against (defaults to HEAD — reviews uncommitted working-tree changes)")
|
||||
commit := fs.String("commit", "", "review a specific commit (shows changes introduced by that commit)")
|
||||
model := fs.String("model", "", "provider name override (default: config default_model)")
|
||||
instructions := fs.String("instructions", "", "extra review instructions appended to the prompt")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
|
||||
// 1. Get the diff.
|
||||
diff, err := getReviewDiff(*base, *commit)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
if diff == "" {
|
||||
fmt.Println("No changes to review.")
|
||||
return 0
|
||||
}
|
||||
|
||||
// 2. Load config and resolve model.
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error: failed to load config:", err)
|
||||
return 1
|
||||
}
|
||||
modelName := *model
|
||||
if modelName == "" {
|
||||
modelName = cfg.DefaultModel
|
||||
}
|
||||
entry, ok := cfg.ResolveModel(modelName)
|
||||
if !ok {
|
||||
fmt.Fprintf(os.Stderr, "error: unknown model %q — check your config\n", modelName)
|
||||
return 1
|
||||
}
|
||||
if err := cfg.Validate(modelName); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
// 3. Create provider.
|
||||
prov, err := boot.NewProviderWithProxy(entry, cfg.NetworkProxySpec())
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error: failed to create provider:", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
// 4. Get the built-in review skill.
|
||||
root, _ := os.Getwd()
|
||||
skillStore := skill.New(skill.Options{ProjectRoot: root, Stderr: os.Stderr})
|
||||
reviewSk, ok := skillStore.Read("review")
|
||||
if !ok {
|
||||
fmt.Fprintln(os.Stderr, "error: built-in review skill not found")
|
||||
return 1
|
||||
}
|
||||
if reviewSk.RunAs != skill.RunSubagent {
|
||||
fmt.Fprintln(os.Stderr, "error: review skill is not a subagent skill")
|
||||
return 1
|
||||
}
|
||||
|
||||
// 5. Build tool registry scoped to the review skill's allowed tools
|
||||
// (read_file, ls, glob, grep, bash).
|
||||
reg := tool.NewRegistry()
|
||||
for _, name := range reviewSk.AllowedTools {
|
||||
if tl, ok := tool.LookupBuiltin(name); ok {
|
||||
reg.Add(tl)
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Prepare the review prompt.
|
||||
task := buildReviewTask(diff, *instructions)
|
||||
|
||||
// 7. Run the review subagent.
|
||||
ctx := context.Background()
|
||||
result, err := agent.RunSubAgent(ctx, prov, reg, reviewSk.Body, task, agent.Options{
|
||||
MaxSteps: 12,
|
||||
Temperature: cfg.Agent.Temperature,
|
||||
Pricing: entry.Price,
|
||||
ContextWindow: entry.ContextWindow,
|
||||
}, event.Discard)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error: review failed:", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
fmt.Print(result)
|
||||
return 0
|
||||
}
|
||||
|
||||
// getReviewDiff runs the appropriate git diff command and returns its output.
|
||||
// - commit="abc": shows diff of abc^..abc
|
||||
// - base="main": shows diff of main...HEAD
|
||||
// - neither: shows diff of uncommitted working-tree changes
|
||||
func getReviewDiff(base, commit string) (string, error) {
|
||||
cwd, _ := os.Getwd()
|
||||
ctx := context.Background()
|
||||
switch {
|
||||
case commit != "":
|
||||
return runGit(ctx, cwd, "diff", commit+"^.."+commit)
|
||||
case base != "":
|
||||
return runGit(ctx, cwd, "diff", base+"...HEAD")
|
||||
default:
|
||||
// Working tree changes: staged + unstaged.
|
||||
out, err := runGit(ctx, cwd, "diff", "HEAD")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if out == "" {
|
||||
// No working-tree changes; check for staged-only.
|
||||
out, err = runGit(ctx, cwd, "diff", "--cached")
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
|
||||
func buildReviewTask(diff string, extra string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("Review the following changes. ")
|
||||
if extra != "" {
|
||||
b.WriteString(extra)
|
||||
b.WriteString(" ")
|
||||
}
|
||||
b.WriteString("The diff is:\n\n```diff\n")
|
||||
// Truncate huge diffs to protect the review subagent's context budget.
|
||||
const maxLen = 16000
|
||||
if len(diff) > maxLen {
|
||||
b.WriteString(diff[:maxLen])
|
||||
b.WriteString("\n```\n\n(diff truncated at ")
|
||||
fmt.Fprint(&b, maxLen)
|
||||
b.WriteString(" chars — focus on the changes shown)")
|
||||
} else {
|
||||
b.WriteString(diff)
|
||||
b.WriteString("\n```")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildReviewTask(t *testing.T) {
|
||||
// Small diff.
|
||||
diff := "diff --git a/foo.go b/foo.go\n+added line"
|
||||
got := buildReviewTask(diff, "")
|
||||
if !strings.Contains(got, "Review the following changes.") {
|
||||
t.Error("missing review prompt prefix")
|
||||
}
|
||||
if !strings.Contains(got, diff) {
|
||||
t.Errorf("diff content missing:\n%s", got)
|
||||
}
|
||||
|
||||
// With extra instructions.
|
||||
got = buildReviewTask(diff, "focus on error handling")
|
||||
if !strings.Contains(got, "focus on error handling") {
|
||||
t.Error("extra instructions missing")
|
||||
}
|
||||
if !strings.Contains(got, "The diff is:") {
|
||||
t.Error("missing diff separator")
|
||||
}
|
||||
|
||||
// Truncation.
|
||||
hugeDiff := strings.Repeat("x", 20000)
|
||||
got = buildReviewTask(hugeDiff, "")
|
||||
if !strings.Contains(got, "truncated at 16000") {
|
||||
t.Error("large diff should be truncated")
|
||||
}
|
||||
if len(got) > 16500 {
|
||||
t.Errorf("truncated output too long: %d", len(got))
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,6 @@ type RunMetrics struct {
|
||||
ReadinessRecoveries int `json:"readiness_recoveries"`
|
||||
ReadinessErrors int `json:"readiness_errors"`
|
||||
ReadinessMissingProjectChecks int `json:"readiness_missing_project_checks"`
|
||||
ReadinessMissingCompleteSteps int `json:"readiness_missing_complete_steps"`
|
||||
ReadinessIncompleteTodos int `json:"readiness_incomplete_todos"`
|
||||
ReadinessCommandMismatches int `json:"readiness_command_mismatches"`
|
||||
}
|
||||
@@ -76,9 +75,6 @@ func (s *metricsSink) RecordReadinessAudit(a evidence.ReadinessAudit) {
|
||||
s.m.ReadinessRecoveries++
|
||||
}
|
||||
s.m.ReadinessMissingProjectChecks += a.MissingProjectChecks
|
||||
if a.MissingCompleteStep {
|
||||
s.m.ReadinessMissingCompleteSteps++
|
||||
}
|
||||
s.m.ReadinessIncompleteTodos += a.IncompleteTodos
|
||||
s.m.ReadinessCommandMismatches += a.CommandMismatchMissing
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ func TestMetricsSinkAccumulatesReadinessAudit(t *testing.T) {
|
||||
s.RecordReadinessAudit(evidence.ReadinessAudit{
|
||||
Result: evidence.ReadinessBlocked,
|
||||
MissingProjectChecks: 2,
|
||||
MissingCompleteStep: true,
|
||||
IncompleteTodos: 3,
|
||||
CommandMismatchMissing: 2,
|
||||
})
|
||||
@@ -25,8 +24,7 @@ func TestMetricsSinkAccumulatesReadinessAudit(t *testing.T) {
|
||||
Recovered: true,
|
||||
})
|
||||
s.RecordReadinessAudit(evidence.ReadinessAudit{
|
||||
Result: evidence.ReadinessErrored,
|
||||
MissingCompleteStep: true,
|
||||
Result: evidence.ReadinessErrored,
|
||||
})
|
||||
|
||||
if s.m.ReadinessChecks != 3 {
|
||||
@@ -47,9 +45,6 @@ func TestMetricsSinkAccumulatesReadinessAudit(t *testing.T) {
|
||||
if s.m.ReadinessMissingProjectChecks != 2 {
|
||||
t.Fatalf("missing project checks = %d, want 2", s.m.ReadinessMissingProjectChecks)
|
||||
}
|
||||
if s.m.ReadinessMissingCompleteSteps != 2 {
|
||||
t.Fatalf("missing complete_step = %d, want 2", s.m.ReadinessMissingCompleteSteps)
|
||||
}
|
||||
if s.m.ReadinessIncompleteTodos != 3 {
|
||||
t.Fatalf("incomplete todos = %d, want 3", s.m.ReadinessIncompleteTodos)
|
||||
}
|
||||
@@ -72,7 +67,6 @@ func TestWriteMetricsIncludesReadinessFields(t *testing.T) {
|
||||
ReadinessRecoveries: 1,
|
||||
ReadinessErrors: 0,
|
||||
ReadinessMissingProjectChecks: 0,
|
||||
ReadinessMissingCompleteSteps: 0,
|
||||
ReadinessIncompleteTodos: 0,
|
||||
ReadinessCommandMismatches: 0,
|
||||
}); err != nil {
|
||||
@@ -94,7 +88,6 @@ func TestWriteMetricsIncludesReadinessFields(t *testing.T) {
|
||||
"readiness_recoveries",
|
||||
"readiness_errors",
|
||||
"readiness_missing_project_checks",
|
||||
"readiness_missing_complete_steps",
|
||||
"readiness_incomplete_todos",
|
||||
"readiness_command_mismatches",
|
||||
} {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package codegraph
|
||||
|
||||
// releaseAssetSHA256 holds the upstream SHA256SUMS values for Version. These
|
||||
// are baked into the reasonix binary so downloaded CodeGraph archives are
|
||||
// verified against trusted metadata instead of trusting whichever mirror served
|
||||
// the bytes.
|
||||
var releaseAssetSHA256 = map[string]string{
|
||||
"codegraph-darwin-arm64.tar.gz": "83a3b90bc334ab2a34240e29e9fab7ff273ab6381794aa6f3cba428397c916b5",
|
||||
"codegraph-darwin-x64.tar.gz": "74d2331161a317fa6164285a61ec480ce7893be46c1677a4b5a2932e35586b9d",
|
||||
"codegraph-linux-arm64.tar.gz": "7b4225f90ca5285cccfec099323129348c2753bcbc9910281f9b61db88fa5f37",
|
||||
"codegraph-linux-x64.tar.gz": "61805e3c9b4052db53c71241b800859095fea4f2cbd2a1844a6c2b9594b9f84a",
|
||||
"codegraph-win32-arm64.zip": "c728ada3d42701213dde26d8e94ded3ed1c7d4b568124210649ce8f9f938a31a",
|
||||
"codegraph-win32-x64.zip": "a5571d3ee54cc1caac76bf09e0f7cb350fc4dd6788a5437217eac33b71fa7a15",
|
||||
}
|
||||
@@ -40,17 +40,18 @@ You have codegraph tools for symbol-level code intelligence. For architecture qu
|
||||
- codegraph_files — project file tree with symbol counts
|
||||
Use grep/read_file for content search (comments, strings, config values) and when codegraph is not available.`
|
||||
|
||||
// BundleDirName is the directory, beside the reasonix executable, that the release
|
||||
// archive unpacks the CodeGraph bundle into. Its launcher lives at
|
||||
// <BundleDirName>/bin/codegraph, with the bundled node runtime and lib/ beside it;
|
||||
// the launcher resolves those relative to itself, so the bundle is relocatable.
|
||||
// BundleDirName is the optional directory, beside the reasonix executable, where
|
||||
// an operator can place an unpacked CodeGraph bundle for offline use. Its
|
||||
// launcher lives at <BundleDirName>/bin/codegraph, with the bundled node runtime
|
||||
// and lib/ beside it; the launcher resolves those relative to itself, so the
|
||||
// bundle is relocatable.
|
||||
const BundleDirName = "codegraph"
|
||||
|
||||
// Resolve returns the absolute path to the CodeGraph launcher. Search order:
|
||||
// 1. override — an explicit [codegraph].path from config (~ and ${VAR} expanded);
|
||||
// 2. the per-version cache populated by Install (the normal case);
|
||||
// 2. the per-version cache populated by Install;
|
||||
// 3. a system-installed `codegraph` on PATH;
|
||||
// 4. a bundle placed beside the reasonix executable (fallback for manual setups).
|
||||
// 4. a bundle placed beside the executable (manual/offline fallback).
|
||||
//
|
||||
// ok is false when none resolves — the caller then triggers Install (or skips the
|
||||
// feature), so the codegraph_* tools come online once the cache is populated.
|
||||
@@ -76,6 +77,19 @@ func Resolve(override string) (string, bool) {
|
||||
// The executable path is symlink-resolved first so a launcher installed via a
|
||||
// symlink (e.g. a package manager's bin shim) still points at the real bundle.
|
||||
func bundled() (string, bool) {
|
||||
base, ok := bundledBaseDir()
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
for _, rel := range launcherNames() {
|
||||
if p := filepath.Join(base, rel); isExec(p) {
|
||||
return p, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func bundledBaseDir() (string, bool) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", false
|
||||
@@ -83,13 +97,7 @@ func bundled() (string, bool) {
|
||||
if real, err := filepath.EvalSymlinks(exe); err == nil {
|
||||
exe = real
|
||||
}
|
||||
base := filepath.Join(filepath.Dir(exe), BundleDirName)
|
||||
for _, rel := range launcherNames() {
|
||||
if p := filepath.Join(base, rel); isExec(p) {
|
||||
return p, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
return filepath.Join(filepath.Dir(exe), BundleDirName), true
|
||||
}
|
||||
|
||||
// launcherNames are the bundle-relative launcher paths to try, per OS. The unix
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -24,6 +23,10 @@ const (
|
||||
Version = "v0.9.7"
|
||||
cgRepo = "colbymchenry/codegraph"
|
||||
|
||||
officialMirrorBase = "https://pub-147fb53b9c1e4bbf891a257968619ea7.r2.dev/codegraph"
|
||||
officialMainlandMirrorBase = ""
|
||||
perSourceDownloadTimeout = 15 * time.Second
|
||||
|
||||
renameAttempts = 5
|
||||
renameBackoff = 200 * time.Millisecond
|
||||
)
|
||||
@@ -79,7 +82,8 @@ func assetName() string {
|
||||
}
|
||||
|
||||
// Install downloads and unpacks the CodeGraph bundle into CacheDir on first use,
|
||||
// verifying it against the release's SHA256SUMS, then returns the launcher path.
|
||||
// verifying it against the checksum baked into the reasonix binary, then returns
|
||||
// the launcher path.
|
||||
// It is idempotent: a present cache is returned untouched. log, if non-nil,
|
||||
// receives a couple of progress lines. The extraction is staged in a temp dir and
|
||||
// atomically renamed into place, so a cancelled or failed run leaves no partial
|
||||
@@ -104,22 +108,10 @@ func InstallWithClient(ctx context.Context, client *http.Client, log func(string
|
||||
asset := assetName()
|
||||
logf(log, "codegraph: downloading %s (%s, one-time)…", asset, Version)
|
||||
|
||||
base := fmt.Sprintf("https://github.com/%s/releases/download/%s", cgRepo, Version)
|
||||
sums, err := httpGet(ctx, client, base+"/SHA256SUMS")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("codegraph: fetch checksums: %w", err)
|
||||
}
|
||||
want, err := sha256For(string(sums), asset)
|
||||
data, err := downloadAsset(ctx, client, asset, log)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := httpGet(ctx, client, base+"/"+asset)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("codegraph: download %s: %w", asset, err)
|
||||
}
|
||||
if got := sha256.Sum256(data); hex.EncodeToString(got[:]) != want {
|
||||
return "", fmt.Errorf("codegraph: checksum mismatch for %s", asset)
|
||||
}
|
||||
|
||||
parent := filepath.Dir(dir)
|
||||
if err := os.MkdirAll(parent, 0o755); err != nil {
|
||||
@@ -162,6 +154,65 @@ func InstallWithClient(ctx context.Context, client *http.Client, log func(string
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func downloadAsset(ctx context.Context, client *http.Client, asset string, log func(string)) ([]byte, error) {
|
||||
want := expectedAssetSHA256(asset)
|
||||
if want == "" {
|
||||
return nil, fmt.Errorf("codegraph: no embedded checksum for %s (%s)", asset, Version)
|
||||
}
|
||||
return downloadAssetFromBases(ctx, client, asset, want, downloadBases(), log)
|
||||
}
|
||||
|
||||
func downloadAssetFromBases(ctx context.Context, client *http.Client, asset, want string, bases []string, log func(string)) ([]byte, error) {
|
||||
var errs []string
|
||||
for _, base := range bases {
|
||||
url := strings.TrimRight(base, "/") + "/" + asset
|
||||
getCtx := ctx
|
||||
cancel := func() {}
|
||||
if perSourceDownloadTimeout > 0 {
|
||||
getCtx, cancel = context.WithTimeout(ctx, perSourceDownloadTimeout)
|
||||
}
|
||||
data, err := httpGet(getCtx, client, url)
|
||||
cancel()
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Sprintf("%s: %v", base, err))
|
||||
continue
|
||||
}
|
||||
got := fmt.Sprintf("%x", sha256.Sum256(data))
|
||||
if got != want {
|
||||
errs = append(errs, fmt.Sprintf("%s: checksum mismatch for %s", base, asset))
|
||||
continue
|
||||
}
|
||||
logf(log, "codegraph: downloaded from %s", base)
|
||||
return data, nil
|
||||
}
|
||||
return nil, fmt.Errorf("codegraph: download %s failed (%s)", asset, strings.Join(errs, "; "))
|
||||
}
|
||||
|
||||
func expectedAssetSHA256(asset string) string {
|
||||
return releaseAssetSHA256[asset]
|
||||
}
|
||||
|
||||
func downloadBases() []string {
|
||||
bases := []string{officialMirrorBase + "/" + Version}
|
||||
if strings.TrimSpace(officialMainlandMirrorBase) != "" {
|
||||
bases = append(bases, strings.TrimRight(officialMainlandMirrorBase, "/")+"/"+Version)
|
||||
}
|
||||
bases = append(bases, fmt.Sprintf("https://github.com/%s/releases/download/%s", cgRepo, Version))
|
||||
return dedupeStrings(bases)
|
||||
}
|
||||
|
||||
func dedupeStrings(values []string) []string {
|
||||
var out []string
|
||||
seen := make(map[string]bool, len(values))
|
||||
for _, v := range values {
|
||||
if !seen[v] {
|
||||
seen[v] = true
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// promote moves the freshly extracted bundle (root) into its versioned home
|
||||
// (dir). On Windows os.Rename onto an existing directory fails with "Access is
|
||||
// denied", so a stale/partial dest from an earlier interrupted install (cached()
|
||||
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -169,6 +171,79 @@ func TestInstallReturnsCachedWithoutNetwork(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadAssetUsesEmbeddedChecksumAndMirrorFallback(t *testing.T) {
|
||||
asset := assetName()
|
||||
body := []byte("verified codegraph payload")
|
||||
sum := sha256.Sum256(body)
|
||||
prev, hadPrev := releaseAssetSHA256[asset]
|
||||
releaseAssetSHA256[asset] = fmt.Sprintf("%x", sum)
|
||||
t.Cleanup(func() {
|
||||
if hadPrev {
|
||||
releaseAssetSHA256[asset] = prev
|
||||
} else {
|
||||
delete(releaseAssetSHA256, asset)
|
||||
}
|
||||
})
|
||||
|
||||
bad := httptest.NewServer(http.NotFoundHandler())
|
||||
defer bad.Close()
|
||||
good := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasSuffix(r.URL.Path, "/"+asset) {
|
||||
t.Fatalf("download path = %q, want asset suffix", r.URL.Path)
|
||||
}
|
||||
w.Write(body)
|
||||
}))
|
||||
defer good.Close()
|
||||
|
||||
got, err := downloadAssetFromBases(context.Background(), http.DefaultClient, asset, fmt.Sprintf("%x", sum), []string{bad.URL, good.URL}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("downloadAsset: %v", err)
|
||||
}
|
||||
if string(got) != string(body) {
|
||||
t.Fatalf("downloadAsset body = %q, want %q", got, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadAssetRejectsMirrorChecksumMismatch(t *testing.T) {
|
||||
asset := assetName()
|
||||
prev, hadPrev := releaseAssetSHA256[asset]
|
||||
releaseAssetSHA256[asset] = strings.Repeat("0", 64)
|
||||
t.Cleanup(func() {
|
||||
if hadPrev {
|
||||
releaseAssetSHA256[asset] = prev
|
||||
} else {
|
||||
delete(releaseAssetSHA256, asset)
|
||||
}
|
||||
})
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("tampered"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
if _, err := downloadAssetFromBases(ctx, http.DefaultClient, asset, strings.Repeat("0", 64), []string{srv.URL}, nil); err == nil {
|
||||
t.Fatal("downloadAsset accepted a checksum mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadBasesUseOfficialSourcesBeforeGitHub(t *testing.T) {
|
||||
got := downloadBases()
|
||||
want := []string{
|
||||
officialMirrorBase + "/" + Version,
|
||||
fmt.Sprintf("https://github.com/%s/releases/download/%s", cgRepo, Version),
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("downloadBases = %#v, want %#v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("downloadBases[%d] = %q, want %q (all=%#v)", i, got[i], want[i], got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPGetDetachedCtxSurvivesParentCancel(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ── IsLikelyChatModel ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestIsLikelyChatModel_RejectsEmptyInput(t *testing.T) {
|
||||
for _, model := range []string{"", " ", "\t"} {
|
||||
if IsLikelyChatModel(model) {
|
||||
t.Errorf("IsLikelyChatModel(%q) = true, want false", model)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsLikelyChatModel_AllowsKnownChatModels(t *testing.T) {
|
||||
for _, model := range []string{
|
||||
"mimo-v2.5", "mimo-v2.5-pro", "mimo-v2-pro", "mimo-v2-omni",
|
||||
"deepseek-v4-flash", "deepseek-v4-pro",
|
||||
"gpt-4o", "gpt-4o-mini",
|
||||
"claude-3.5-sonnet", "qwen-max",
|
||||
} {
|
||||
if !IsLikelyChatModel(model) {
|
||||
t.Errorf("IsLikelyChatModel(%q) = false, want true", model)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsLikelyChatModel_FiltersAudioModels(t *testing.T) {
|
||||
// Real-world samples from #3483.
|
||||
for _, model := range []string{
|
||||
"mimo-v2.5-asr", "mimo-v2.5-tts", "mimo-v2.5-tts-voice",
|
||||
"mimo-v2-tts-voiceclone", "mimo-v2-tts-voicedesign",
|
||||
"tts-1",
|
||||
} {
|
||||
if IsLikelyChatModel(model) {
|
||||
t.Errorf("IsLikelyChatModel(%q) = true, want false", model)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsLikelyChatModel_FiltersNonChatKeywords(t *testing.T) {
|
||||
for _, model := range []string{
|
||||
"whisper-1",
|
||||
"text-embedding-3-small", "text-embedding-ada-002",
|
||||
"text-moderation-stable",
|
||||
"rerank-v1",
|
||||
"dall-e-3",
|
||||
"text-to-speech-v1", "speech-to-text-v2",
|
||||
} {
|
||||
if IsLikelyChatModel(model) {
|
||||
t.Errorf("IsLikelyChatModel(%q) = true, want false", model)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsLikelyChatModel_DoesNotFilterVoiceAlone(t *testing.T) {
|
||||
for _, model := range []string{
|
||||
"voice-chat-model", "gpt-4o-voice",
|
||||
} {
|
||||
if !IsLikelyChatModel(model) {
|
||||
t.Errorf("IsLikelyChatModel(%q) = false, want true", model)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── ModelList / ChatModelList ──────────────────────────────────────────────────
|
||||
|
||||
func TestModelList_ReturnsRawList(t *testing.T) {
|
||||
p := ProviderEntry{
|
||||
Models: []string{
|
||||
"mimo-v2.5", "mimo-v2.5-pro",
|
||||
"mimo-v2.5-asr", "mimo-v2.5-tts", "mimo-v2.5-tts-voice",
|
||||
},
|
||||
}
|
||||
got := p.ModelList()
|
||||
want := []string{
|
||||
"mimo-v2.5", "mimo-v2.5-pro",
|
||||
"mimo-v2.5-asr", "mimo-v2.5-tts", "mimo-v2.5-tts-voice",
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("ModelList() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatModelList_FiltersNonChatModels(t *testing.T) {
|
||||
p := ProviderEntry{
|
||||
Models: []string{
|
||||
"mimo-v2.5", "mimo-v2.5-pro",
|
||||
"mimo-v2.5-asr", "mimo-v2.5-tts", "mimo-v2.5-tts-voice",
|
||||
"mimo-v2-tts-voiceclone", "mimo-v2-tts-voicedesign",
|
||||
},
|
||||
}
|
||||
got := p.ChatModelList()
|
||||
want := []string{"mimo-v2.5", "mimo-v2.5-pro"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("ChatModelList() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatModelList_AllNonChat(t *testing.T) {
|
||||
p := ProviderEntry{
|
||||
Models: []string{"mimo-v2.5-tts", "mimo-v2.5-asr"},
|
||||
}
|
||||
got := p.ChatModelList()
|
||||
if len(got) != 0 {
|
||||
t.Errorf("ChatModelList() = %v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatModelList_AllChat(t *testing.T) {
|
||||
p := ProviderEntry{
|
||||
Models: []string{"gpt-4o", "gpt-4o-mini"},
|
||||
}
|
||||
got := p.ChatModelList()
|
||||
want := []string{"gpt-4o", "gpt-4o-mini"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("ChatModelList() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatModelList_EmptyModels(t *testing.T) {
|
||||
p := ProviderEntry{}
|
||||
if got := p.ChatModelList(); got != nil {
|
||||
t.Errorf("ChatModelList() = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
Loaded 100 of 139 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user