Compare commits
241
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8abfd71684 | ||
|
|
0ca75544ab | ||
|
|
572a037e5d | ||
|
|
ad92181fa7 | ||
|
|
c56f4aa5d8 | ||
|
|
a344a766fd | ||
|
|
bca793d064 | ||
|
|
ad3c192837 | ||
|
|
5512231ca8 | ||
|
|
bad394cd49 | ||
|
|
3b97580621 | ||
|
|
cb88fe26aa | ||
|
|
e345b89ce5 | ||
|
|
26c7b240ba | ||
|
|
d327a2b1cf | ||
|
|
c1b03b728a | ||
|
|
2a2437bf22 | ||
|
|
4ccb82e81a | ||
|
|
92912219df | ||
|
|
bab3124e8b | ||
|
|
7a66ec6bc9 | ||
|
|
3a505b2691 | ||
|
|
20f43372f6 | ||
|
|
fb79dd7bf8 | ||
|
|
4025b655a4 | ||
|
|
7379903568 | ||
|
|
a685e7a805 | ||
|
|
ce7484b4f5 | ||
|
|
0bc1dcbe1b | ||
|
|
a69b339baf | ||
|
|
26f835cdd2 | ||
|
|
bd3d1413fd | ||
|
|
2c17a980ff | ||
|
|
b784c923a8 | ||
|
|
ea96f898c0 | ||
|
|
47435f6e17 | ||
|
|
df59d1412b | ||
|
|
46739ca7cd | ||
|
|
d055c1cad6 | ||
|
|
adfbfe350d | ||
|
|
652a776554 | ||
|
|
1d78100f63 | ||
|
|
57a5d5fd34 | ||
|
|
14684d8e75 | ||
|
|
2cac848823 | ||
|
|
5a3e0ef13a | ||
|
|
7ed4499748 | ||
|
|
4d5e86d8a5 | ||
|
|
222b6cda96 | ||
|
|
8e243c6500 | ||
|
|
98f3ff6273 | ||
|
|
ce08442732 | ||
|
|
8fcfbd697a | ||
|
|
a8669aba8f | ||
|
|
d31e9cff6a | ||
|
|
0cb11c2412 | ||
|
|
9b1d7047d4 | ||
|
|
703d634744 | ||
|
|
e273a31e70 | ||
|
|
277c68d8e5 | ||
|
|
10985671ad | ||
|
|
3dfbb70593 | ||
|
|
07947bab7d | ||
|
|
4eed55973f | ||
|
|
6e984378d7 | ||
|
|
4fd3141ab5 | ||
|
|
8d0a303af4 | ||
|
|
0186a85063 | ||
|
|
ed4e4843c2 | ||
|
|
a93a1b93e1 | ||
|
|
ace63b3ddb | ||
|
|
d338bd528c | ||
|
|
ea2d089db0 | ||
|
|
4226097228 | ||
|
|
e35a4131d0 | ||
|
|
0e669b6016 | ||
|
|
9163611989 | ||
|
|
d93cefd47a | ||
|
|
a580fb47d2 | ||
|
|
9d3c81a683 | ||
|
|
86e545a23e | ||
|
|
b0afdf6ea4 | ||
|
|
d8c25bfeb4 | ||
|
|
160ba295a8 | ||
|
|
16332a8583 | ||
|
|
ae6e85b2a4 | ||
|
|
fdad823edc | ||
|
|
5cc1d6097e | ||
|
|
8c1af9b445 | ||
|
|
ef979ccfa8 | ||
|
|
bb30e06855 | ||
|
|
b055f973df | ||
|
|
45fa5e7199 | ||
|
|
3ebf27aab9 | ||
|
|
1d041c8861 | ||
|
|
089ab9defa | ||
|
|
f7708efa5b | ||
|
|
60807846a9 | ||
|
|
afd0716cbd | ||
|
|
920255e8c6 | ||
|
|
21e0778002 | ||
|
|
d9363da9ee | ||
|
|
9b23130ac4 | ||
|
|
62a24c2dda | ||
|
|
3a3aa300bb | ||
|
|
cf50a289db | ||
|
|
3c85cf4fac | ||
|
|
878ddc6a0a | ||
|
|
3761121728 | ||
|
|
3aaa34be1e | ||
|
|
985c2a3d15 | ||
|
|
eb553f53ac | ||
|
|
d1482e1483 | ||
|
|
45f0050372 | ||
|
|
b5c8bd3421 | ||
|
|
2bab5e8c39 | ||
|
|
85b5f5b705 | ||
|
|
460a87f359 | ||
|
|
c190f5f611 | ||
|
|
7911cb62ab | ||
|
|
839c5cda12 | ||
|
|
67c985ce82 | ||
|
|
575f2cf2a5 | ||
|
|
933a491ade | ||
|
|
3b6b3e6fc8 | ||
|
|
8631d6c01d | ||
|
|
68bb8ce1da | ||
|
|
306fc77076 | ||
|
|
759ec104b6 | ||
|
|
ef205c3660 | ||
|
|
df3203d2dd | ||
|
|
ed439b2057 | ||
|
|
cd775a2862 | ||
|
|
b020758446 | ||
|
|
179c40749d | ||
|
|
1e25df21a2 | ||
|
|
ffc000de8e | ||
|
|
0b9e929f68 | ||
|
|
d0dcffefa7 | ||
|
|
7d46872775 | ||
|
|
afb04ed5d4 | ||
|
|
6d95f0d14c | ||
|
|
d018903887 | ||
|
|
d30e917385 | ||
|
|
72c09e1dcc | ||
|
|
bc1fd0633d | ||
|
|
88e2eb5416 | ||
|
|
b8848cfae1 | ||
|
|
4f51c0912d | ||
|
|
1c71604e0a | ||
|
|
e242fe19e4 | ||
|
|
f991a6c0b6 | ||
|
|
b1764b2ffd | ||
|
|
ebe5a2b74a | ||
|
|
9f20e0d14b | ||
|
|
ebb907d646 | ||
|
|
b8ee882126 | ||
|
|
693127d382 | ||
|
|
0d90a22f90 | ||
|
|
34ebe814dd | ||
|
|
1fb6c0b5b3 | ||
|
|
98aeb60a7f | ||
|
|
1608565c80 | ||
|
|
b06afd657d | ||
|
|
dd296f7033 | ||
|
|
fb7b2f6b4d | ||
|
|
e0f1c3c20e | ||
|
|
dec304a273 | ||
|
|
c9719dff72 | ||
|
|
7f95cc64c5 | ||
|
|
b525c03d20 | ||
|
|
8da5fd0a66 | ||
|
|
0303c29e3f | ||
|
|
adb0c4d4f9 | ||
|
|
991496a753 | ||
|
|
76db218674 | ||
|
|
29671c1397 | ||
|
|
f66624fe6e | ||
|
|
d475fd6137 | ||
|
|
93eee0daf4 | ||
|
|
445e0d7676 | ||
|
|
4018c863e3 | ||
|
|
a8f2884521 | ||
|
|
c0814da785 | ||
|
|
20dcff1e2e | ||
|
|
11dd281c92 | ||
|
|
548608b7ad | ||
|
|
4e0f509e7b | ||
|
|
ff3b174c42 | ||
|
|
70303d0b42 | ||
|
|
7ccf223c84 | ||
|
|
e9b9a62fe4 | ||
|
|
81c623f26e | ||
|
|
3696d1ded1 | ||
|
|
50f208d69f | ||
|
|
958320f9c1 | ||
|
|
d1ee4c8dca | ||
|
|
ac018e3a35 | ||
|
|
e6e9c15d34 | ||
|
|
aaee5fb680 | ||
|
|
ff0abacf4b | ||
|
|
0771e3a8be | ||
|
|
da952135ca | ||
|
|
789705ea96 | ||
|
|
ba54cee55e | ||
|
|
847e06f9e1 | ||
|
|
2db618dea3 | ||
|
|
ecab692ca1 | ||
|
|
59a323e9a8 | ||
|
|
658bf6fa58 | ||
|
|
a82ca86008 | ||
|
|
ed472d8a67 | ||
|
|
ff4414bb15 | ||
|
|
56ad2db020 | ||
|
|
ae811ad8d2 | ||
|
|
85df106713 | ||
|
|
892bb75265 | ||
|
|
a115565054 | ||
|
|
d82d22b2d7 | ||
|
|
d723147083 | ||
|
|
9f9f0fb8eb | ||
|
|
ecb274273a | ||
|
|
5f421883a8 | ||
|
|
fa97475ee8 | ||
|
|
0eaeb4588e | ||
|
|
1413d77b1f | ||
|
|
624dd94b5d | ||
|
|
d86f24b6b3 | ||
|
|
03de51bd3c | ||
|
|
8f9742d988 | ||
|
|
f6e7aefa72 | ||
|
|
e269788a8f | ||
|
|
66780195dc | ||
|
|
ad2087094d | ||
|
|
5bdf1c4b96 | ||
|
|
135f8ffb2a | ||
|
|
bf5a01edd9 | ||
|
|
81ca2df6ad | ||
|
|
aea68c386a | ||
|
|
8eea53a41e | ||
|
|
3befd0c6c5 |
@@ -8,6 +8,7 @@
|
|||||||
# - Denounce with minus prefix: -username or -platform:username.
|
# - Denounce with minus prefix: -username or -platform:username.
|
||||||
# - Optional details after a space following the handle.
|
# - Optional details after a space following the handle.
|
||||||
adamdotdevin
|
adamdotdevin
|
||||||
|
ariane-emory
|
||||||
-florianleibert
|
-florianleibert
|
||||||
fwang
|
fwang
|
||||||
iamdavidhill
|
iamdavidhill
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ runs:
|
|||||||
using: "composite"
|
using: "composite"
|
||||||
steps:
|
steps:
|
||||||
- name: Mount Bun Cache
|
- name: Mount Bun Cache
|
||||||
|
if: ${{ runner.os == 'Linux' }}
|
||||||
uses: useblacksmith/stickydisk@v1
|
uses: useblacksmith/stickydisk@v1
|
||||||
with:
|
with:
|
||||||
key: ${{ github.repository }}-bun-cache-${{ runner.os }}
|
key: ${{ github.repository }}-bun-cache-${{ runner.os }}
|
||||||
|
|||||||
@@ -60,9 +60,11 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
COMMENT=$(bun script/duplicate-pr.ts -f pr_info.txt "Check the attached file for PR details and search for duplicates")
|
COMMENT=$(bun script/duplicate-pr.ts -f pr_info.txt "Check the attached file for PR details and search for duplicates")
|
||||||
|
|
||||||
gh pr comment "$PR_NUMBER" --body "_The following comment was made by an LLM, it may be inaccurate:_
|
if [ "$COMMENT" != "No duplicate PRs found" ]; then
|
||||||
|
gh pr comment "$PR_NUMBER" --body "_The following comment was made by an LLM, it may be inaccurate:_
|
||||||
|
|
||||||
$COMMENT"
|
$COMMENT"
|
||||||
|
fi
|
||||||
|
|
||||||
add-contributor-label:
|
add-contributor-label:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ jobs:
|
|||||||
if: contains(matrix.settings.host, 'ubuntu')
|
if: contains(matrix.settings.host, 'ubuntu')
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: /var/cache/apt/archives
|
path: ~/apt-cache
|
||||||
key: ${{ runner.os }}-${{ matrix.settings.target }}-apt-${{ hashFiles('.github/workflows/publish.yml') }}
|
key: ${{ runner.os }}-${{ matrix.settings.target }}-apt-${{ hashFiles('.github/workflows/publish.yml') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-${{ matrix.settings.target }}-apt-
|
${{ runner.os }}-${{ matrix.settings.target }}-apt-
|
||||||
@@ -145,8 +145,10 @@ jobs:
|
|||||||
- name: install dependencies (ubuntu only)
|
- name: install dependencies (ubuntu only)
|
||||||
if: contains(matrix.settings.host, 'ubuntu')
|
if: contains(matrix.settings.host, 'ubuntu')
|
||||||
run: |
|
run: |
|
||||||
|
mkdir -p ~/apt-cache && chmod -R a+rw ~/apt-cache
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
sudo apt-get install -y --no-install-recommends -o dir::cache::archives="$HOME/apt-cache" libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||||
|
sudo chmod -R a+rw ~/apt-cache
|
||||||
|
|
||||||
- name: install Rust stable
|
- name: install Rust stable
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
@@ -169,13 +171,23 @@ jobs:
|
|||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||||
|
|
||||||
|
- name: Resolve tauri portable SHA
|
||||||
|
if: contains(matrix.settings.host, 'ubuntu')
|
||||||
|
run: echo "TAURI_PORTABLE_SHA=$(git ls-remote https://github.com/tauri-apps/tauri.git refs/heads/feat/truly-portable-appimage | cut -f1)" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
# Fixes AppImage build issues, can be removed when https://github.com/tauri-apps/tauri/pull/12491 is released
|
# Fixes AppImage build issues, can be removed when https://github.com/tauri-apps/tauri/pull/12491 is released
|
||||||
- name: Install tauri-cli from portable appimage branch
|
- name: Install tauri-cli from portable appimage branch
|
||||||
|
uses: taiki-e/cache-cargo-install-action@v3
|
||||||
if: contains(matrix.settings.host, 'ubuntu')
|
if: contains(matrix.settings.host, 'ubuntu')
|
||||||
run: |
|
with:
|
||||||
cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch feat/truly-portable-appimage --force
|
tool: tauri-cli
|
||||||
echo "Installed tauri-cli version:"
|
git: https://github.com/tauri-apps/tauri
|
||||||
cargo tauri --version
|
# branch: feat/truly-portable-appimage
|
||||||
|
rev: ${{ env.TAURI_PORTABLE_SHA }}
|
||||||
|
|
||||||
|
- name: Show tauri-cli version
|
||||||
|
if: contains(matrix.settings.host, 'ubuntu')
|
||||||
|
run: cargo tauri --version
|
||||||
|
|
||||||
- name: Build and upload artifacts
|
- name: Build and upload artifacts
|
||||||
uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a
|
uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
name: sign-cli
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- brendan/desktop-signpath
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
actions: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sign-cli:
|
||||||
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
|
if: github.repository == 'anomalyco/opencode'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-tags: true
|
||||||
|
|
||||||
|
- uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: |
|
||||||
|
./packages/opencode/script/build.ts
|
||||||
|
|
||||||
|
- name: Upload unsigned Windows CLI
|
||||||
|
id: upload_unsigned_windows_cli
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: unsigned-opencode-windows-cli
|
||||||
|
path: packages/opencode/dist/opencode-windows-x64/bin/opencode.exe
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Submit SignPath signing request
|
||||||
|
id: submit_signpath_signing_request
|
||||||
|
uses: signpath/github-action-submit-signing-request@v1
|
||||||
|
with:
|
||||||
|
api-token: ${{ secrets.SIGNPATH_API_KEY }}
|
||||||
|
organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }}
|
||||||
|
project-slug: ${{ secrets.SIGNPATH_PROJECT_SLUG }}
|
||||||
|
signing-policy-slug: ${{ secrets.SIGNPATH_SIGNING_POLICY_SLUG }}
|
||||||
|
artifact-configuration-slug: ${{ secrets.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }}
|
||||||
|
github-artifact-id: ${{ steps.upload_unsigned_windows_cli.outputs.artifact-id }}
|
||||||
|
wait-for-completion: true
|
||||||
|
output-artifact-directory: signed-opencode-cli
|
||||||
|
|
||||||
|
- name: Upload signed Windows CLI
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: signed-opencode-windows-cli
|
||||||
|
path: signed-opencode-cli/*.exe
|
||||||
|
if-no-files-found: error
|
||||||
@@ -359,6 +359,7 @@ opencode serve --hostname 0.0.0.0 --port 4096
|
|||||||
opencode serve [--port <number>] [--hostname <string>] [--cors <origin>]
|
opencode serve [--port <number>] [--hostname <string>] [--cors <origin>]
|
||||||
opencode session [command]
|
opencode session [command]
|
||||||
opencode session list
|
opencode session list
|
||||||
|
opencode session delete <sessionID>
|
||||||
opencode stats
|
opencode stats
|
||||||
opencode uninstall
|
opencode uninstall
|
||||||
opencode upgrade
|
opencode upgrade
|
||||||
@@ -598,6 +599,7 @@ OPENCODE_EXPERIMENTAL_MARKDOWN
|
|||||||
OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX
|
OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX
|
||||||
OPENCODE_EXPERIMENTAL_OXFMT
|
OPENCODE_EXPERIMENTAL_OXFMT
|
||||||
OPENCODE_EXPERIMENTAL_PLAN_MODE
|
OPENCODE_EXPERIMENTAL_PLAN_MODE
|
||||||
|
OPENCODE_ENABLE_QUESTION_TOOL
|
||||||
OPENCODE_FAKE_VCS
|
OPENCODE_FAKE_VCS
|
||||||
OPENCODE_GIT_BASH_PATH
|
OPENCODE_GIT_BASH_PATH
|
||||||
OPENCODE_MODEL
|
OPENCODE_MODEL
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
mode: primary
|
mode: primary
|
||||||
hidden: true
|
hidden: true
|
||||||
model: opencode/claude-haiku-4-5
|
model: opencode/minimax-m2.5
|
||||||
color: "#44BA81"
|
color: "#44BA81"
|
||||||
tools:
|
tools:
|
||||||
"*": false
|
"*": false
|
||||||
@@ -12,6 +12,8 @@ You are a triage agent responsible for triaging github issues.
|
|||||||
|
|
||||||
Use your github-triage tool to triage issues.
|
Use your github-triage tool to triage issues.
|
||||||
|
|
||||||
|
This file is the source of truth for ownership/routing rules.
|
||||||
|
|
||||||
## Labels
|
## Labels
|
||||||
|
|
||||||
### windows
|
### windows
|
||||||
@@ -43,12 +45,34 @@ Desktop app issues:
|
|||||||
|
|
||||||
**Only** add if the issue explicitly mentions nix.
|
**Only** add if the issue explicitly mentions nix.
|
||||||
|
|
||||||
|
If the issue does not mention nix, do not add nix.
|
||||||
|
|
||||||
|
If the issue mentions nix, assign to `rekram1-node`.
|
||||||
|
|
||||||
#### zen
|
#### zen
|
||||||
|
|
||||||
**Only** add if the issue mentions "zen" or "opencode zen" or "opencode black".
|
**Only** add if the issue mentions "zen" or "opencode zen" or "opencode black".
|
||||||
|
|
||||||
If the issue doesn't have "zen" or "opencode black" in it then don't add zen label
|
If the issue doesn't have "zen" or "opencode black" in it then don't add zen label
|
||||||
|
|
||||||
|
#### core
|
||||||
|
|
||||||
|
Use for core server issues in `packages/opencode/`, excluding `packages/opencode/src/cli/cmd/tui/`.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- LSP server behavior
|
||||||
|
- Harness behavior (agent + tools)
|
||||||
|
- Feature requests for server behavior
|
||||||
|
- Agent context construction
|
||||||
|
- API endpoints
|
||||||
|
- Provider integration issues
|
||||||
|
- New, broken, or poor-quality models
|
||||||
|
|
||||||
|
#### acp
|
||||||
|
|
||||||
|
If the issue mentions acp support, assign acp label.
|
||||||
|
|
||||||
#### docs
|
#### docs
|
||||||
|
|
||||||
Add if the issue requests better documentation or docs updates.
|
Add if the issue requests better documentation or docs updates.
|
||||||
@@ -66,13 +90,51 @@ TUI issues potentially caused by our underlying TUI library:
|
|||||||
|
|
||||||
When assigning to people here are the following rules:
|
When assigning to people here are the following rules:
|
||||||
|
|
||||||
adamdotdev:
|
Desktop / Web:
|
||||||
ONLY assign adam if the issue will have the "desktop" label.
|
Use for desktop-labeled issues only.
|
||||||
|
|
||||||
fwang:
|
- adamdotdevin
|
||||||
ONLY assign fwang if the issue will have the "zen" label.
|
- iamdavidhill
|
||||||
|
- Brendonovich
|
||||||
|
- nexxeln
|
||||||
|
|
||||||
jayair:
|
Zen:
|
||||||
ONLY assign jayair if the issue will have the "docs" label.
|
ONLY assign if the issue will have the "zen" label.
|
||||||
|
|
||||||
In all other cases use best judgment. Avoid assigning to kommander needlessly, when in doubt assign to rekram1-node.
|
- fwang
|
||||||
|
- MrMushrooooom
|
||||||
|
|
||||||
|
TUI (`packages/opencode/src/cli/cmd/tui/...`):
|
||||||
|
|
||||||
|
- thdxr for TUI UX/UI product decisions and interaction flow
|
||||||
|
- kommander for OpenTUI engine issues: rendering artifacts, keybind handling, terminal compatibility, SSH behavior, and low-level perf bottlenecks
|
||||||
|
- rekram1-node for TUI bugs that are not clearly OpenTUI engine issues
|
||||||
|
|
||||||
|
Core (`packages/opencode/...`, excluding TUI subtree):
|
||||||
|
|
||||||
|
- thdxr for sqlite/snapshot/memory bugs and larger architectural core features
|
||||||
|
- jlongster for opencode server + API feature work (tool currently remaps jlongster -> thdxr until assignable)
|
||||||
|
- rekram1-node for harness issues, provider issues, and other bug-squashing
|
||||||
|
|
||||||
|
For core bugs that do not clearly map, either thdxr or rekram1-node is acceptable.
|
||||||
|
|
||||||
|
Docs:
|
||||||
|
|
||||||
|
- R44VC0RP
|
||||||
|
|
||||||
|
Windows:
|
||||||
|
|
||||||
|
- Hona (assign any issue that mentions Windows or is likely Windows-specific)
|
||||||
|
|
||||||
|
Determinism rules:
|
||||||
|
|
||||||
|
- If title + body does not contain "zen", do not add the "zen" label
|
||||||
|
- If "nix" label is added but title + body does not mention nix/nixos, the tool will drop "nix"
|
||||||
|
- If title + body mentions nix/nixos, assign to `rekram1-node`
|
||||||
|
- If "desktop" label is added, the tool will override assignee and randomly pick one Desktop / Web owner
|
||||||
|
|
||||||
|
In all other cases, choose the team/section with the most overlap with the issue and assign a member from that team at random.
|
||||||
|
|
||||||
|
ACP:
|
||||||
|
|
||||||
|
- rekram1-node (assign any acp issues to rekram1-node)
|
||||||
|
|||||||
@@ -16,15 +16,12 @@ wip:
|
|||||||
|
|
||||||
For anything in the packages/web use the docs: prefix.
|
For anything in the packages/web use the docs: prefix.
|
||||||
|
|
||||||
For anything in the packages/app use the ignore: prefix.
|
|
||||||
|
|
||||||
prefer to explain WHY something was done from an end user perspective instead of
|
prefer to explain WHY something was done from an end user perspective instead of
|
||||||
WHAT was done.
|
WHAT was done.
|
||||||
|
|
||||||
do not do generic messages like "improved agent experience" be very specific
|
do not do generic messages like "improved agent experience" be very specific
|
||||||
about what user facing changes were made
|
about what user facing changes were made
|
||||||
|
|
||||||
if there are changes do a git pull --rebase
|
|
||||||
if there are conflicts DO NOT FIX THEM. notify me and I will fix them
|
if there are conflicts DO NOT FIX THEM. notify me and I will fix them
|
||||||
|
|
||||||
## GIT DIFF
|
## GIT DIFF
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
// "enterprise": {
|
|
||||||
// "url": "https://enterprise.dev.opencode.ai",
|
|
||||||
// },
|
|
||||||
"provider": {
|
"provider": {
|
||||||
"opencode": {
|
"opencode": {
|
||||||
"options": {},
|
"options": {},
|
||||||
|
|||||||
@@ -1,8 +1,22 @@
|
|||||||
/// <reference path="../env.d.ts" />
|
/// <reference path="../env.d.ts" />
|
||||||
// import { Octokit } from "@octokit/rest"
|
|
||||||
import { tool } from "@opencode-ai/plugin"
|
import { tool } from "@opencode-ai/plugin"
|
||||||
import DESCRIPTION from "./github-triage.txt"
|
import DESCRIPTION from "./github-triage.txt"
|
||||||
|
|
||||||
|
const TEAM = {
|
||||||
|
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
|
||||||
|
zen: ["fwang", "MrMushrooooom"],
|
||||||
|
tui: ["thdxr", "kommander", "rekram1-node"],
|
||||||
|
core: ["thdxr", "rekram1-node", "jlongster"],
|
||||||
|
docs: ["R44VC0RP"],
|
||||||
|
windows: ["Hona"],
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const ASSIGNEES = [...new Set(Object.values(TEAM).flat())]
|
||||||
|
|
||||||
|
function pick<T>(items: readonly T[]) {
|
||||||
|
return items[Math.floor(Math.random() * items.length)]!
|
||||||
|
}
|
||||||
|
|
||||||
function getIssueNumber(): number {
|
function getIssueNumber(): number {
|
||||||
const issue = parseInt(process.env.ISSUE_NUMBER ?? "", 10)
|
const issue = parseInt(process.env.ISSUE_NUMBER ?? "", 10)
|
||||||
if (!issue) throw new Error("ISSUE_NUMBER env var not set")
|
if (!issue) throw new Error("ISSUE_NUMBER env var not set")
|
||||||
@@ -29,60 +43,69 @@ export default tool({
|
|||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
args: {
|
args: {
|
||||||
assignee: tool.schema
|
assignee: tool.schema
|
||||||
.enum(["thdxr", "adamdotdevin", "rekram1-node", "fwang", "jayair", "kommander"])
|
.enum(ASSIGNEES as [string, ...string[]])
|
||||||
.describe("The username of the assignee")
|
.describe("The username of the assignee")
|
||||||
.default("rekram1-node"),
|
.default("rekram1-node"),
|
||||||
labels: tool.schema
|
labels: tool.schema
|
||||||
.array(tool.schema.enum(["nix", "opentui", "perf", "desktop", "zen", "docs", "windows"]))
|
.array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"]))
|
||||||
.describe("The labels(s) to add to the issue")
|
.describe("The labels(s) to add to the issue")
|
||||||
.default([]),
|
.default([]),
|
||||||
},
|
},
|
||||||
async execute(args) {
|
async execute(args) {
|
||||||
const issue = getIssueNumber()
|
const issue = getIssueNumber()
|
||||||
// const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN })
|
|
||||||
const owner = "anomalyco"
|
const owner = "anomalyco"
|
||||||
const repo = "opencode"
|
const repo = "opencode"
|
||||||
|
|
||||||
const results: string[] = []
|
const results: string[] = []
|
||||||
|
let labels = [...new Set(args.labels.map((x) => (x === "desktop" ? "web" : x)))]
|
||||||
|
const web = labels.includes("web")
|
||||||
|
const text = `${process.env.ISSUE_TITLE ?? ""}\n${process.env.ISSUE_BODY ?? ""}`.toLowerCase()
|
||||||
|
const zen = /\bzen\b/.test(text) || text.includes("opencode black")
|
||||||
|
const nix = /\bnix(os)?\b/.test(text)
|
||||||
|
|
||||||
if (args.assignee === "adamdotdevin" && !args.labels.includes("desktop")) {
|
if (labels.includes("nix") && !nix) {
|
||||||
throw new Error("Only desktop issues should be assigned to adamdotdevin")
|
labels = labels.filter((x) => x !== "nix")
|
||||||
|
results.push("Dropped label: nix (issue does not mention nix)")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.assignee === "fwang" && !args.labels.includes("zen")) {
|
const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
|
||||||
throw new Error("Only zen issues should be assigned to fwang")
|
|
||||||
|
if (labels.includes("zen") && !zen) {
|
||||||
|
throw new Error("Only add the zen label when issue title/body contains 'zen'")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.assignee === "kommander" && !args.labels.includes("opentui")) {
|
if (web && !nix && !(TEAM.desktop as readonly string[]).includes(assignee)) {
|
||||||
|
throw new Error("Web issues must be assigned to adamdotdevin, iamdavidhill, Brendonovich, or nexxeln")
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((TEAM.zen as readonly string[]).includes(assignee) && !labels.includes("zen")) {
|
||||||
|
throw new Error("Only zen issues should be assigned to fwang or MrMushrooooom")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assignee === "Hona" && !labels.includes("windows")) {
|
||||||
|
throw new Error("Only windows issues should be assigned to Hona")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assignee === "R44VC0RP" && !labels.includes("docs")) {
|
||||||
|
throw new Error("Only docs issues should be assigned to R44VC0RP")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assignee === "kommander" && !labels.includes("opentui")) {
|
||||||
throw new Error("Only opentui issues should be assigned to kommander")
|
throw new Error("Only opentui issues should be assigned to kommander")
|
||||||
}
|
}
|
||||||
|
|
||||||
// await octokit.rest.issues.addAssignees({
|
|
||||||
// owner,
|
|
||||||
// repo,
|
|
||||||
// issue_number: issue,
|
|
||||||
// assignees: [args.assignee],
|
|
||||||
// })
|
|
||||||
await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/assignees`, {
|
await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/assignees`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ assignees: [args.assignee] }),
|
body: JSON.stringify({ assignees: [assignee] }),
|
||||||
})
|
})
|
||||||
results.push(`Assigned @${args.assignee} to issue #${issue}`)
|
results.push(`Assigned @${assignee} to issue #${issue}`)
|
||||||
|
|
||||||
const labels: string[] = args.labels.map((label) => (label === "desktop" ? "web" : label))
|
|
||||||
|
|
||||||
if (labels.length > 0) {
|
if (labels.length > 0) {
|
||||||
// await octokit.rest.issues.addLabels({
|
|
||||||
// owner,
|
|
||||||
// repo,
|
|
||||||
// issue_number: issue,
|
|
||||||
// labels,
|
|
||||||
// })
|
|
||||||
await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/labels`, {
|
await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/labels`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ labels }),
|
body: JSON.stringify({ labels }),
|
||||||
})
|
})
|
||||||
results.push(`Added labels: ${args.labels.join(", ")}`)
|
results.push(`Added labels: ${labels.join(", ")}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return results.join("\n")
|
return results.join("\n")
|
||||||
|
|||||||
@@ -1,88 +1,6 @@
|
|||||||
Use this tool to assign and/or label a GitHub issue.
|
Use this tool to assign and/or label a GitHub issue.
|
||||||
|
|
||||||
You can assign the following users:
|
Choose labels and assignee using the current triage policy and ownership rules.
|
||||||
- thdxr
|
Pick the most fitting labels for the issue and assign one owner.
|
||||||
- adamdotdevin
|
|
||||||
- fwang
|
|
||||||
- jayair
|
|
||||||
- kommander
|
|
||||||
- rekram1-node
|
|
||||||
|
|
||||||
|
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.
|
||||||
You can use the following labels:
|
|
||||||
- nix
|
|
||||||
- opentui
|
|
||||||
- perf
|
|
||||||
- web
|
|
||||||
- zen
|
|
||||||
- docs
|
|
||||||
|
|
||||||
Always try to assign an issue, if in doubt, assign rekram1-node to it.
|
|
||||||
|
|
||||||
## Breakdown of responsibilities:
|
|
||||||
|
|
||||||
### thdxr
|
|
||||||
|
|
||||||
Dax is responsible for managing core parts of the application, for large feature requests, api changes, or things that require significant changes to the codebase assign him.
|
|
||||||
|
|
||||||
This relates to OpenCode server primarily but has overlap with just about anything
|
|
||||||
|
|
||||||
### adamdotdevin
|
|
||||||
|
|
||||||
Adam is responsible for managing the Desktop/Web app. If there is an issue relating to the desktop app or `opencode web` command. Assign him.
|
|
||||||
|
|
||||||
|
|
||||||
### fwang
|
|
||||||
|
|
||||||
Frank is responsible for managing Zen, if you see complaints about OpenCode Zen, maybe it's the dashboard, the model quality, billing issues, etc. Assign him to the issue.
|
|
||||||
|
|
||||||
### jayair
|
|
||||||
|
|
||||||
Jay is responsible for documentation. If there is an issue relating to documentation assign him.
|
|
||||||
|
|
||||||
### kommander
|
|
||||||
|
|
||||||
Sebastian is responsible for managing an OpenTUI (a library for building terminal user interfaces). OpenCode's TUI is built with OpenTUI. If there are issues about:
|
|
||||||
- random characters on screen
|
|
||||||
- keybinds not working on different terminals
|
|
||||||
- general terminal stuff
|
|
||||||
Then assign the issue to Him.
|
|
||||||
|
|
||||||
### rekram1-node
|
|
||||||
|
|
||||||
ALL BUGS SHOULD BE assigned to rekram1-node unless they have the `opentui` label.
|
|
||||||
|
|
||||||
Assign Aiden to an issue as a catch all, if you can't assign anyone else. Most of the time this will be bugs/polish things.
|
|
||||||
If no one else makes sense to assign, assign rekram1-node to it.
|
|
||||||
|
|
||||||
Always assign to aiden if the issue mentions "acp", "zed", or model performance issues
|
|
||||||
|
|
||||||
## Breakdown of Labels:
|
|
||||||
|
|
||||||
### nix
|
|
||||||
|
|
||||||
Any issue that mentions nix, or nixos should have a nix label
|
|
||||||
|
|
||||||
### opentui
|
|
||||||
|
|
||||||
Anything relating to the TUI itself should have an opentui label
|
|
||||||
|
|
||||||
### perf
|
|
||||||
|
|
||||||
Anything related to slow performance, high ram, high cpu usage, or any other performance related issue should have a perf label
|
|
||||||
|
|
||||||
### desktop
|
|
||||||
|
|
||||||
Anything related to `opencode web` command or the desktop app should have a desktop label. Never add this label for anything terminal/tui related
|
|
||||||
|
|
||||||
### zen
|
|
||||||
|
|
||||||
Anything related to OpenCode Zen, billing, or model quality from Zen should have a zen label
|
|
||||||
|
|
||||||
### docs
|
|
||||||
|
|
||||||
Anything related to the documentation should have a docs label
|
|
||||||
|
|
||||||
### windows
|
|
||||||
|
|
||||||
Use for any issue that involves the windows OS
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
github-policies:
|
||||||
|
runners:
|
||||||
|
allowed_groups:
|
||||||
|
- "GitHub Actions"
|
||||||
|
- "blacksmith runners 01kbd5v56sg8tz7rea39b7ygpt"
|
||||||
@@ -110,3 +110,4 @@ const table = sqliteTable("session", {
|
|||||||
|
|
||||||
- Avoid mocks as much as possible
|
- Avoid mocks as much as possible
|
||||||
- Test actual implementation, do not duplicate logic into tests
|
- Test actual implementation, do not duplicate logic into tests
|
||||||
|
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
|
||||||
|
|||||||
+4
-2
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -50,7 +51,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS و Linux (موصى به، دائما محدث)
|
brew install anomalyco/tap/opencode # macOS و Linux (موصى به، دائما محدث)
|
||||||
brew install opencode # macOS و Linux (صيغة brew الرسمية، تحديث اقل)
|
brew install opencode # macOS و Linux (صيغة brew الرسمية، تحديث اقل)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # اي نظام
|
mise use -g opencode # اي نظام
|
||||||
nix run nixpkgs#opencode # او github:anomalyco/opencode لاحدث فرع dev
|
nix run nixpkgs#opencode # او github:anomalyco/opencode لاحدث فرع dev
|
||||||
```
|
```
|
||||||
|
|||||||
+4
-2
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -50,7 +51,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS e Linux (recomendado, sempre atualizado)
|
brew install anomalyco/tap/opencode # macOS e Linux (recomendado, sempre atualizado)
|
||||||
brew install opencode # macOS e Linux (fórmula oficial do brew, atualiza menos)
|
brew install opencode # macOS e Linux (fórmula oficial do brew, atualiza menos)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # qualquer sistema
|
mise use -g opencode # qualquer sistema
|
||||||
nix run nixpkgs#opencode # ou github:anomalyco/opencode para a branch dev mais recente
|
nix run nixpkgs#opencode # ou github:anomalyco/opencode para a branch dev mais recente
|
||||||
```
|
```
|
||||||
|
|||||||
+4
-2
@@ -32,7 +32,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -51,7 +52,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS i Linux (preporučeno, uvijek ažurno)
|
brew install anomalyco/tap/opencode # macOS i Linux (preporučeno, uvijek ažurno)
|
||||||
brew install opencode # macOS i Linux (zvanična brew formula, rjeđe se ažurira)
|
brew install opencode # macOS i Linux (zvanična brew formula, rjeđe se ažurira)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # Bilo koji OS
|
mise use -g opencode # Bilo koji OS
|
||||||
nix run nixpkgs#opencode # ili github:anomalyco/opencode za najnoviji dev branch
|
nix run nixpkgs#opencode # ili github:anomalyco/opencode za najnoviji dev branch
|
||||||
```
|
```
|
||||||
|
|||||||
+4
-2
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -50,7 +51,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS og Linux (anbefalet, altid up to date)
|
brew install anomalyco/tap/opencode # macOS og Linux (anbefalet, altid up to date)
|
||||||
brew install opencode # macOS og Linux (officiel brew formula, opdateres sjældnere)
|
brew install opencode # macOS og Linux (officiel brew formula, opdateres sjældnere)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # alle OS
|
mise use -g opencode # alle OS
|
||||||
nix run nixpkgs#opencode # eller github:anomalyco/opencode for nyeste dev-branch
|
nix run nixpkgs#opencode # eller github:anomalyco/opencode for nyeste dev-branch
|
||||||
```
|
```
|
||||||
|
|||||||
+4
-2
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -50,7 +51,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS und Linux (empfohlen, immer aktuell)
|
brew install anomalyco/tap/opencode # macOS und Linux (empfohlen, immer aktuell)
|
||||||
brew install opencode # macOS und Linux (offizielle Brew-Formula, seltener aktualisiert)
|
brew install opencode # macOS und Linux (offizielle Brew-Formula, seltener aktualisiert)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # jedes Betriebssystem
|
mise use -g opencode # jedes Betriebssystem
|
||||||
nix run nixpkgs#opencode # oder github:anomalyco/opencode für den neuesten dev-Branch
|
nix run nixpkgs#opencode # oder github:anomalyco/opencode für den neuesten dev-Branch
|
||||||
```
|
```
|
||||||
|
|||||||
+4
-2
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -50,7 +51,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS y Linux (recomendado, siempre al día)
|
brew install anomalyco/tap/opencode # macOS y Linux (recomendado, siempre al día)
|
||||||
brew install opencode # macOS y Linux (fórmula oficial de brew, se actualiza menos)
|
brew install opencode # macOS y Linux (fórmula oficial de brew, se actualiza menos)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # cualquier sistema
|
mise use -g opencode # cualquier sistema
|
||||||
nix run nixpkgs#opencode # o github:anomalyco/opencode para la rama dev más reciente
|
nix run nixpkgs#opencode # o github:anomalyco/opencode para la rama dev más reciente
|
||||||
```
|
```
|
||||||
|
|||||||
+4
-2
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -50,7 +51,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS et Linux (recommandé, toujours à jour)
|
brew install anomalyco/tap/opencode # macOS et Linux (recommandé, toujours à jour)
|
||||||
brew install opencode # macOS et Linux (formule officielle brew, mise à jour moins fréquente)
|
brew install opencode # macOS et Linux (formule officielle brew, mise à jour moins fréquente)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # n'importe quel OS
|
mise use -g opencode # n'importe quel OS
|
||||||
nix run nixpkgs#opencode # ou github:anomalyco/opencode pour la branche dev la plus récente
|
nix run nixpkgs#opencode # ou github:anomalyco/opencode pour la branche dev la plus récente
|
||||||
```
|
```
|
||||||
|
|||||||
+4
-2
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -50,7 +51,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS e Linux (consigliato, sempre aggiornato)
|
brew install anomalyco/tap/opencode # macOS e Linux (consigliato, sempre aggiornato)
|
||||||
brew install opencode # macOS e Linux (formula brew ufficiale, aggiornata meno spesso)
|
brew install opencode # macOS e Linux (formula brew ufficiale, aggiornata meno spesso)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # Qualsiasi OS
|
mise use -g opencode # Qualsiasi OS
|
||||||
nix run nixpkgs#opencode # oppure github:anomalyco/opencode per l’ultima branch di sviluppo
|
nix run nixpkgs#opencode # oppure github:anomalyco/opencode per l’ultima branch di sviluppo
|
||||||
```
|
```
|
||||||
|
|||||||
+4
-2
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -50,7 +51,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS と Linux(推奨。常に最新)
|
brew install anomalyco/tap/opencode # macOS と Linux(推奨。常に最新)
|
||||||
brew install opencode # macOS と Linux(公式 brew formula。更新頻度は低め)
|
brew install opencode # macOS と Linux(公式 brew formula。更新頻度は低め)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # どのOSでも
|
mise use -g opencode # どのOSでも
|
||||||
nix run nixpkgs#opencode # または github:anomalyco/opencode で最新 dev ブランチ
|
nix run nixpkgs#opencode # または github:anomalyco/opencode で最新 dev ブランチ
|
||||||
```
|
```
|
||||||
|
|||||||
+4
-2
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -50,7 +51,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS 및 Linux (권장, 항상 최신)
|
brew install anomalyco/tap/opencode # macOS 및 Linux (권장, 항상 최신)
|
||||||
brew install opencode # macOS 및 Linux (공식 brew formula, 업데이트 빈도 낮음)
|
brew install opencode # macOS 및 Linux (공식 brew formula, 업데이트 빈도 낮음)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # 어떤 OS든
|
mise use -g opencode # 어떤 OS든
|
||||||
nix run nixpkgs#opencode # 또는 github:anomalyco/opencode 로 최신 dev 브랜치
|
nix run nixpkgs#opencode # 또는 github:anomalyco/opencode 로 최신 dev 브랜치
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -32,7 +32,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -51,7 +52,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date)
|
brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date)
|
||||||
brew install opencode # macOS and Linux (official brew formula, updated less)
|
brew install opencode # macOS and Linux (official brew formula, updated less)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # Any OS
|
mise use -g opencode # Any OS
|
||||||
nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch
|
nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch
|
||||||
```
|
```
|
||||||
|
|||||||
+4
-2
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -50,7 +51,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS og Linux (anbefalt, alltid oppdatert)
|
brew install anomalyco/tap/opencode # macOS og Linux (anbefalt, alltid oppdatert)
|
||||||
brew install opencode # macOS og Linux (offisiell brew-formel, oppdateres sjeldnere)
|
brew install opencode # macOS og Linux (offisiell brew-formel, oppdateres sjeldnere)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # alle OS
|
mise use -g opencode # alle OS
|
||||||
nix run nixpkgs#opencode # eller github:anomalyco/opencode for nyeste dev-branch
|
nix run nixpkgs#opencode # eller github:anomalyco/opencode for nyeste dev-branch
|
||||||
```
|
```
|
||||||
|
|||||||
+4
-2
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -50,7 +51,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS i Linux (polecane, zawsze aktualne)
|
brew install anomalyco/tap/opencode # macOS i Linux (polecane, zawsze aktualne)
|
||||||
brew install opencode # macOS i Linux (oficjalna formuła brew, rzadziej aktualizowana)
|
brew install opencode # macOS i Linux (oficjalna formuła brew, rzadziej aktualizowana)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # dowolny system
|
mise use -g opencode # dowolny system
|
||||||
nix run nixpkgs#opencode # lub github:anomalyco/opencode dla najnowszej gałęzi dev
|
nix run nixpkgs#opencode # lub github:anomalyco/opencode dla najnowszej gałęzi dev
|
||||||
```
|
```
|
||||||
|
|||||||
+4
-2
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -50,7 +51,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS и Linux (рекомендуем, всегда актуально)
|
brew install anomalyco/tap/opencode # macOS и Linux (рекомендуем, всегда актуально)
|
||||||
brew install opencode # macOS и Linux (официальная формула brew, обновляется реже)
|
brew install opencode # macOS и Linux (официальная формула brew, обновляется реже)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # любая ОС
|
mise use -g opencode # любая ОС
|
||||||
nix run nixpkgs#opencode # или github:anomalyco/opencode для самой свежей ветки dev
|
nix run nixpkgs#opencode # или github:anomalyco/opencode для самой свежей ветки dev
|
||||||
```
|
```
|
||||||
|
|||||||
+4
-2
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -50,7 +51,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS และ Linux (แนะนำ อัปเดตเสมอ)
|
brew install anomalyco/tap/opencode # macOS และ Linux (แนะนำ อัปเดตเสมอ)
|
||||||
brew install opencode # macOS และ Linux (brew formula อย่างเป็นทางการ อัปเดตน้อยกว่า)
|
brew install opencode # macOS และ Linux (brew formula อย่างเป็นทางการ อัปเดตน้อยกว่า)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # ระบบปฏิบัติการใดก็ได้
|
mise use -g opencode # ระบบปฏิบัติการใดก็ได้
|
||||||
nix run nixpkgs#opencode # หรือ github:anomalyco/opencode สำหรับสาขาพัฒนาล่าสุด
|
nix run nixpkgs#opencode # หรือ github:anomalyco/opencode สำหรับสาขาพัฒนาล่าสุด
|
||||||
```
|
```
|
||||||
|
|||||||
+4
-2
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -50,7 +51,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS ve Linux (önerilir, her zaman güncel)
|
brew install anomalyco/tap/opencode # macOS ve Linux (önerilir, her zaman güncel)
|
||||||
brew install opencode # macOS ve Linux (resmi brew formülü, daha az güncellenir)
|
brew install opencode # macOS ve Linux (resmi brew formülü, daha az güncellenir)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # Tüm işletim sistemleri
|
mise use -g opencode # Tüm işletim sistemleri
|
||||||
nix run nixpkgs#opencode # veya en güncel geliştirme dalı için github:anomalyco/opencode
|
nix run nixpkgs#opencode # veya en güncel geliştirme dalı için github:anomalyco/opencode
|
||||||
```
|
```
|
||||||
|
|||||||
+139
@@ -0,0 +1,139 @@
|
|||||||
|
<p align="center">
|
||||||
|
<a href="https://opencode.ai">
|
||||||
|
<picture>
|
||||||
|
<source srcset="packages/console/app/src/asset/logo-ornate-dark.svg" media="(prefers-color-scheme: dark)">
|
||||||
|
<source srcset="packages/console/app/src/asset/logo-ornate-light.svg" media="(prefers-color-scheme: light)">
|
||||||
|
<img src="packages/console/app/src/asset/logo-ornate-light.svg" alt="OpenCode logo">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p align="center">AI-агент для програмування з відкритим кодом.</p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
|
||||||
|
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
||||||
|
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="README.md">English</a> |
|
||||||
|
<a href="README.zh.md">简体中文</a> |
|
||||||
|
<a href="README.zht.md">繁體中文</a> |
|
||||||
|
<a href="README.ko.md">한국어</a> |
|
||||||
|
<a href="README.de.md">Deutsch</a> |
|
||||||
|
<a href="README.es.md">Español</a> |
|
||||||
|
<a href="README.fr.md">Français</a> |
|
||||||
|
<a href="README.it.md">Italiano</a> |
|
||||||
|
<a href="README.da.md">Dansk</a> |
|
||||||
|
<a href="README.ja.md">日本語</a> |
|
||||||
|
<a href="README.pl.md">Polski</a> |
|
||||||
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
|
<a href="README.ar.md">العربية</a> |
|
||||||
|
<a href="README.no.md">Norsk</a> |
|
||||||
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
|
<a href="README.th.md">ไทย</a> |
|
||||||
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
[](https://opencode.ai)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Встановлення
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# YOLO
|
||||||
|
curl -fsSL https://opencode.ai/install | bash
|
||||||
|
|
||||||
|
# Менеджери пакетів
|
||||||
|
npm i -g opencode-ai@latest # або bun/pnpm/yarn
|
||||||
|
scoop install opencode # Windows
|
||||||
|
choco install opencode # Windows
|
||||||
|
brew install anomalyco/tap/opencode # macOS і Linux (рекомендовано, завжди актуально)
|
||||||
|
brew install opencode # macOS і Linux (офіційна формула Homebrew, оновлюється рідше)
|
||||||
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
|
mise use -g opencode # Будь-яка ОС
|
||||||
|
nix run nixpkgs#opencode # або github:anomalyco/opencode для найновішої dev-гілки
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> Перед встановленням видаліть версії старші за 0.1.x.
|
||||||
|
|
||||||
|
### Десктопний застосунок (BETA)
|
||||||
|
|
||||||
|
OpenCode також доступний як десктопний застосунок. Завантажуйте напряму зі [сторінки релізів](https://github.com/anomalyco/opencode/releases) або [opencode.ai/download](https://opencode.ai/download).
|
||||||
|
|
||||||
|
| Платформа | Завантаження |
|
||||||
|
| --------------------- | ------------------------------------- |
|
||||||
|
| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
|
||||||
|
| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
|
||||||
|
| Windows | `opencode-desktop-windows-x64.exe` |
|
||||||
|
| Linux | `.deb`, `.rpm` або AppImage |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS (Homebrew)
|
||||||
|
brew install --cask opencode-desktop
|
||||||
|
# Windows (Scoop)
|
||||||
|
scoop bucket add extras; scoop install extras/opencode-desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Каталог встановлення
|
||||||
|
|
||||||
|
Скрипт встановлення дотримується такого порядку пріоритету для шляху встановлення:
|
||||||
|
|
||||||
|
1. `$OPENCODE_INSTALL_DIR` - Користувацький каталог встановлення
|
||||||
|
2. `$XDG_BIN_DIR` - Шлях, сумісний зі специфікацією XDG Base Directory
|
||||||
|
3. `$HOME/bin` - Стандартний каталог користувацьких бінарників (якщо існує або його можна створити)
|
||||||
|
4. `$HOME/.opencode/bin` - Резервний варіант за замовчуванням
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Приклади
|
||||||
|
OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash
|
||||||
|
XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
### Агенти
|
||||||
|
|
||||||
|
OpenCode містить два вбудовані агенти, між якими можна перемикатися клавішею `Tab`.
|
||||||
|
|
||||||
|
- **build** - Агент за замовчуванням із повним доступом для завдань розробки
|
||||||
|
- **plan** - Агент лише для читання для аналізу та дослідження коду
|
||||||
|
- За замовчуванням забороняє редагування файлів
|
||||||
|
- Запитує дозвіл перед запуском bash-команд
|
||||||
|
- Ідеально підходить для дослідження незнайомих кодових баз або планування змін
|
||||||
|
|
||||||
|
Також доступний допоміжний агент **general** для складного пошуку та багатокрокових завдань.
|
||||||
|
Він використовується всередині системи й може бути викликаний у повідомленнях через `@general`.
|
||||||
|
|
||||||
|
Дізнайтеся більше про [agents](https://opencode.ai/docs/agents).
|
||||||
|
|
||||||
|
### Документація
|
||||||
|
|
||||||
|
Щоб дізнатися більше про налаштування OpenCode, [**перейдіть до нашої документації**](https://opencode.ai/docs).
|
||||||
|
|
||||||
|
### Внесок
|
||||||
|
|
||||||
|
Якщо ви хочете зробити внесок в OpenCode, будь ласка, прочитайте нашу [документацію для контриб'юторів](./CONTRIBUTING.md) перед надсиланням pull request.
|
||||||
|
|
||||||
|
### Проєкти на базі OpenCode
|
||||||
|
|
||||||
|
Якщо ви працюєте над проєктом, пов'язаним з OpenCode, і використовуєте "opencode" у назві, наприклад "opencode-dashboard" або "opencode-mobile", додайте примітку до свого README.
|
||||||
|
Уточніть, що цей проєкт не створений командою OpenCode і жодним чином не афілійований із нами.
|
||||||
|
|
||||||
|
### FAQ
|
||||||
|
|
||||||
|
#### Чим це відрізняється від Claude Code?
|
||||||
|
|
||||||
|
За можливостями це дуже схоже на Claude Code. Ось ключові відмінності:
|
||||||
|
|
||||||
|
- 100% open source
|
||||||
|
- Немає прив'язки до конкретного провайдера. Ми рекомендуємо моделі, які надаємо через [OpenCode Zen](https://opencode.ai/zen), але OpenCode також працює з Claude, OpenAI, Google і навіть локальними моделями. З розвитком моделей різниця між ними зменшуватиметься, а ціни падатимуть, тому незалежність від провайдера має значення.
|
||||||
|
- Підтримка LSP з коробки
|
||||||
|
- Фокус на TUI. OpenCode створено користувачами neovim та авторами [terminal.shop](https://terminal.shop); ми й надалі розширюватимемо межі можливого в терміналі.
|
||||||
|
- Клієнт-серверна архітектура. Наприклад, це дає змогу запускати OpenCode на вашому комп'ютері й керувати ним віддалено з мобільного застосунку, тобто TUI-фронтенд - лише один із можливих клієнтів.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Приєднуйтеся до нашої спільноти** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||||
+4
-2
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -50,7 +51,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS 和 Linux(推荐,始终保持最新)
|
brew install anomalyco/tap/opencode # macOS 和 Linux(推荐,始终保持最新)
|
||||||
brew install opencode # macOS 和 Linux(官方 brew formula,更新频率较低)
|
brew install opencode # macOS 和 Linux(官方 brew formula,更新频率较低)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # 任意系统
|
mise use -g opencode # 任意系统
|
||||||
nix run nixpkgs#opencode # 或用 github:anomalyco/opencode 获取最新 dev 分支
|
nix run nixpkgs#opencode # 或用 github:anomalyco/opencode 获取最新 dev 分支
|
||||||
```
|
```
|
||||||
|
|||||||
+4
-2
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -50,7 +51,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS 與 Linux(推薦,始終保持最新)
|
brew install anomalyco/tap/opencode # macOS 與 Linux(推薦,始終保持最新)
|
||||||
brew install opencode # macOS 與 Linux(官方 brew formula,更新頻率較低)
|
brew install opencode # macOS 與 Linux(官方 brew formula,更新頻率較低)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # 任何作業系統
|
mise use -g opencode # 任何作業系統
|
||||||
nix run nixpkgs#opencode # 或使用 github:anomalyco/opencode 以取得最新開發分支
|
nix run nixpkgs#opencode # 或使用 github:anomalyco/opencode 以取得最新開發分支
|
||||||
```
|
```
|
||||||
|
|||||||
Generated
+3
-3
@@ -2,11 +2,11 @@
|
|||||||
"nodes": {
|
"nodes": {
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1770073757,
|
"lastModified": 1770812194,
|
||||||
"narHash": "sha256-Vy+G+F+3E/Tl+GMNgiHl9Pah2DgShmIUBJXmbiQPHbI=",
|
"narHash": "sha256-OH+lkaIKAvPXR3nITO7iYZwew2nW9Y7Xxq0yfM/UcUU=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "47472570b1e607482890801aeaf29bfb749884f6",
|
"rev": "8482c7ded03bae7550f3d69884f1e611e3bd19e8",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -145,6 +145,16 @@ const ZEN_MODELS = [
|
|||||||
new sst.Secret("ZEN_MODELS18"),
|
new sst.Secret("ZEN_MODELS18"),
|
||||||
new sst.Secret("ZEN_MODELS19"),
|
new sst.Secret("ZEN_MODELS19"),
|
||||||
new sst.Secret("ZEN_MODELS20"),
|
new sst.Secret("ZEN_MODELS20"),
|
||||||
|
new sst.Secret("ZEN_MODELS21"),
|
||||||
|
new sst.Secret("ZEN_MODELS22"),
|
||||||
|
new sst.Secret("ZEN_MODELS23"),
|
||||||
|
new sst.Secret("ZEN_MODELS24"),
|
||||||
|
new sst.Secret("ZEN_MODELS25"),
|
||||||
|
new sst.Secret("ZEN_MODELS26"),
|
||||||
|
new sst.Secret("ZEN_MODELS27"),
|
||||||
|
new sst.Secret("ZEN_MODELS28"),
|
||||||
|
new sst.Secret("ZEN_MODELS29"),
|
||||||
|
new sst.Secret("ZEN_MODELS30"),
|
||||||
]
|
]
|
||||||
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
|
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
|
||||||
const STRIPE_PUBLISHABLE_KEY = new sst.Secret("STRIPE_PUBLISHABLE_KEY")
|
const STRIPE_PUBLISHABLE_KEY = new sst.Secret("STRIPE_PUBLISHABLE_KEY")
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ else
|
|||||||
needs_baseline=false
|
needs_baseline=false
|
||||||
if [ "$arch" = "x64" ]; then
|
if [ "$arch" = "x64" ]; then
|
||||||
if [ "$os" = "linux" ]; then
|
if [ "$os" = "linux" ]; then
|
||||||
if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then
|
if ! grep -qwi avx2 /proc/cpuinfo 2>/dev/null; then
|
||||||
needs_baseline=true
|
needs_baseline=true
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
@@ -141,6 +141,20 @@ else
|
|||||||
needs_baseline=true
|
needs_baseline=true
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ "$os" = "windows" ]; then
|
||||||
|
ps="(Add-Type -MemberDefinition \"[DllImport(\"\"kernel32.dll\"\")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);\" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)"
|
||||||
|
out=""
|
||||||
|
if command -v powershell.exe >/dev/null 2>&1; then
|
||||||
|
out=$(powershell.exe -NoProfile -NonInteractive -Command "$ps" 2>/dev/null || true)
|
||||||
|
elif command -v pwsh >/dev/null 2>&1; then
|
||||||
|
out=$(pwsh -NoProfile -NonInteractive -Command "$ps" 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
out=$(echo "$out" | tr -d '\r' | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')
|
||||||
|
if [ "$out" != "true" ] && [ "$out" != "1" ]; then
|
||||||
|
needs_baseline=true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
target="$os-$arch"
|
target="$os-$arch"
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"keep": {
|
|
||||||
"days": true,
|
|
||||||
"amount": 14
|
|
||||||
},
|
|
||||||
"auditLog": "/home/thdxr/dev/projects/sst/opencode/logs/.2c5480b3b2480f80fa29b850af461dce619c0b2f-audit.json",
|
|
||||||
"files": [
|
|
||||||
{
|
|
||||||
"date": 1759827172859,
|
|
||||||
"name": "/home/thdxr/dev/projects/sst/opencode/logs/mcp-puppeteer-2025-10-07.log",
|
|
||||||
"hash": "a3d98b26edd793411b968a0d24cfeee8332138e282023c3b83ec169d55c67f16"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"hashType": "sha256"
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:52.879"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:52.880"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:56.191"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:56.192"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:59.267"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:59.268"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:20.276"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:20.277"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:30.838"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:30.839"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:42.452"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:42.452"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:46.499"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:46.500"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:02.295"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:02.295"}
|
|
||||||
{"arguments":{"url":"https://google.com"},"level":"debug","message":"Tool call received","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:37.150","tool":"puppeteer_navigate"}
|
|
||||||
{"0":"n","1":"p","2":"x","level":"info","message":"Launching browser with config:","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:37.150"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:55:08.488"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:55:08.489"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:11.815"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:11.816"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:21.934"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:21.935"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:32.544"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:32.544"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:41.154"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:41.155"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:55.426"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:55.427"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:15.715"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:15.716"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:25.063"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:25.064"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:48.567"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:48.568"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:25:08.937"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:25:08.938"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:37.120"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:37.121"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:52.490"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:52.491"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:39:25.524"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:39:25.525"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:40:57.126"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:40:57.127"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:42:24.175"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:42:24.176"}
|
|
||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-pp2gb4nxiIT3VltB6Xli2wZPH32JfnMsI+BbihyU1+E=",
|
"x86_64-linux": "sha256-C3WIEER2XgzO85wk2sp3BzQ6dknW026zslD8nKZjo2U=",
|
||||||
"aarch64-linux": "sha256-hJwxhBICZz/pbIxQsF/sIpZTlFIgLpcAyF44O8wxMdU=",
|
"aarch64-linux": "sha256-+tTJHZMZ/+8fAjI/1fUTuca8J2MZfB+5vhBoZ7jgqcE=",
|
||||||
"aarch64-darwin": "sha256-DPONXP52XOg/ApdSnLp32a+K5XCOnDGhbTUto2Rme0g=",
|
"aarch64-darwin": "sha256-vS82puFGBBToxyIBa8Zi0KLKdJYr64T6HZL2rL32mH8=",
|
||||||
"x86_64-darwin": "sha256-KX1h5LRJSgthpbOPqWlbM/sPf8cvQrdRJvxtrz/FzBQ="
|
"x86_64-darwin": "sha256-Tr8JMTCxV6WVt3dXV7iq3PNCm2Cn+RXAbU9+o7pKKV0="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-2
@@ -35,11 +35,13 @@
|
|||||||
"@tsconfig/bun": "1.0.9",
|
"@tsconfig/bun": "1.0.9",
|
||||||
"@cloudflare/workers-types": "4.20251008.0",
|
"@cloudflare/workers-types": "4.20251008.0",
|
||||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||||
"@pierre/diffs": "1.0.2",
|
"@pierre/diffs": "1.1.0-beta.13",
|
||||||
"@solid-primitives/storage": "4.3.3",
|
"@solid-primitives/storage": "4.3.3",
|
||||||
"@tailwindcss/vite": "4.1.11",
|
"@tailwindcss/vite": "4.1.11",
|
||||||
"diff": "8.0.2",
|
"diff": "8.0.2",
|
||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
|
"drizzle-kit": "1.0.0-beta.12-a5629fb",
|
||||||
|
"drizzle-orm": "1.0.0-beta.12-a5629fb",
|
||||||
"ai": "5.0.124",
|
"ai": "5.0.124",
|
||||||
"hono": "4.10.7",
|
"hono": "4.10.7",
|
||||||
"hono-openapi": "1.1.2",
|
"hono-openapi": "1.1.2",
|
||||||
@@ -101,6 +103,7 @@
|
|||||||
"@types/node": "catalog:"
|
"@types/node": "catalog:"
|
||||||
},
|
},
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch"
|
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||||
|
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
|
test("ctrl+l focuses the prompt", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
await expect(prompt).toBeVisible()
|
||||||
|
|
||||||
|
await page.locator("main").click({ position: { x: 5, y: 5 } })
|
||||||
|
await expect(prompt).not.toBeFocused()
|
||||||
|
|
||||||
|
await page.keyboard.press("Control+L")
|
||||||
|
await expect(prompt).toBeFocused()
|
||||||
|
})
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { modKey } from "../utils"
|
||||||
|
|
||||||
|
const expanded = async (el: { getAttribute: (name: string) => Promise<string | null> }) => {
|
||||||
|
const value = await el.getAttribute("aria-expanded")
|
||||||
|
if (value !== "true" && value !== "false") throw new Error(`Expected aria-expanded to be true|false, got: ${value}`)
|
||||||
|
return value === "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
test("review panel can be toggled via keybind", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const treeToggle = page.getByRole("button", { name: "Toggle file tree" }).first()
|
||||||
|
await expect(treeToggle).toBeVisible()
|
||||||
|
if (await expanded(treeToggle)) await treeToggle.click()
|
||||||
|
await expect(treeToggle).toHaveAttribute("aria-expanded", "false")
|
||||||
|
|
||||||
|
const reviewToggle = page.getByRole("button", { name: "Toggle review" }).first()
|
||||||
|
await expect(reviewToggle).toBeVisible()
|
||||||
|
if (await expanded(reviewToggle)) await reviewToggle.click()
|
||||||
|
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
|
||||||
|
await expect(page.locator("#review-panel")).toHaveCount(0)
|
||||||
|
|
||||||
|
await page.keyboard.press(`${modKey}+Shift+R`)
|
||||||
|
await expect(reviewToggle).toHaveAttribute("aria-expanded", "true")
|
||||||
|
await expect(page.locator("#review-panel")).toBeVisible()
|
||||||
|
|
||||||
|
await page.keyboard.press(`${modKey}+Shift+R`)
|
||||||
|
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
|
||||||
|
await expect(page.locator("#review-panel")).toHaveCount(0)
|
||||||
|
})
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
import { modKey } from "../utils"
|
||||||
|
|
||||||
|
test("mod+w closes the active file tab", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
await page.locator(promptSelector).click()
|
||||||
|
await page.keyboard.type("/open")
|
||||||
|
await expect(page.locator('[data-slash-id="file.open"]').first()).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
const dialog = page
|
||||||
|
.getByRole("dialog")
|
||||||
|
.filter({ has: page.getByPlaceholder(/search files/i) })
|
||||||
|
.first()
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
|
await dialog.getByRole("textbox").first().fill("package.json")
|
||||||
|
const item = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first()
|
||||||
|
await expect(item).toBeVisible({ timeout: 30_000 })
|
||||||
|
await item.click()
|
||||||
|
await expect(dialog).toHaveCount(0)
|
||||||
|
|
||||||
|
const tab = page.getByRole("tab", { name: "package.json" }).first()
|
||||||
|
await expect(tab).toBeVisible()
|
||||||
|
await tab.click()
|
||||||
|
await expect(tab).toHaveAttribute("aria-selected", "true")
|
||||||
|
|
||||||
|
await page.keyboard.press(`${modKey}+W`)
|
||||||
|
await expect(page.getByRole("tab", { name: "package.json" })).toHaveCount(0)
|
||||||
|
})
|
||||||
@@ -1,15 +1,28 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { openPalette, clickListItem } from "../actions"
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
test("can open a file tab from the search palette", async ({ page, gotoSession }) => {
|
test("can open a file tab from the search palette", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|
||||||
const dialog = await openPalette(page)
|
await page.locator(promptSelector).click()
|
||||||
|
await page.keyboard.type("/open")
|
||||||
|
|
||||||
|
const command = page.locator('[data-slash-id="file.open"]').first()
|
||||||
|
await expect(command).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
const dialog = page
|
||||||
|
.getByRole("dialog")
|
||||||
|
.filter({ has: page.getByPlaceholder(/search files/i) })
|
||||||
|
.first()
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
const input = dialog.getByRole("textbox").first()
|
const input = dialog.getByRole("textbox").first()
|
||||||
await input.fill("package.json")
|
await input.fill("package.json")
|
||||||
|
|
||||||
await clickListItem(dialog, { keyStartsWith: "file:" })
|
const item = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first()
|
||||||
|
await expect(item).toBeVisible({ timeout: 30_000 })
|
||||||
|
await item.click()
|
||||||
|
|
||||||
await expect(dialog).toHaveCount(0)
|
await expect(dialog).toHaveCount(0)
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,41 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { openPalette, clickListItem } from "../actions"
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
|
test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|
||||||
const sep = process.platform === "win32" ? "\\" : "/"
|
await page.locator(promptSelector).click()
|
||||||
const file = ["packages", "app", "package.json"].join(sep)
|
await page.keyboard.type("/open")
|
||||||
|
|
||||||
const dialog = await openPalette(page)
|
const command = page.locator('[data-slash-id="file.open"]').first()
|
||||||
|
await expect(command).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
const dialog = page
|
||||||
|
.getByRole("dialog")
|
||||||
|
.filter({ has: page.getByPlaceholder(/search files/i) })
|
||||||
|
.first()
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
const input = dialog.getByRole("textbox").first()
|
const input = dialog.getByRole("textbox").first()
|
||||||
await input.fill(file)
|
await input.fill("package.json")
|
||||||
|
|
||||||
await clickListItem(dialog, { text: /packages.*app.*package.json/ })
|
const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]')
|
||||||
|
let index = -1
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? ""))
|
||||||
|
index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, "")))
|
||||||
|
return index >= 0
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
|
const item = items.nth(index)
|
||||||
|
await expect(item).toBeVisible()
|
||||||
|
await item.click()
|
||||||
|
|
||||||
await expect(dialog).toHaveCount(0)
|
await expect(dialog).toHaveCount(0)
|
||||||
|
|
||||||
@@ -22,5 +45,5 @@ test("smoke file viewer renders real file content", async ({ page, gotoSession }
|
|||||||
|
|
||||||
const code = page.locator('[data-component="code"]').first()
|
const code = page.locator('[data-component="code"]').first()
|
||||||
await expect(code).toBeVisible()
|
await expect(code).toBeVisible()
|
||||||
await expect(code.getByText("@opencode-ai/app")).toBeVisible()
|
await expect(code.getByText(/"name"\s*:\s*"@opencode-ai\/app"/)).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ test("smoke model selection updates prompt footer", async ({ page, gotoSession }
|
|||||||
const key = await target.getAttribute("data-key")
|
const key = await target.getAttribute("data-key")
|
||||||
if (!key) throw new Error("Failed to resolve model key from list item")
|
if (!key) throw new Error("Failed to resolve model key from list item")
|
||||||
|
|
||||||
const name = (await target.locator("span").first().innerText()).trim()
|
|
||||||
const model = key.split(":").slice(1).join(":")
|
const model = key.split(":").slice(1).join(":")
|
||||||
|
|
||||||
await input.fill(model)
|
await input.fill(model)
|
||||||
@@ -37,6 +36,13 @@ test("smoke model selection updates prompt footer", async ({ page, gotoSession }
|
|||||||
|
|
||||||
await expect(dialog).toHaveCount(0)
|
await expect(dialog).toHaveCount(0)
|
||||||
|
|
||||||
const form = page.locator(promptSelector).locator("xpath=ancestor::form[1]")
|
await page.locator(promptSelector).click()
|
||||||
await expect(form.locator('[data-component="button"]').filter({ hasText: name }).first()).toBeVisible()
|
await page.keyboard.type("/model")
|
||||||
|
await expect(command).toBeVisible()
|
||||||
|
await command.hover()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
const dialogAgain = page.getByRole("dialog")
|
||||||
|
await expect(dialogAgain).toBeVisible()
|
||||||
|
await expect(dialogAgain.locator(`[data-slot="list-item"][data-key="${key}"][data-selected="true"]`)).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -69,15 +69,19 @@ async function createSessionFromWorkspace(page: Page, slug: string, text: string
|
|||||||
|
|
||||||
const prompt = page.locator(promptSelector)
|
const prompt = page.locator(promptSelector)
|
||||||
await expect(prompt).toBeVisible()
|
await expect(prompt).toBeVisible()
|
||||||
|
await expect(prompt).toBeEditable()
|
||||||
await prompt.click()
|
await prompt.click()
|
||||||
await page.keyboard.type(text)
|
await expect(prompt).toBeFocused()
|
||||||
await page.keyboard.press("Enter")
|
await prompt.fill(text)
|
||||||
|
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
|
||||||
|
await prompt.press("Enter")
|
||||||
|
|
||||||
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
|
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
|
||||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/[^/?#]+`), { timeout: 30_000 })
|
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
|
||||||
|
|
||||||
const sessionID = sessionIDFromUrl(page.url())
|
const sessionID = sessionIDFromUrl(page.url())
|
||||||
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${sessionID}(?:[/?#]|$)`))
|
||||||
return sessionID
|
return sessionID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,18 +11,12 @@ import {
|
|||||||
cleanupTestProject,
|
cleanupTestProject,
|
||||||
clickMenuItem,
|
clickMenuItem,
|
||||||
confirmDialog,
|
confirmDialog,
|
||||||
openProjectMenu,
|
|
||||||
openSidebar,
|
openSidebar,
|
||||||
openWorkspaceMenu,
|
openWorkspaceMenu,
|
||||||
setWorkspacesEnabled,
|
setWorkspacesEnabled,
|
||||||
} from "../actions"
|
} from "../actions"
|
||||||
import {
|
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
|
||||||
inlineInputSelector,
|
import { createSdk, dirSlug } from "../utils"
|
||||||
projectSwitchSelector,
|
|
||||||
projectWorkspacesToggleSelector,
|
|
||||||
workspaceItemSelector,
|
|
||||||
} from "../selectors"
|
|
||||||
import { dirSlug } from "../utils"
|
|
||||||
|
|
||||||
function slugFromUrl(url: string) {
|
function slugFromUrl(url: string) {
|
||||||
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
||||||
@@ -143,26 +137,35 @@ test("non-git projects keep workspace mode disabled", async ({ page, withProject
|
|||||||
await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n")
|
await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withProject(
|
await withProject(async () => {
|
||||||
async () => {
|
await page.goto(`/${nonGitSlug}/session`)
|
||||||
await openSidebar(page)
|
|
||||||
|
|
||||||
const nonGitButton = page.locator(projectSwitchSelector(nonGitSlug)).first()
|
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
|
||||||
await expect(nonGitButton).toBeVisible()
|
|
||||||
await nonGitButton.click()
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${nonGitSlug}/session`))
|
|
||||||
|
|
||||||
const menu = await openProjectMenu(page, nonGitSlug)
|
const activeDir = base64Decode(slugFromUrl(page.url()))
|
||||||
const toggle = menu.locator(projectWorkspacesToggleSelector(nonGitSlug)).first()
|
expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
|
||||||
|
|
||||||
await expect(toggle).toBeVisible()
|
await openSidebar(page)
|
||||||
await expect(toggle).toBeDisabled()
|
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
|
||||||
|
|
||||||
await expect(menu.getByRole("menuitem", { name: "New workspace" })).toHaveCount(0)
|
const trigger = page.locator('[data-action="project-menu"]').first()
|
||||||
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
|
const hasMenu = await trigger
|
||||||
},
|
.isVisible()
|
||||||
{ extra: [nonGit] },
|
.then((x) => x)
|
||||||
)
|
.catch(() => false)
|
||||||
|
if (!hasMenu) return
|
||||||
|
|
||||||
|
await trigger.click({ force: true })
|
||||||
|
|
||||||
|
const menu = page.locator(dropdownMenuContentSelector).first()
|
||||||
|
await expect(menu).toBeVisible()
|
||||||
|
|
||||||
|
const toggle = menu.locator('[data-action="project-workspaces-toggle"]').first()
|
||||||
|
|
||||||
|
await expect(toggle).toBeVisible()
|
||||||
|
await expect(toggle).toBeDisabled()
|
||||||
|
await expect(menu.getByRole("menuitem", { name: "New workspace" })).toHaveCount(0)
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupTestProject(nonGit)
|
await cleanupTestProject(nonGit)
|
||||||
}
|
}
|
||||||
@@ -256,14 +259,45 @@ test("can delete a workspace", async ({ page, withProject }) => {
|
|||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
await withProject(async (project) => {
|
await withProject(async (project) => {
|
||||||
const { rootSlug, slug } = await setupWorkspaceTest(page, project)
|
const sdk = createSdk(project.directory)
|
||||||
|
const { rootSlug, slug, directory } = await setupWorkspaceTest(page, project)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const worktrees = await sdk.worktree
|
||||||
|
.list()
|
||||||
|
.then((r) => r.data ?? [])
|
||||||
|
.catch(() => [] as string[])
|
||||||
|
return worktrees.includes(directory)
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
const menu = await openWorkspaceMenu(page, slug)
|
const menu = await openWorkspaceMenu(page, slug)
|
||||||
await clickMenuItem(menu, /^Delete$/i, { force: true })
|
await clickMenuItem(menu, /^Delete$/i, { force: true })
|
||||||
await confirmDialog(page, /^Delete workspace$/i)
|
await confirmDialog(page, /^Delete workspace$/i)
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
|
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
|
||||||
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const worktrees = await sdk.worktree
|
||||||
|
.list()
|
||||||
|
.then((r) => r.data ?? [])
|
||||||
|
.catch(() => [] as string[])
|
||||||
|
return worktrees.includes(directory)
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBe(false)
|
||||||
|
|
||||||
|
await project.gotoSession()
|
||||||
|
|
||||||
|
await openSidebar(page)
|
||||||
|
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 })
|
||||||
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
|
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,40 +1,95 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
|
import type { Page } from "@playwright/test"
|
||||||
import { promptSelector } from "../selectors"
|
import { promptSelector } from "../selectors"
|
||||||
import { withSession } from "../actions"
|
import { withSession } from "../actions"
|
||||||
|
|
||||||
|
function contextButton(page: Page) {
|
||||||
|
return page
|
||||||
|
.locator('[data-component="button"]')
|
||||||
|
.filter({ has: page.locator('[data-component="progress-circle"]').first() })
|
||||||
|
.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedContextSession(input: { sessionID: string; sdk: Parameters<typeof withSession>[0] }) {
|
||||||
|
await input.sdk.session.promptAsync({
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
noReply: true,
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "seed context",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const messages = await input.sdk.session
|
||||||
|
.messages({ sessionID: input.sessionID, limit: 1 })
|
||||||
|
.then((r) => r.data ?? [])
|
||||||
|
return messages.length
|
||||||
|
})
|
||||||
|
.toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
|
||||||
test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => {
|
test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => {
|
||||||
const title = `e2e smoke context ${Date.now()}`
|
const title = `e2e smoke context ${Date.now()}`
|
||||||
|
|
||||||
await withSession(sdk, title, async (session) => {
|
await withSession(sdk, title, async (session) => {
|
||||||
await sdk.session.promptAsync({
|
await seedContextSession({ sessionID: session.id, sdk })
|
||||||
sessionID: session.id,
|
|
||||||
noReply: true,
|
|
||||||
parts: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: "seed context",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
const messages = await sdk.session.messages({ sessionID: session.id, limit: 1 }).then((r) => r.data ?? [])
|
|
||||||
return messages.length
|
|
||||||
})
|
|
||||||
.toBeGreaterThan(0)
|
|
||||||
|
|
||||||
await gotoSession(session.id)
|
await gotoSession(session.id)
|
||||||
|
|
||||||
const contextButton = page
|
const trigger = contextButton(page)
|
||||||
.locator('[data-component="button"]')
|
await expect(trigger).toBeVisible()
|
||||||
.filter({ has: page.locator('[data-component="progress-circle"]').first() })
|
await trigger.click()
|
||||||
.first()
|
|
||||||
|
|
||||||
await expect(contextButton).toBeVisible()
|
|
||||||
await contextButton.click()
|
|
||||||
|
|
||||||
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
|
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
|
||||||
await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible()
|
await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("context panel can be closed from the context tab close action", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withSession(sdk, `e2e context toggle ${Date.now()}`, async (session) => {
|
||||||
|
await seedContextSession({ sessionID: session.id, sdk })
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
await page.locator(promptSelector).click()
|
||||||
|
|
||||||
|
const trigger = contextButton(page)
|
||||||
|
await expect(trigger).toBeVisible()
|
||||||
|
await trigger.click()
|
||||||
|
|
||||||
|
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
|
||||||
|
const context = tabs.getByRole("tab", { name: "Context" })
|
||||||
|
await expect(context).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Close tab" }).first().click()
|
||||||
|
await expect(context).toHaveCount(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("context panel can open file picker from context actions", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withSession(sdk, `e2e context tabs ${Date.now()}`, async (session) => {
|
||||||
|
await seedContextSession({ sessionID: session.id, sdk })
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
await page.locator(promptSelector).click()
|
||||||
|
|
||||||
|
const trigger = contextButton(page)
|
||||||
|
await expect(trigger).toBeVisible()
|
||||||
|
await trigger.click()
|
||||||
|
|
||||||
|
await expect(page.getByRole("tab", { name: "Context" })).toBeVisible()
|
||||||
|
await page.getByRole("button", { name: "Open file" }).first().click()
|
||||||
|
|
||||||
|
const dialog = page
|
||||||
|
.getByRole("dialog")
|
||||||
|
.filter({ has: page.getByPlaceholder(/search files/i) })
|
||||||
|
.first()
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await expect(dialog).toHaveCount(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
import { sessionIDFromUrl } from "../actions"
|
||||||
|
|
||||||
|
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
|
||||||
|
// the connection open while the agent works, causing "Failed to fetch" over
|
||||||
|
// VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately.
|
||||||
|
test("prompt succeeds when sync message endpoint is unreachable", async ({ page, sdk, gotoSession }) => {
|
||||||
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
|
// Simulate Tailscale/VPN killing the long-lived sync connection
|
||||||
|
await page.route("**/session/*/message", (route) => route.abort("connectionfailed"))
|
||||||
|
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const token = `E2E_ASYNC_${Date.now()}`
|
||||||
|
await page.locator(promptSelector).click()
|
||||||
|
await page.keyboard.type(`Reply with exactly: ${token}`)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
||||||
|
const sessionID = sessionIDFromUrl(page.url())!
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Agent response arrives via SSE despite sync endpoint being dead
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
||||||
|
return messages
|
||||||
|
.filter((m) => m.info.role === "assistant")
|
||||||
|
.flatMap((m) => m.parts)
|
||||||
|
.filter((p) => p.type === "text")
|
||||||
|
.map((p) => p.text)
|
||||||
|
.join("\n")
|
||||||
|
},
|
||||||
|
{ timeout: 90_000 },
|
||||||
|
)
|
||||||
|
.toContain(token)
|
||||||
|
} finally {
|
||||||
|
await sdk.session.delete({ sessionID }).catch(() => undefined)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
|
test("dropping text/plain file: uri inserts a file pill", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
await prompt.click()
|
||||||
|
|
||||||
|
const path = process.platform === "win32" ? "C:\\opencode-e2e-drop.txt" : "/tmp/opencode-e2e-drop.txt"
|
||||||
|
const dt = await page.evaluateHandle((text) => {
|
||||||
|
const dt = new DataTransfer()
|
||||||
|
dt.setData("text/plain", text)
|
||||||
|
return dt
|
||||||
|
}, `file:${path}`)
|
||||||
|
|
||||||
|
await page.dispatchEvent("body", "drop", { dataTransfer: dt })
|
||||||
|
|
||||||
|
const pill = page.locator(`${promptSelector} [data-type="file"]`).first()
|
||||||
|
await expect(pill).toBeVisible()
|
||||||
|
await expect(pill).toHaveAttribute("data-path", path)
|
||||||
|
})
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
|
test("dropping an image file adds an attachment", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
await prompt.click()
|
||||||
|
|
||||||
|
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO3+4uQAAAAASUVORK5CYII="
|
||||||
|
const dt = await page.evaluateHandle((b64) => {
|
||||||
|
const dt = new DataTransfer()
|
||||||
|
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0))
|
||||||
|
const file = new File([bytes], "drop.png", { type: "image/png" })
|
||||||
|
dt.items.add(file)
|
||||||
|
return dt
|
||||||
|
}, png)
|
||||||
|
|
||||||
|
await page.dispatchEvent("body", "drop", { dataTransfer: dt })
|
||||||
|
|
||||||
|
const img = page.locator('img[alt="drop.png"]').first()
|
||||||
|
await expect(img).toBeVisible()
|
||||||
|
|
||||||
|
const remove = page.getByRole("button", { name: "Remove attachment" }).first()
|
||||||
|
await expect(remove).toBeVisible()
|
||||||
|
|
||||||
|
await img.hover()
|
||||||
|
await remove.click()
|
||||||
|
await expect(page.locator('img[alt="drop.png"]')).toHaveCount(0)
|
||||||
|
})
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
|
test("shift+enter inserts a newline without submitting", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/session\/?$/)
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type("line one")
|
||||||
|
await page.keyboard.press("Shift+Enter")
|
||||||
|
await page.keyboard.type("line two")
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/session\/?$/)
|
||||||
|
await expect(prompt).toContainText("line one")
|
||||||
|
await expect(prompt).toContainText("line two")
|
||||||
|
})
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptSelector, terminalSelector } from "../selectors"
|
||||||
|
|
||||||
|
test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
const terminal = page.locator(terminalSelector)
|
||||||
|
|
||||||
|
await expect(terminal).not.toBeVisible()
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type("/terminal")
|
||||||
|
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
await expect(terminal).toBeVisible()
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type("/terminal")
|
||||||
|
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
await expect(terminal).not.toBeVisible()
|
||||||
|
})
|
||||||
@@ -44,9 +44,6 @@ test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession })
|
|||||||
)
|
)
|
||||||
|
|
||||||
.toContain(token)
|
.toContain(token)
|
||||||
|
|
||||||
const reply = page.locator('[data-slot="session-turn-summary-section"]').filter({ hasText: token }).first()
|
|
||||||
await expect(reply).toBeVisible({ timeout: 90_000 })
|
|
||||||
} finally {
|
} finally {
|
||||||
page.off("pageerror", onPageError)
|
page.off("pageerror", onPageError)
|
||||||
await sdk.session.delete({ sessionID }).catch(() => undefined)
|
await sdk.session.delete({ sessionID }).catch(() => undefined)
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ export const settingsNotificationsAgentSelector = '[data-action="settings-notifi
|
|||||||
export const settingsNotificationsPermissionsSelector = '[data-action="settings-notifications-permissions"]'
|
export const settingsNotificationsPermissionsSelector = '[data-action="settings-notifications-permissions"]'
|
||||||
export const settingsNotificationsErrorsSelector = '[data-action="settings-notifications-errors"]'
|
export const settingsNotificationsErrorsSelector = '[data-action="settings-notifications-errors"]'
|
||||||
export const settingsSoundsAgentSelector = '[data-action="settings-sounds-agent"]'
|
export const settingsSoundsAgentSelector = '[data-action="settings-sounds-agent"]'
|
||||||
|
export const settingsSoundsAgentEnabledSelector = '[data-action="settings-sounds-agent-enabled"]'
|
||||||
export const settingsSoundsPermissionsSelector = '[data-action="settings-sounds-permissions"]'
|
export const settingsSoundsPermissionsSelector = '[data-action="settings-sounds-permissions"]'
|
||||||
|
export const settingsSoundsPermissionsEnabledSelector = '[data-action="settings-sounds-permissions-enabled"]'
|
||||||
export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-errors"]'
|
export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-errors"]'
|
||||||
|
export const settingsSoundsErrorsEnabledSelector = '[data-action="settings-sounds-errors-enabled"]'
|
||||||
export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]'
|
export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]'
|
||||||
export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]'
|
export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]'
|
||||||
|
|
||||||
@@ -27,6 +30,9 @@ export const projectMenuTriggerSelector = (slug: string) =>
|
|||||||
|
|
||||||
export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
|
export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
|
||||||
|
|
||||||
|
export const projectClearNotificationsSelector = (slug: string) =>
|
||||||
|
`[data-action="project-clear-notifications"][data-project="${slug}"]`
|
||||||
|
|
||||||
export const projectWorkspacesToggleSelector = (slug: string) =>
|
export const projectWorkspacesToggleSelector = (slug: string) =>
|
||||||
`[data-action="project-workspaces-toggle"][data-project="${slug}"]`
|
`[data-action="project-workspaces-toggle"][data-project="${slug}"]`
|
||||||
|
|
||||||
|
|||||||
@@ -10,21 +10,26 @@ async function seedConversation(input: {
|
|||||||
sessionID: string
|
sessionID: string
|
||||||
token: string
|
token: string
|
||||||
}) {
|
}) {
|
||||||
|
const messages = async () =>
|
||||||
|
await input.sdk.session.messages({ sessionID: input.sessionID, limit: 100 }).then((r) => r.data ?? [])
|
||||||
|
const seeded = await messages()
|
||||||
|
const userIDs = new Set(seeded.filter((m) => m.info.role === "user").map((m) => m.info.id))
|
||||||
|
|
||||||
const prompt = input.page.locator(promptSelector)
|
const prompt = input.page.locator(promptSelector)
|
||||||
await expect(prompt).toBeVisible()
|
await expect(prompt).toBeVisible()
|
||||||
await prompt.click()
|
await input.sdk.session.promptAsync({
|
||||||
await input.page.keyboard.type(`Reply with exactly: ${input.token}`)
|
sessionID: input.sessionID,
|
||||||
await input.page.keyboard.press("Enter")
|
noReply: true,
|
||||||
|
parts: [{ type: "text", text: input.token }],
|
||||||
|
})
|
||||||
|
|
||||||
let userMessageID: string | undefined
|
let userMessageID: string | undefined
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const messages = await input.sdk.session
|
const users = (await messages()).filter(
|
||||||
.messages({ sessionID: input.sessionID, limit: 50 })
|
|
||||||
.then((r) => r.data ?? [])
|
|
||||||
const users = messages.filter(
|
|
||||||
(m) =>
|
(m) =>
|
||||||
|
!userIDs.has(m.info.id) &&
|
||||||
m.info.role === "user" &&
|
m.info.role === "user" &&
|
||||||
m.parts.filter((p) => p.type === "text").some((p) => p.text.includes(input.token)),
|
m.parts.filter((p) => p.type === "text").some((p) => p.text.includes(input.token)),
|
||||||
)
|
)
|
||||||
@@ -33,21 +38,14 @@ async function seedConversation(input: {
|
|||||||
const user = users[users.length - 1]
|
const user = users[users.length - 1]
|
||||||
if (!user) return false
|
if (!user) return false
|
||||||
userMessageID = user.info.id
|
userMessageID = user.info.id
|
||||||
|
return true
|
||||||
const assistantText = messages
|
|
||||||
.filter((m) => m.info.role === "assistant")
|
|
||||||
.flatMap((m) => m.parts)
|
|
||||||
.filter((p) => p.type === "text")
|
|
||||||
.map((p) => p.text)
|
|
||||||
.join("\n")
|
|
||||||
|
|
||||||
return assistantText.includes(input.token)
|
|
||||||
},
|
},
|
||||||
{ timeout: 90_000 },
|
{ timeout: 90_000, intervals: [250, 500, 1_000] },
|
||||||
)
|
)
|
||||||
.toBe(true)
|
.toBe(true)
|
||||||
|
|
||||||
if (!userMessageID) throw new Error("Expected a user message id")
|
if (!userMessageID) throw new Error("Expected a user message id")
|
||||||
|
await expect(input.page.locator(`[data-message-id="${userMessageID}"]`).first()).toBeVisible({ timeout: 30_000 })
|
||||||
return { prompt, userMessageID }
|
return { prompt, userMessageID }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,21 +34,34 @@ async function seedMessage(sdk: Sdk, sessionID: string) {
|
|||||||
test("session can be renamed via header menu", async ({ page, sdk, gotoSession }) => {
|
test("session can be renamed via header menu", async ({ page, sdk, gotoSession }) => {
|
||||||
const stamp = Date.now()
|
const stamp = Date.now()
|
||||||
const originalTitle = `e2e rename test ${stamp}`
|
const originalTitle = `e2e rename test ${stamp}`
|
||||||
const newTitle = `e2e renamed ${stamp}`
|
const renamedTitle = `e2e renamed ${stamp}`
|
||||||
|
|
||||||
await withSession(sdk, originalTitle, async (session) => {
|
await withSession(sdk, originalTitle, async (session) => {
|
||||||
await seedMessage(sdk, session.id)
|
await seedMessage(sdk, session.id)
|
||||||
await gotoSession(session.id)
|
await gotoSession(session.id)
|
||||||
|
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
|
||||||
|
|
||||||
const menu = await openSessionMoreMenu(page, session.id)
|
const menu = await openSessionMoreMenu(page, session.id)
|
||||||
await clickMenuItem(menu, /rename/i)
|
await clickMenuItem(menu, /rename/i)
|
||||||
|
|
||||||
const input = page.locator(".session-scroller").locator(inlineInputSelector).first()
|
const input = page.locator(".session-scroller").locator(inlineInputSelector).first()
|
||||||
await expect(input).toBeVisible()
|
await expect(input).toBeVisible()
|
||||||
await input.fill(newTitle)
|
await expect(input).toBeFocused()
|
||||||
|
await input.fill(renamedTitle)
|
||||||
|
await expect(input).toHaveValue(renamedTitle)
|
||||||
await input.press("Enter")
|
await input.press("Enter")
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { level: 1 }).first()).toContainText(newTitle)
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
|
return data?.title
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(renamedTitle)
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -116,8 +129,14 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
|
|||||||
await seedMessage(sdk, session.id)
|
await seedMessage(sdk, session.id)
|
||||||
await gotoSession(session.id)
|
await gotoSession(session.id)
|
||||||
|
|
||||||
const { rightSection, popoverBody } = await openSharePopover(page)
|
const shared = await openSharePopover(page)
|
||||||
await popoverBody.getByRole("button", { name: "Publish" }).first().click()
|
const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first()
|
||||||
|
await expect(publish).toBeVisible({ timeout: 30_000 })
|
||||||
|
await publish.click()
|
||||||
|
|
||||||
|
await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
@@ -129,14 +148,14 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
|
|||||||
)
|
)
|
||||||
.not.toBeUndefined()
|
.not.toBeUndefined()
|
||||||
|
|
||||||
const copyButton = rightSection.locator('button[aria-label="Copy link"]').first()
|
const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()
|
||||||
await expect(copyButton).toBeVisible({ timeout: 30_000 })
|
|
||||||
|
|
||||||
const sharedPopover = await openSharePopover(page)
|
|
||||||
const unpublish = sharedPopover.popoverBody.getByRole("button", { name: "Unpublish" }).first()
|
|
||||||
await expect(unpublish).toBeVisible({ timeout: 30_000 })
|
await expect(unpublish).toBeVisible({ timeout: 30_000 })
|
||||||
await unpublish.click()
|
await unpublish.click()
|
||||||
|
|
||||||
|
await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
@@ -147,10 +166,8 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
|
|||||||
)
|
)
|
||||||
.toBeUndefined()
|
.toBeUndefined()
|
||||||
|
|
||||||
await expect(copyButton).not.toBeVisible({ timeout: 30_000 })
|
const unshared = await openSharePopover(page)
|
||||||
|
await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
|
||||||
const unsharedPopover = await openSharePopover(page)
|
|
||||||
await expect(unsharedPopover.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
|
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
settingsNotificationsPermissionsSelector,
|
settingsNotificationsPermissionsSelector,
|
||||||
settingsReleaseNotesSelector,
|
settingsReleaseNotesSelector,
|
||||||
settingsSoundsAgentSelector,
|
settingsSoundsAgentSelector,
|
||||||
|
settingsSoundsAgentEnabledSelector,
|
||||||
settingsSoundsErrorsSelector,
|
settingsSoundsErrorsSelector,
|
||||||
settingsSoundsPermissionsSelector,
|
settingsSoundsPermissionsSelector,
|
||||||
settingsThemeSelector,
|
settingsThemeSelector,
|
||||||
@@ -335,6 +336,30 @@ test("changing sound agent selection persists in localStorage", async ({ page, g
|
|||||||
expect(stored?.sounds?.agent).not.toBe("staplebops-01")
|
expect(stored?.sounds?.agent).not.toBe("staplebops-01")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("disabling agent sound disables sound selection", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const dialog = await openSettings(page)
|
||||||
|
const select = dialog.locator(settingsSoundsAgentSelector)
|
||||||
|
const switchContainer = dialog.locator(settingsSoundsAgentEnabledSelector)
|
||||||
|
const trigger = select.locator('[data-slot="select-select-trigger"]')
|
||||||
|
await expect(select).toBeVisible()
|
||||||
|
await expect(switchContainer).toBeVisible()
|
||||||
|
await expect(trigger).toBeEnabled()
|
||||||
|
|
||||||
|
await switchContainer.locator('[data-slot="switch-control"]').click()
|
||||||
|
await page.waitForTimeout(100)
|
||||||
|
|
||||||
|
await expect(trigger).toBeDisabled()
|
||||||
|
|
||||||
|
const stored = await page.evaluate((key) => {
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
}, settingsKey)
|
||||||
|
|
||||||
|
expect(stored?.sounds?.agentEnabled).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
test("changing permissions and errors sounds updates localStorage", async ({ page, gotoSession }) => {
|
test("changing permissions and errors sounds updates localStorage", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.1.59",
|
"version": "1.2.6",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
+97
-69
@@ -1,5 +1,5 @@
|
|||||||
import "@/index.css"
|
import "@/index.css"
|
||||||
import { ErrorBoundary, Show, lazy, type ParentProps } from "solid-js"
|
import { ErrorBoundary, Show, Suspense, lazy, type JSX, type ParentProps } from "solid-js"
|
||||||
import { Router, Route, Navigate } from "@solidjs/router"
|
import { Router, Route, Navigate } from "@solidjs/router"
|
||||||
import { MetaProvider } from "@solidjs/meta"
|
import { MetaProvider } from "@solidjs/meta"
|
||||||
import { Font } from "@opencode-ai/ui/font"
|
import { Font } from "@opencode-ai/ui/font"
|
||||||
@@ -30,12 +30,26 @@ import { HighlightsProvider } from "@/context/highlights"
|
|||||||
import Layout from "@/pages/layout"
|
import Layout from "@/pages/layout"
|
||||||
import DirectoryLayout from "@/pages/directory-layout"
|
import DirectoryLayout from "@/pages/directory-layout"
|
||||||
import { ErrorPage } from "./pages/error"
|
import { ErrorPage } from "./pages/error"
|
||||||
import { Suspense, JSX } from "solid-js"
|
|
||||||
|
|
||||||
const Home = lazy(() => import("@/pages/home"))
|
const Home = lazy(() => import("@/pages/home"))
|
||||||
const Session = lazy(() => import("@/pages/session"))
|
const Session = lazy(() => import("@/pages/session"))
|
||||||
const Loading = () => <div class="size-full" />
|
const Loading = () => <div class="size-full" />
|
||||||
|
|
||||||
|
const HomeRoute = () => (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<Home />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
|
||||||
|
const SessionRoute = () => (
|
||||||
|
<SessionProviders>
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<Session />
|
||||||
|
</Suspense>
|
||||||
|
</SessionProviders>
|
||||||
|
)
|
||||||
|
|
||||||
|
const SessionIndexRoute = () => <Navigate href="session" />
|
||||||
|
|
||||||
function UiI18nBridge(props: ParentProps) {
|
function UiI18nBridge(props: ParentProps) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
return <I18nProvider value={{ locale: language.locale, t: language.t }}>{props.children}</I18nProvider>
|
return <I18nProvider value={{ locale: language.locale, t: language.t }}>{props.children}</I18nProvider>
|
||||||
@@ -52,6 +66,71 @@ function MarkedProviderWithNativeParser(props: ParentProps) {
|
|||||||
return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
|
return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AppShellProviders(props: ParentProps) {
|
||||||
|
return (
|
||||||
|
<SettingsProvider>
|
||||||
|
<PermissionProvider>
|
||||||
|
<LayoutProvider>
|
||||||
|
<NotificationProvider>
|
||||||
|
<ModelsProvider>
|
||||||
|
<CommandProvider>
|
||||||
|
<HighlightsProvider>
|
||||||
|
<Layout>{props.children}</Layout>
|
||||||
|
</HighlightsProvider>
|
||||||
|
</CommandProvider>
|
||||||
|
</ModelsProvider>
|
||||||
|
</NotificationProvider>
|
||||||
|
</LayoutProvider>
|
||||||
|
</PermissionProvider>
|
||||||
|
</SettingsProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionProviders(props: ParentProps) {
|
||||||
|
return (
|
||||||
|
<TerminalProvider>
|
||||||
|
<FileProvider>
|
||||||
|
<PromptProvider>
|
||||||
|
<CommentsProvider>{props.children}</CommentsProvider>
|
||||||
|
</PromptProvider>
|
||||||
|
</FileProvider>
|
||||||
|
</TerminalProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
|
||||||
|
return (
|
||||||
|
<AppShellProviders>
|
||||||
|
{props.appChildren}
|
||||||
|
{props.children}
|
||||||
|
</AppShellProviders>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStoredDefaultServerUrl = (platform: ReturnType<typeof usePlatform>) => {
|
||||||
|
if (platform.platform !== "web") return
|
||||||
|
const result = platform.getDefaultServerUrl?.()
|
||||||
|
if (result instanceof Promise) return
|
||||||
|
if (!result) return
|
||||||
|
return normalizeServerUrl(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveDefaultServerUrl = (props: {
|
||||||
|
defaultUrl?: string
|
||||||
|
storedDefaultServerUrl?: string
|
||||||
|
hostname: string
|
||||||
|
origin: string
|
||||||
|
isDev: boolean
|
||||||
|
devHost?: string
|
||||||
|
devPort?: string
|
||||||
|
}) => {
|
||||||
|
if (props.defaultUrl) return props.defaultUrl
|
||||||
|
if (props.storedDefaultServerUrl) return props.storedDefaultServerUrl
|
||||||
|
if (props.hostname.includes("opencode.ai")) return "http://localhost:4096"
|
||||||
|
if (props.isDev) return `http://${props.devHost ?? "localhost"}:${props.devPort ?? "4096"}`
|
||||||
|
return props.origin
|
||||||
|
}
|
||||||
|
|
||||||
export function AppBaseProviders(props: ParentProps) {
|
export function AppBaseProviders(props: ParentProps) {
|
||||||
return (
|
return (
|
||||||
<MetaProvider>
|
<MetaProvider>
|
||||||
@@ -86,80 +165,29 @@ function ServerKey(props: ParentProps) {
|
|||||||
|
|
||||||
export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element; isSidecar?: boolean }) {
|
export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element; isSidecar?: boolean }) {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
|
const storedDefaultServerUrl = getStoredDefaultServerUrl(platform)
|
||||||
const stored = (() => {
|
const defaultServerUrl = resolveDefaultServerUrl({
|
||||||
if (platform.platform !== "web") return
|
defaultUrl: props.defaultUrl,
|
||||||
const result = platform.getDefaultServerUrl?.()
|
storedDefaultServerUrl,
|
||||||
if (result instanceof Promise) return
|
hostname: location.hostname,
|
||||||
if (!result) return
|
origin: window.location.origin,
|
||||||
return normalizeServerUrl(result)
|
isDev: import.meta.env.DEV,
|
||||||
})()
|
devHost: import.meta.env.VITE_OPENCODE_SERVER_HOST,
|
||||||
|
devPort: import.meta.env.VITE_OPENCODE_SERVER_PORT,
|
||||||
const defaultServerUrl = () => {
|
})
|
||||||
if (props.defaultUrl) return props.defaultUrl
|
|
||||||
if (stored) return stored
|
|
||||||
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
|
|
||||||
if (import.meta.env.DEV)
|
|
||||||
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
|
||||||
|
|
||||||
return window.location.origin
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ServerProvider defaultUrl={defaultServerUrl()} isSidecar={props.isSidecar}>
|
<ServerProvider defaultUrl={defaultServerUrl} isSidecar={props.isSidecar}>
|
||||||
<ServerKey>
|
<ServerKey>
|
||||||
<GlobalSDKProvider>
|
<GlobalSDKProvider>
|
||||||
<GlobalSyncProvider>
|
<GlobalSyncProvider>
|
||||||
<Router
|
<Router
|
||||||
root={(routerProps) => (
|
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
|
||||||
<SettingsProvider>
|
|
||||||
<PermissionProvider>
|
|
||||||
<LayoutProvider>
|
|
||||||
<NotificationProvider>
|
|
||||||
<ModelsProvider>
|
|
||||||
<CommandProvider>
|
|
||||||
<HighlightsProvider>
|
|
||||||
<Layout>
|
|
||||||
{props.children}
|
|
||||||
{routerProps.children}
|
|
||||||
</Layout>
|
|
||||||
</HighlightsProvider>
|
|
||||||
</CommandProvider>
|
|
||||||
</ModelsProvider>
|
|
||||||
</NotificationProvider>
|
|
||||||
</LayoutProvider>
|
|
||||||
</PermissionProvider>
|
|
||||||
</SettingsProvider>
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Route
|
<Route path="/" component={HomeRoute} />
|
||||||
path="/"
|
|
||||||
component={() => (
|
|
||||||
<Suspense fallback={<Loading />}>
|
|
||||||
<Home />
|
|
||||||
</Suspense>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Route path="/:dir" component={DirectoryLayout}>
|
<Route path="/:dir" component={DirectoryLayout}>
|
||||||
<Route path="/" component={() => <Navigate href="session" />} />
|
<Route path="/" component={SessionIndexRoute} />
|
||||||
<Route
|
<Route path="/session/:id?" component={SessionRoute} />
|
||||||
path="/session/:id?"
|
|
||||||
component={(p) => (
|
|
||||||
<Show when={p.params.id ?? "new"}>
|
|
||||||
<TerminalProvider>
|
|
||||||
<FileProvider>
|
|
||||||
<PromptProvider>
|
|
||||||
<CommentsProvider>
|
|
||||||
<Suspense fallback={<Loading />}>
|
|
||||||
<Session />
|
|
||||||
</Suspense>
|
|
||||||
</CommentsProvider>
|
|
||||||
</PromptProvider>
|
|
||||||
</FileProvider>
|
|
||||||
</TerminalProvider>
|
|
||||||
</Show>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Route>
|
</Route>
|
||||||
</Router>
|
</Router>
|
||||||
</GlobalSyncProvider>
|
</GlobalSyncProvider>
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
|||||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { iife } from "@opencode-ai/util/iife"
|
|
||||||
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
|
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { Link } from "@/components/link"
|
import { Link } from "@/components/link"
|
||||||
@@ -55,6 +54,47 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
error: undefined as string | undefined,
|
error: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| { type: "method.select"; index: number }
|
||||||
|
| { type: "method.reset" }
|
||||||
|
| { type: "auth.pending" }
|
||||||
|
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
|
||||||
|
| { type: "auth.error"; error: string }
|
||||||
|
|
||||||
|
function dispatch(action: Action) {
|
||||||
|
setStore(
|
||||||
|
produce((draft) => {
|
||||||
|
if (action.type === "method.select") {
|
||||||
|
draft.methodIndex = action.index
|
||||||
|
draft.authorization = undefined
|
||||||
|
draft.state = undefined
|
||||||
|
draft.error = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action.type === "method.reset") {
|
||||||
|
draft.methodIndex = undefined
|
||||||
|
draft.authorization = undefined
|
||||||
|
draft.state = undefined
|
||||||
|
draft.error = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action.type === "auth.pending") {
|
||||||
|
draft.state = "pending"
|
||||||
|
draft.error = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action.type === "auth.complete") {
|
||||||
|
draft.state = "complete"
|
||||||
|
draft.authorization = action.authorization
|
||||||
|
draft.error = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
draft.state = "error"
|
||||||
|
draft.error = action.error
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined))
|
const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined))
|
||||||
|
|
||||||
const methodLabel = (value?: { type?: string; label?: string }) => {
|
const methodLabel = (value?: { type?: string; label?: string }) => {
|
||||||
@@ -63,6 +103,24 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
return value.label ?? ""
|
return value.label ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatError(value: unknown, fallback: string): string {
|
||||||
|
if (value && typeof value === "object" && "data" in value) {
|
||||||
|
const data = (value as { data?: { message?: unknown } }).data
|
||||||
|
if (typeof data?.message === "string" && data.message) return data.message
|
||||||
|
}
|
||||||
|
if (value && typeof value === "object" && "error" in value) {
|
||||||
|
const nested = formatError((value as { error?: unknown }).error, "")
|
||||||
|
if (nested) return nested
|
||||||
|
}
|
||||||
|
if (value && typeof value === "object" && "message" in value) {
|
||||||
|
const message = (value as { message?: unknown }).message
|
||||||
|
if (typeof message === "string" && message) return message
|
||||||
|
}
|
||||||
|
if (value instanceof Error && value.message) return value.message
|
||||||
|
if (typeof value === "string" && value) return value
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
async function selectMethod(index: number) {
|
async function selectMethod(index: number) {
|
||||||
if (timer.current !== undefined) {
|
if (timer.current !== undefined) {
|
||||||
clearTimeout(timer.current)
|
clearTimeout(timer.current)
|
||||||
@@ -70,17 +128,10 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const method = methods()[index]
|
const method = methods()[index]
|
||||||
setStore(
|
dispatch({ type: "method.select", index })
|
||||||
produce((draft) => {
|
|
||||||
draft.methodIndex = index
|
|
||||||
draft.authorization = undefined
|
|
||||||
draft.state = undefined
|
|
||||||
draft.error = undefined
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (method.type === "oauth") {
|
if (method.type === "oauth") {
|
||||||
setStore("state", "pending")
|
dispatch({ type: "auth.pending" })
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
await globalSDK.client.provider.oauth
|
await globalSDK.client.provider.oauth
|
||||||
.authorize(
|
.authorize(
|
||||||
@@ -100,18 +151,15 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
timer.current = setTimeout(() => {
|
timer.current = setTimeout(() => {
|
||||||
timer.current = undefined
|
timer.current = undefined
|
||||||
if (!alive.value) return
|
if (!alive.value) return
|
||||||
setStore("state", "complete")
|
dispatch({ type: "auth.complete", authorization: x.data! })
|
||||||
setStore("authorization", x.data!)
|
|
||||||
}, delay)
|
}, delay)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setStore("state", "complete")
|
dispatch({ type: "auth.complete", authorization: x.data! })
|
||||||
setStore("authorization", x.data!)
|
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (!alive.value) return
|
if (!alive.value) return
|
||||||
setStore("state", "error")
|
dispatch({ type: "auth.error", error: formatError(e, language.t("common.requestFailed")) })
|
||||||
setStore("error", String(e))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,10 +177,6 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
if (methods().length === 1) {
|
if (methods().length === 1) {
|
||||||
selectMethod(0)
|
selectMethod(0)
|
||||||
}
|
}
|
||||||
document.addEventListener("keydown", handleKey)
|
|
||||||
onCleanup(() => {
|
|
||||||
document.removeEventListener("keydown", handleKey)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
async function complete() {
|
async function complete() {
|
||||||
@@ -152,17 +196,243 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (store.authorization) {
|
if (store.authorization) {
|
||||||
setStore("authorization", undefined)
|
dispatch({ type: "method.reset" })
|
||||||
setStore("methodIndex", undefined)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (store.methodIndex) {
|
if (store.methodIndex !== undefined) {
|
||||||
setStore("methodIndex", undefined)
|
dispatch({ type: "method.reset" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dialog.show(() => <DialogSelectProvider />)
|
dialog.show(() => <DialogSelectProvider />)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MethodSelection() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div class="text-14-regular text-text-base">
|
||||||
|
{language.t("provider.connect.selectMethod", { provider: provider().name })}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<List
|
||||||
|
ref={(ref) => {
|
||||||
|
listRef = ref
|
||||||
|
}}
|
||||||
|
items={methods}
|
||||||
|
key={(m) => m?.label}
|
||||||
|
onSelect={async (selected, index) => {
|
||||||
|
if (!selected) return
|
||||||
|
selectMethod(index)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(i) => (
|
||||||
|
<div class="w-full flex items-center gap-x-2">
|
||||||
|
<div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
|
||||||
|
<div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
|
||||||
|
</div>
|
||||||
|
<span>{methodLabel(i)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ApiAuthView() {
|
||||||
|
const [formStore, setFormStore] = createStore({
|
||||||
|
value: "",
|
||||||
|
error: undefined as string | undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const form = e.currentTarget as HTMLFormElement
|
||||||
|
const formData = new FormData(form)
|
||||||
|
const apiKey = formData.get("apiKey") as string
|
||||||
|
|
||||||
|
if (!apiKey?.trim()) {
|
||||||
|
setFormStore("error", language.t("provider.connect.apiKey.required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormStore("error", undefined)
|
||||||
|
await globalSDK.client.auth.set({
|
||||||
|
providerID: props.provider,
|
||||||
|
auth: {
|
||||||
|
type: "api",
|
||||||
|
key: apiKey,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await complete()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<Switch>
|
||||||
|
<Match when={provider().id === "opencode"}>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="text-14-regular text-text-base">{language.t("provider.connect.opencodeZen.line1")}</div>
|
||||||
|
<div class="text-14-regular text-text-base">{language.t("provider.connect.opencodeZen.line2")}</div>
|
||||||
|
<div class="text-14-regular text-text-base">
|
||||||
|
{language.t("provider.connect.opencodeZen.visit.prefix")}
|
||||||
|
<Link href="https://opencode.ai/zen" tabIndex={-1}>
|
||||||
|
{language.t("provider.connect.opencodeZen.visit.link")}
|
||||||
|
</Link>
|
||||||
|
{language.t("provider.connect.opencodeZen.visit.suffix")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
|
<Match when={true}>
|
||||||
|
<div class="text-14-regular text-text-base">
|
||||||
|
{language.t("provider.connect.apiKey.description", { provider: provider().name })}
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
||||||
|
<TextField
|
||||||
|
autofocus
|
||||||
|
type="text"
|
||||||
|
label={language.t("provider.connect.apiKey.label", { provider: provider().name })}
|
||||||
|
placeholder={language.t("provider.connect.apiKey.placeholder")}
|
||||||
|
name="apiKey"
|
||||||
|
value={formStore.value}
|
||||||
|
onChange={(v) => setFormStore("value", v)}
|
||||||
|
validationState={formStore.error ? "invalid" : undefined}
|
||||||
|
error={formStore.error}
|
||||||
|
/>
|
||||||
|
<Button class="w-auto" type="submit" size="large" variant="primary">
|
||||||
|
{language.t("common.submit")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function OAuthCodeView() {
|
||||||
|
const [formStore, setFormStore] = createStore({
|
||||||
|
value: "",
|
||||||
|
error: undefined as string | undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (store.authorization?.method === "code" && store.authorization?.url) {
|
||||||
|
platform.openLink(store.authorization.url)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const form = e.currentTarget as HTMLFormElement
|
||||||
|
const formData = new FormData(form)
|
||||||
|
const code = formData.get("code") as string
|
||||||
|
|
||||||
|
if (!code?.trim()) {
|
||||||
|
setFormStore("error", language.t("provider.connect.oauth.code.required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormStore("error", undefined)
|
||||||
|
const result = await globalSDK.client.provider.oauth
|
||||||
|
.callback({
|
||||||
|
providerID: props.provider,
|
||||||
|
method: store.methodIndex,
|
||||||
|
code,
|
||||||
|
})
|
||||||
|
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
|
||||||
|
.catch((error) => ({ ok: false as const, error }))
|
||||||
|
if (result.ok) {
|
||||||
|
await complete()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid")))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<div class="text-14-regular text-text-base">
|
||||||
|
{language.t("provider.connect.oauth.code.visit.prefix")}
|
||||||
|
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.code.visit.link")}</Link>
|
||||||
|
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
||||||
|
<TextField
|
||||||
|
autofocus
|
||||||
|
type="text"
|
||||||
|
label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
|
||||||
|
placeholder={language.t("provider.connect.oauth.code.placeholder")}
|
||||||
|
name="code"
|
||||||
|
value={formStore.value}
|
||||||
|
onChange={(v) => setFormStore("value", v)}
|
||||||
|
validationState={formStore.error ? "invalid" : undefined}
|
||||||
|
error={formStore.error}
|
||||||
|
/>
|
||||||
|
<Button class="w-auto" type="submit" size="large" variant="primary">
|
||||||
|
{language.t("common.submit")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function OAuthAutoView() {
|
||||||
|
const code = createMemo(() => {
|
||||||
|
const instructions = store.authorization?.instructions
|
||||||
|
if (instructions?.includes(":")) {
|
||||||
|
return instructions.split(":")[1]?.trim()
|
||||||
|
}
|
||||||
|
return instructions
|
||||||
|
})
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void (async () => {
|
||||||
|
if (store.authorization?.url) {
|
||||||
|
platform.openLink(store.authorization.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await globalSDK.client.provider.oauth
|
||||||
|
.callback({
|
||||||
|
providerID: props.provider,
|
||||||
|
method: store.methodIndex,
|
||||||
|
})
|
||||||
|
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
|
||||||
|
.catch((error) => ({ ok: false as const, error }))
|
||||||
|
|
||||||
|
if (!alive.value) return
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
const message = formatError(result.error, language.t("common.requestFailed"))
|
||||||
|
dispatch({ type: "auth.error", error: message })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await complete()
|
||||||
|
})()
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<div class="text-14-regular text-text-base">
|
||||||
|
{language.t("provider.connect.oauth.auto.visit.prefix")}
|
||||||
|
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.auto.visit.link")}</Link>
|
||||||
|
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
|
||||||
|
</div>
|
||||||
|
<TextField
|
||||||
|
label={language.t("provider.connect.oauth.auto.confirmationCode")}
|
||||||
|
class="font-mono"
|
||||||
|
value={code()}
|
||||||
|
readOnly
|
||||||
|
copyable
|
||||||
|
/>
|
||||||
|
<div class="text-14-regular text-text-base flex items-center gap-4">
|
||||||
|
<Spinner />
|
||||||
|
<span>{language.t("provider.connect.status.waiting")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
title={
|
title={
|
||||||
@@ -188,267 +458,42 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-2.5 pb-10 flex flex-col gap-6">
|
<div class="px-2.5 pb-10 flex flex-col gap-6">
|
||||||
<Switch>
|
<div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
|
||||||
<Match when={store.methodIndex === undefined}>
|
<Switch>
|
||||||
<div class="text-14-regular text-text-base">
|
<Match when={store.methodIndex === undefined}>
|
||||||
{language.t("provider.connect.selectMethod", { provider: provider().name })}
|
<MethodSelection />
|
||||||
</div>
|
</Match>
|
||||||
<div class="">
|
<Match when={store.state === "pending"}>
|
||||||
<List
|
<div class="text-14-regular text-text-base">
|
||||||
ref={(ref) => {
|
<div class="flex items-center gap-x-2">
|
||||||
listRef = ref
|
<Spinner />
|
||||||
}}
|
<span>{language.t("provider.connect.status.inProgress")}</span>
|
||||||
items={methods}
|
|
||||||
key={(m) => m?.label}
|
|
||||||
onSelect={async (method, index) => {
|
|
||||||
if (!method) return
|
|
||||||
selectMethod(index)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(i) => (
|
|
||||||
<div class="w-full flex items-center gap-x-2">
|
|
||||||
<div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
|
|
||||||
<div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
|
|
||||||
</div>
|
|
||||||
<span>{methodLabel(i)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</List>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
<Match when={store.state === "pending"}>
|
|
||||||
<div class="text-14-regular text-text-base">
|
|
||||||
<div class="flex items-center gap-x-2">
|
|
||||||
<Spinner />
|
|
||||||
<span>{language.t("provider.connect.status.inProgress")}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
<Match when={store.state === "error"}>
|
|
||||||
<div class="text-14-regular text-text-base">
|
|
||||||
<div class="flex items-center gap-x-2">
|
|
||||||
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
|
|
||||||
<span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
<Match when={method()?.type === "api"}>
|
|
||||||
{iife(() => {
|
|
||||||
const [formStore, setFormStore] = createStore({
|
|
||||||
value: "",
|
|
||||||
error: undefined as string | undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
async function handleSubmit(e: SubmitEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
|
|
||||||
const form = e.currentTarget as HTMLFormElement
|
|
||||||
const formData = new FormData(form)
|
|
||||||
const apiKey = formData.get("apiKey") as string
|
|
||||||
|
|
||||||
if (!apiKey?.trim()) {
|
|
||||||
setFormStore("error", language.t("provider.connect.apiKey.required"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setFormStore("error", undefined)
|
|
||||||
await globalSDK.client.auth.set({
|
|
||||||
providerID: props.provider,
|
|
||||||
auth: {
|
|
||||||
type: "api",
|
|
||||||
key: apiKey,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
await complete()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col gap-6">
|
|
||||||
<Switch>
|
|
||||||
<Match when={provider().id === "opencode"}>
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<div class="text-14-regular text-text-base">
|
|
||||||
{language.t("provider.connect.opencodeZen.line1")}
|
|
||||||
</div>
|
|
||||||
<div class="text-14-regular text-text-base">
|
|
||||||
{language.t("provider.connect.opencodeZen.line2")}
|
|
||||||
</div>
|
|
||||||
<div class="text-14-regular text-text-base">
|
|
||||||
{language.t("provider.connect.opencodeZen.visit.prefix")}
|
|
||||||
<Link href="https://opencode.ai/zen" tabIndex={-1}>
|
|
||||||
{language.t("provider.connect.opencodeZen.visit.link")}
|
|
||||||
</Link>
|
|
||||||
{language.t("provider.connect.opencodeZen.visit.suffix")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
<Match when={true}>
|
|
||||||
<div class="text-14-regular text-text-base">
|
|
||||||
{language.t("provider.connect.apiKey.description", { provider: provider().name })}
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
|
||||||
<TextField
|
|
||||||
autofocus
|
|
||||||
type="text"
|
|
||||||
label={language.t("provider.connect.apiKey.label", { provider: provider().name })}
|
|
||||||
placeholder={language.t("provider.connect.apiKey.placeholder")}
|
|
||||||
name="apiKey"
|
|
||||||
value={formStore.value}
|
|
||||||
onChange={setFormStore.bind(null, "value")}
|
|
||||||
validationState={formStore.error ? "invalid" : undefined}
|
|
||||||
error={formStore.error}
|
|
||||||
/>
|
|
||||||
<Button class="w-auto" type="submit" size="large" variant="primary">
|
|
||||||
{language.t("common.submit")}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
</div>
|
||||||
})}
|
</Match>
|
||||||
</Match>
|
<Match when={store.state === "error"}>
|
||||||
<Match when={method()?.type === "oauth"}>
|
<div class="text-14-regular text-text-base">
|
||||||
<Switch>
|
<div class="flex items-center gap-x-2">
|
||||||
<Match when={store.authorization?.method === "code"}>
|
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
|
||||||
{iife(() => {
|
<span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
|
||||||
const [formStore, setFormStore] = createStore({
|
</div>
|
||||||
value: "",
|
</div>
|
||||||
error: undefined as string | undefined,
|
</Match>
|
||||||
})
|
<Match when={method()?.type === "api"}>
|
||||||
|
<ApiAuthView />
|
||||||
onMount(() => {
|
</Match>
|
||||||
if (store.authorization?.method === "code" && store.authorization?.url) {
|
<Match when={method()?.type === "oauth"}>
|
||||||
platform.openLink(store.authorization.url)
|
<Switch>
|
||||||
}
|
<Match when={store.authorization?.method === "code"}>
|
||||||
})
|
<OAuthCodeView />
|
||||||
|
</Match>
|
||||||
async function handleSubmit(e: SubmitEvent) {
|
<Match when={store.authorization?.method === "auto"}>
|
||||||
e.preventDefault()
|
<OAuthAutoView />
|
||||||
|
</Match>
|
||||||
const form = e.currentTarget as HTMLFormElement
|
</Switch>
|
||||||
const formData = new FormData(form)
|
</Match>
|
||||||
const code = formData.get("code") as string
|
</Switch>
|
||||||
|
</div>
|
||||||
if (!code?.trim()) {
|
|
||||||
setFormStore("error", language.t("provider.connect.oauth.code.required"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setFormStore("error", undefined)
|
|
||||||
const result = await globalSDK.client.provider.oauth
|
|
||||||
.callback({
|
|
||||||
providerID: props.provider,
|
|
||||||
method: store.methodIndex,
|
|
||||||
code,
|
|
||||||
})
|
|
||||||
.then((value) =>
|
|
||||||
value.error ? { ok: false as const, error: value.error } : { ok: true as const },
|
|
||||||
)
|
|
||||||
.catch((error) => ({ ok: false as const, error }))
|
|
||||||
if (result.ok) {
|
|
||||||
await complete()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const message = result.error instanceof Error ? result.error.message : String(result.error)
|
|
||||||
setFormStore("error", message || language.t("provider.connect.oauth.code.invalid"))
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col gap-6">
|
|
||||||
<div class="text-14-regular text-text-base">
|
|
||||||
{language.t("provider.connect.oauth.code.visit.prefix")}
|
|
||||||
<Link href={store.authorization!.url}>
|
|
||||||
{language.t("provider.connect.oauth.code.visit.link")}
|
|
||||||
</Link>
|
|
||||||
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
|
|
||||||
</div>
|
|
||||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
|
||||||
<TextField
|
|
||||||
autofocus
|
|
||||||
type="text"
|
|
||||||
label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
|
|
||||||
placeholder={language.t("provider.connect.oauth.code.placeholder")}
|
|
||||||
name="code"
|
|
||||||
value={formStore.value}
|
|
||||||
onChange={setFormStore.bind(null, "value")}
|
|
||||||
validationState={formStore.error ? "invalid" : undefined}
|
|
||||||
error={formStore.error}
|
|
||||||
/>
|
|
||||||
<Button class="w-auto" type="submit" size="large" variant="primary">
|
|
||||||
{language.t("common.submit")}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Match>
|
|
||||||
<Match when={store.authorization?.method === "auto"}>
|
|
||||||
{iife(() => {
|
|
||||||
const code = createMemo(() => {
|
|
||||||
const instructions = store.authorization?.instructions
|
|
||||||
if (instructions?.includes(":")) {
|
|
||||||
return instructions?.split(":")[1]?.trim()
|
|
||||||
}
|
|
||||||
return instructions
|
|
||||||
})
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
void (async () => {
|
|
||||||
if (store.authorization?.url) {
|
|
||||||
platform.openLink(store.authorization.url)
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await globalSDK.client.provider.oauth
|
|
||||||
.callback({
|
|
||||||
providerID: props.provider,
|
|
||||||
method: store.methodIndex,
|
|
||||||
})
|
|
||||||
.then((value) =>
|
|
||||||
value.error ? { ok: false as const, error: value.error } : { ok: true as const },
|
|
||||||
)
|
|
||||||
.catch((error) => ({ ok: false as const, error }))
|
|
||||||
|
|
||||||
if (!alive.value) return
|
|
||||||
|
|
||||||
if (!result.ok) {
|
|
||||||
const message = result.error instanceof Error ? result.error.message : String(result.error)
|
|
||||||
setStore("state", "error")
|
|
||||||
setStore("error", message)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await complete()
|
|
||||||
})()
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col gap-6">
|
|
||||||
<div class="text-14-regular text-text-base">
|
|
||||||
{language.t("provider.connect.oauth.auto.visit.prefix")}
|
|
||||||
<Link href={store.authorization!.url}>
|
|
||||||
{language.t("provider.connect.oauth.auto.visit.link")}
|
|
||||||
</Link>
|
|
||||||
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
|
|
||||||
</div>
|
|
||||||
<TextField
|
|
||||||
label={language.t("provider.connect.oauth.auto.confirmationCode")}
|
|
||||||
class="font-mono"
|
|
||||||
value={code()}
|
|
||||||
readOnly
|
|
||||||
copyable
|
|
||||||
/>
|
|
||||||
<div class="text-14-regular text-text-base flex items-center gap-4">
|
|
||||||
<Spinner />
|
|
||||||
<span>{language.t("provider.connect.status.waiting")}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
|||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { For } from "solid-js"
|
import { For } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { Link } from "@/components/link"
|
import { Link } from "@/components/link"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
@@ -16,6 +16,147 @@ import { DialogSelectProvider } from "./dialog-select-provider"
|
|||||||
const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
|
const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
|
||||||
const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
|
const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
|
||||||
|
|
||||||
|
type Translator = ReturnType<typeof useLanguage>["t"]
|
||||||
|
|
||||||
|
type ModelRow = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type HeaderRow = {
|
||||||
|
key: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormState = {
|
||||||
|
providerID: string
|
||||||
|
name: string
|
||||||
|
baseURL: string
|
||||||
|
apiKey: string
|
||||||
|
models: ModelRow[]
|
||||||
|
headers: HeaderRow[]
|
||||||
|
saving: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormErrors = {
|
||||||
|
providerID: string | undefined
|
||||||
|
name: string | undefined
|
||||||
|
baseURL: string | undefined
|
||||||
|
models: Array<{ id?: string; name?: string }>
|
||||||
|
headers: Array<{ key?: string; value?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
type ValidateArgs = {
|
||||||
|
form: FormState
|
||||||
|
t: Translator
|
||||||
|
disabledProviders: string[]
|
||||||
|
existingProviderIDs: Set<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCustomProvider(input: ValidateArgs) {
|
||||||
|
const providerID = input.form.providerID.trim()
|
||||||
|
const name = input.form.name.trim()
|
||||||
|
const baseURL = input.form.baseURL.trim()
|
||||||
|
const apiKey = input.form.apiKey.trim()
|
||||||
|
|
||||||
|
const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
|
||||||
|
const key = apiKey && !env ? apiKey : undefined
|
||||||
|
|
||||||
|
const idError = !providerID
|
||||||
|
? input.t("provider.custom.error.providerID.required")
|
||||||
|
: !PROVIDER_ID.test(providerID)
|
||||||
|
? input.t("provider.custom.error.providerID.format")
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const nameError = !name ? input.t("provider.custom.error.name.required") : undefined
|
||||||
|
const urlError = !baseURL
|
||||||
|
? input.t("provider.custom.error.baseURL.required")
|
||||||
|
: !/^https?:\/\//.test(baseURL)
|
||||||
|
? input.t("provider.custom.error.baseURL.format")
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const disabled = input.disabledProviders.includes(providerID)
|
||||||
|
const existsError = idError
|
||||||
|
? undefined
|
||||||
|
: input.existingProviderIDs.has(providerID) && !disabled
|
||||||
|
? input.t("provider.custom.error.providerID.exists")
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const seenModels = new Set<string>()
|
||||||
|
const modelErrors = input.form.models.map((m) => {
|
||||||
|
const id = m.id.trim()
|
||||||
|
const modelIdError = !id
|
||||||
|
? input.t("provider.custom.error.required")
|
||||||
|
: seenModels.has(id)
|
||||||
|
? input.t("provider.custom.error.duplicate")
|
||||||
|
: (() => {
|
||||||
|
seenModels.add(id)
|
||||||
|
return undefined
|
||||||
|
})()
|
||||||
|
const modelNameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined
|
||||||
|
return { id: modelIdError, name: modelNameError }
|
||||||
|
})
|
||||||
|
const modelsValid = modelErrors.every((m) => !m.id && !m.name)
|
||||||
|
const models = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
|
||||||
|
|
||||||
|
const seenHeaders = new Set<string>()
|
||||||
|
const headerErrors = input.form.headers.map((h) => {
|
||||||
|
const key = h.key.trim()
|
||||||
|
const value = h.value.trim()
|
||||||
|
|
||||||
|
if (!key && !value) return {}
|
||||||
|
const keyError = !key
|
||||||
|
? input.t("provider.custom.error.required")
|
||||||
|
: seenHeaders.has(key.toLowerCase())
|
||||||
|
? input.t("provider.custom.error.duplicate")
|
||||||
|
: (() => {
|
||||||
|
seenHeaders.add(key.toLowerCase())
|
||||||
|
return undefined
|
||||||
|
})()
|
||||||
|
const valueError = !value ? input.t("provider.custom.error.required") : undefined
|
||||||
|
return { key: keyError, value: valueError }
|
||||||
|
})
|
||||||
|
const headersValid = headerErrors.every((h) => !h.key && !h.value)
|
||||||
|
const headers = Object.fromEntries(
|
||||||
|
input.form.headers
|
||||||
|
.map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
|
||||||
|
.filter((h) => !!h.key && !!h.value)
|
||||||
|
.map((h) => [h.key, h.value]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const errors: FormErrors = {
|
||||||
|
providerID: idError ?? existsError,
|
||||||
|
name: nameError,
|
||||||
|
baseURL: urlError,
|
||||||
|
models: modelErrors,
|
||||||
|
headers: headerErrors,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
|
||||||
|
if (!ok) return { errors }
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
baseURL,
|
||||||
|
...(Object.keys(headers).length ? { headers } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
errors,
|
||||||
|
result: {
|
||||||
|
providerID,
|
||||||
|
name,
|
||||||
|
key,
|
||||||
|
config: {
|
||||||
|
npm: OPENAI_COMPATIBLE,
|
||||||
|
name,
|
||||||
|
...(env ? { env: [env] } : {}),
|
||||||
|
options,
|
||||||
|
models,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
back?: "providers" | "close"
|
back?: "providers" | "close"
|
||||||
}
|
}
|
||||||
@@ -26,7 +167,7 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
const [form, setForm] = createStore({
|
const [form, setForm] = createStore<FormState>({
|
||||||
providerID: "",
|
providerID: "",
|
||||||
name: "",
|
name: "",
|
||||||
baseURL: "",
|
baseURL: "",
|
||||||
@@ -36,12 +177,12 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
saving: false,
|
saving: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const [errors, setErrors] = createStore({
|
const [errors, setErrors] = createStore<FormErrors>({
|
||||||
providerID: undefined as string | undefined,
|
providerID: undefined,
|
||||||
name: undefined as string | undefined,
|
name: undefined,
|
||||||
baseURL: undefined as string | undefined,
|
baseURL: undefined,
|
||||||
models: [{} as { id?: string; name?: string }],
|
models: [{}],
|
||||||
headers: [{} as { key?: string; value?: string }],
|
headers: [{}],
|
||||||
})
|
})
|
||||||
|
|
||||||
const goBack = () => {
|
const goBack = () => {
|
||||||
@@ -53,169 +194,36 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const addModel = () => {
|
const addModel = () => {
|
||||||
setForm(
|
setForm("models", (v) => [...v, { id: "", name: "" }])
|
||||||
"models",
|
setErrors("models", (v) => [...v, {}])
|
||||||
produce((draft) => {
|
|
||||||
draft.push({ id: "", name: "" })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
setErrors(
|
|
||||||
"models",
|
|
||||||
produce((draft) => {
|
|
||||||
draft.push({})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeModel = (index: number) => {
|
const removeModel = (index: number) => {
|
||||||
if (form.models.length <= 1) return
|
if (form.models.length <= 1) return
|
||||||
setForm(
|
setForm("models", (v) => v.filter((_, i) => i !== index))
|
||||||
"models",
|
setErrors("models", (v) => v.filter((_, i) => i !== index))
|
||||||
produce((draft) => {
|
|
||||||
draft.splice(index, 1)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
setErrors(
|
|
||||||
"models",
|
|
||||||
produce((draft) => {
|
|
||||||
draft.splice(index, 1)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const addHeader = () => {
|
const addHeader = () => {
|
||||||
setForm(
|
setForm("headers", (v) => [...v, { key: "", value: "" }])
|
||||||
"headers",
|
setErrors("headers", (v) => [...v, {}])
|
||||||
produce((draft) => {
|
|
||||||
draft.push({ key: "", value: "" })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
setErrors(
|
|
||||||
"headers",
|
|
||||||
produce((draft) => {
|
|
||||||
draft.push({})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeHeader = (index: number) => {
|
const removeHeader = (index: number) => {
|
||||||
if (form.headers.length <= 1) return
|
if (form.headers.length <= 1) return
|
||||||
setForm(
|
setForm("headers", (v) => v.filter((_, i) => i !== index))
|
||||||
"headers",
|
setErrors("headers", (v) => v.filter((_, i) => i !== index))
|
||||||
produce((draft) => {
|
|
||||||
draft.splice(index, 1)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
setErrors(
|
|
||||||
"headers",
|
|
||||||
produce((draft) => {
|
|
||||||
draft.splice(index, 1)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const validate = () => {
|
const validate = () => {
|
||||||
const providerID = form.providerID.trim()
|
const output = validateCustomProvider({
|
||||||
const name = form.name.trim()
|
form,
|
||||||
const baseURL = form.baseURL.trim()
|
t: language.t,
|
||||||
const apiKey = form.apiKey.trim()
|
disabledProviders: globalSync.data.config.disabled_providers ?? [],
|
||||||
|
existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)),
|
||||||
const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
|
|
||||||
const key = apiKey && !env ? apiKey : undefined
|
|
||||||
|
|
||||||
const idError = !providerID
|
|
||||||
? language.t("provider.custom.error.providerID.required")
|
|
||||||
: !PROVIDER_ID.test(providerID)
|
|
||||||
? language.t("provider.custom.error.providerID.format")
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const nameError = !name ? language.t("provider.custom.error.name.required") : undefined
|
|
||||||
const urlError = !baseURL
|
|
||||||
? language.t("provider.custom.error.baseURL.required")
|
|
||||||
: !/^https?:\/\//.test(baseURL)
|
|
||||||
? language.t("provider.custom.error.baseURL.format")
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const disabled = (globalSync.data.config.disabled_providers ?? []).includes(providerID)
|
|
||||||
const existingProvider = globalSync.data.provider.all.find((p) => p.id === providerID)
|
|
||||||
const existsError = idError
|
|
||||||
? undefined
|
|
||||||
: existingProvider && !disabled
|
|
||||||
? language.t("provider.custom.error.providerID.exists")
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const seenModels = new Set<string>()
|
|
||||||
const modelErrors = form.models.map((m) => {
|
|
||||||
const id = m.id.trim()
|
|
||||||
const modelIdError = !id
|
|
||||||
? language.t("provider.custom.error.required")
|
|
||||||
: seenModels.has(id)
|
|
||||||
? language.t("provider.custom.error.duplicate")
|
|
||||||
: (() => {
|
|
||||||
seenModels.add(id)
|
|
||||||
return undefined
|
|
||||||
})()
|
|
||||||
const modelNameError = !m.name.trim() ? language.t("provider.custom.error.required") : undefined
|
|
||||||
return { id: modelIdError, name: modelNameError }
|
|
||||||
})
|
})
|
||||||
const modelsValid = modelErrors.every((m) => !m.id && !m.name)
|
setErrors(output.errors)
|
||||||
const models = Object.fromEntries(form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
|
return output.result
|
||||||
|
|
||||||
const seenHeaders = new Set<string>()
|
|
||||||
const headerErrors = form.headers.map((h) => {
|
|
||||||
const key = h.key.trim()
|
|
||||||
const value = h.value.trim()
|
|
||||||
|
|
||||||
if (!key && !value) return {}
|
|
||||||
const keyError = !key
|
|
||||||
? language.t("provider.custom.error.required")
|
|
||||||
: seenHeaders.has(key.toLowerCase())
|
|
||||||
? language.t("provider.custom.error.duplicate")
|
|
||||||
: (() => {
|
|
||||||
seenHeaders.add(key.toLowerCase())
|
|
||||||
return undefined
|
|
||||||
})()
|
|
||||||
const valueError = !value ? language.t("provider.custom.error.required") : undefined
|
|
||||||
return { key: keyError, value: valueError }
|
|
||||||
})
|
|
||||||
const headersValid = headerErrors.every((h) => !h.key && !h.value)
|
|
||||||
const headers = Object.fromEntries(
|
|
||||||
form.headers
|
|
||||||
.map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
|
|
||||||
.filter((h) => !!h.key && !!h.value)
|
|
||||||
.map((h) => [h.key, h.value]),
|
|
||||||
)
|
|
||||||
|
|
||||||
setErrors(
|
|
||||||
produce((draft) => {
|
|
||||||
draft.providerID = idError ?? existsError
|
|
||||||
draft.name = nameError
|
|
||||||
draft.baseURL = urlError
|
|
||||||
draft.models = modelErrors
|
|
||||||
draft.headers = headerErrors
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
|
|
||||||
if (!ok) return
|
|
||||||
|
|
||||||
const options = {
|
|
||||||
baseURL,
|
|
||||||
...(Object.keys(headers).length ? { headers } : {}),
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
providerID,
|
|
||||||
name,
|
|
||||||
key,
|
|
||||||
config: {
|
|
||||||
npm: OPENAI_COMPATIBLE,
|
|
||||||
name,
|
|
||||||
...(env ? { env: [env] } : {}),
|
|
||||||
options,
|
|
||||||
models,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const save = async (e: SubmitEvent) => {
|
const save = async (e: SubmitEvent) => {
|
||||||
@@ -297,7 +305,7 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
placeholder={language.t("provider.custom.field.providerID.placeholder")}
|
placeholder={language.t("provider.custom.field.providerID.placeholder")}
|
||||||
description={language.t("provider.custom.field.providerID.description")}
|
description={language.t("provider.custom.field.providerID.description")}
|
||||||
value={form.providerID}
|
value={form.providerID}
|
||||||
onChange={setForm.bind(null, "providerID")}
|
onChange={(v) => setForm("providerID", v)}
|
||||||
validationState={errors.providerID ? "invalid" : undefined}
|
validationState={errors.providerID ? "invalid" : undefined}
|
||||||
error={errors.providerID}
|
error={errors.providerID}
|
||||||
/>
|
/>
|
||||||
@@ -305,7 +313,7 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
label={language.t("provider.custom.field.name.label")}
|
label={language.t("provider.custom.field.name.label")}
|
||||||
placeholder={language.t("provider.custom.field.name.placeholder")}
|
placeholder={language.t("provider.custom.field.name.placeholder")}
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={setForm.bind(null, "name")}
|
onChange={(v) => setForm("name", v)}
|
||||||
validationState={errors.name ? "invalid" : undefined}
|
validationState={errors.name ? "invalid" : undefined}
|
||||||
error={errors.name}
|
error={errors.name}
|
||||||
/>
|
/>
|
||||||
@@ -313,7 +321,7 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
label={language.t("provider.custom.field.baseURL.label")}
|
label={language.t("provider.custom.field.baseURL.label")}
|
||||||
placeholder={language.t("provider.custom.field.baseURL.placeholder")}
|
placeholder={language.t("provider.custom.field.baseURL.placeholder")}
|
||||||
value={form.baseURL}
|
value={form.baseURL}
|
||||||
onChange={setForm.bind(null, "baseURL")}
|
onChange={(v) => setForm("baseURL", v)}
|
||||||
validationState={errors.baseURL ? "invalid" : undefined}
|
validationState={errors.baseURL ? "invalid" : undefined}
|
||||||
error={errors.baseURL}
|
error={errors.baseURL}
|
||||||
/>
|
/>
|
||||||
@@ -322,7 +330,7 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
placeholder={language.t("provider.custom.field.apiKey.placeholder")}
|
placeholder={language.t("provider.custom.field.apiKey.placeholder")}
|
||||||
description={language.t("provider.custom.field.apiKey.description")}
|
description={language.t("provider.custom.field.apiKey.description")}
|
||||||
value={form.apiKey}
|
value={form.apiKey}
|
||||||
onChange={setForm.bind(null, "apiKey")}
|
onChange={(v) => setForm("apiKey", v)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||||||
iconHover: false,
|
iconHover: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let iconInput: HTMLInputElement | undefined
|
||||||
|
|
||||||
function handleFileSelect(file: File) {
|
function handleFileSelect(file: File) {
|
||||||
if (!file.type.startsWith("image/")) return
|
if (!file.type.startsWith("image/")) return
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
@@ -72,31 +74,35 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||||||
async function handleSubmit(e: SubmitEvent) {
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
setStore("saving", true)
|
await Promise.resolve()
|
||||||
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
.then(async () => {
|
||||||
const start = store.startup.trim()
|
setStore("saving", true)
|
||||||
|
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
||||||
|
const start = store.startup.trim()
|
||||||
|
|
||||||
if (props.project.id && props.project.id !== "global") {
|
if (props.project.id && props.project.id !== "global") {
|
||||||
await globalSDK.client.project.update({
|
await globalSDK.client.project.update({
|
||||||
projectID: props.project.id,
|
projectID: props.project.id,
|
||||||
directory: props.project.worktree,
|
directory: props.project.worktree,
|
||||||
name,
|
name,
|
||||||
icon: { color: store.color, override: store.iconUrl },
|
icon: { color: store.color, override: store.iconUrl },
|
||||||
commands: { start },
|
commands: { start },
|
||||||
|
})
|
||||||
|
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
|
||||||
|
dialog.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
globalSync.project.meta(props.project.worktree, {
|
||||||
|
name,
|
||||||
|
icon: { color: store.color, override: store.iconUrl || undefined },
|
||||||
|
commands: { start: start || undefined },
|
||||||
|
})
|
||||||
|
dialog.close()
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setStore("saving", false)
|
||||||
})
|
})
|
||||||
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
|
|
||||||
setStore("saving", false)
|
|
||||||
dialog.close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
globalSync.project.meta(props.project.worktree, {
|
|
||||||
name,
|
|
||||||
icon: { color: store.color, override: store.iconUrl || undefined },
|
|
||||||
commands: { start: start || undefined },
|
|
||||||
})
|
|
||||||
setStore("saving", false)
|
|
||||||
dialog.close()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -134,7 +140,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||||||
if (store.iconUrl && store.iconHover) {
|
if (store.iconUrl && store.iconHover) {
|
||||||
clearIcon()
|
clearIcon()
|
||||||
} else {
|
} else {
|
||||||
document.getElementById("icon-upload")?.click()
|
iconInput?.click()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -176,7 +182,16 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||||||
<Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
|
<Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input id="icon-upload" type="file" accept="image/*" class="hidden" onChange={handleInputChange} />
|
<input
|
||||||
|
id="icon-upload"
|
||||||
|
ref={(el) => {
|
||||||
|
iconInput = el
|
||||||
|
}}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden"
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
|
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
|
||||||
<span>{language.t("dialog.project.edit.icon.hint")}</span>
|
<span>{language.t("dialog.project.edit.icon.hint")}</span>
|
||||||
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
|
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { usePrompt } from "@/context/prompt"
|
|||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { extractPromptFromParts } from "@/utils/prompt"
|
import { extractPromptFromParts } from "@/utils/prompt"
|
||||||
import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client"
|
import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client"
|
||||||
import { base64Encode } from "@opencode-ai/util/encode"
|
import { base64Encode } from "@opencode-ai/util/encode"
|
||||||
@@ -66,15 +67,23 @@ export const DialogFork: Component = () => {
|
|||||||
attachmentName: language.t("common.attachment"),
|
attachmentName: language.t("common.attachment"),
|
||||||
})
|
})
|
||||||
|
|
||||||
dialog.close()
|
sdk.client.session
|
||||||
|
.fork({ sessionID, messageID: item.id })
|
||||||
sdk.client.session.fork({ sessionID, messageID: item.id }).then((forked) => {
|
.then((forked) => {
|
||||||
if (!forked.data) return
|
if (!forked.data) {
|
||||||
navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`)
|
showToast({ title: language.t("common.requestFailed") })
|
||||||
requestAnimationFrame(() => {
|
return
|
||||||
prompt.set(restored)
|
}
|
||||||
|
dialog.close()
|
||||||
|
navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
prompt.set(restored)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||||
})
|
})
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import { Switch } from "@opencode-ai/ui/switch"
|
import { Switch } from "@opencode-ai/ui/switch"
|
||||||
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import type { Component } from "solid-js"
|
import type { Component } from "solid-js"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
@@ -17,6 +18,15 @@ export const DialogManageModels: Component = () => {
|
|||||||
const handleConnectProvider = () => {
|
const handleConnectProvider = () => {
|
||||||
dialog.show(() => <DialogSelectProvider />)
|
dialog.show(() => <DialogSelectProvider />)
|
||||||
}
|
}
|
||||||
|
const providerRank = (id: string) => popularProviders.indexOf(id)
|
||||||
|
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
|
||||||
|
const providerVisible = (providerID: string) =>
|
||||||
|
providerList(providerID).every((x) => local.model.visible({ modelID: x.id, providerID: x.provider.id }))
|
||||||
|
const setProviderVisibility = (providerID: string, checked: boolean) => {
|
||||||
|
providerList(providerID).forEach((x) => {
|
||||||
|
local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, checked)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -35,21 +45,41 @@ export const DialogManageModels: Component = () => {
|
|||||||
items={local.model.list()}
|
items={local.model.list()}
|
||||||
filterKeys={["provider.name", "name", "id"]}
|
filterKeys={["provider.name", "name", "id"]}
|
||||||
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
||||||
groupBy={(x) => x.provider.name}
|
groupBy={(x) => x.provider.id}
|
||||||
|
groupHeader={(group) => {
|
||||||
|
const provider = group.items[0].provider
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span>{provider.name}</span>
|
||||||
|
<Tooltip
|
||||||
|
placement="top"
|
||||||
|
value={language.t("dialog.model.manage.provider.toggle", { provider: provider.name })}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
class="-mr-1"
|
||||||
|
checked={providerVisible(provider.id)}
|
||||||
|
onChange={(checked) => setProviderVisibility(provider.id, checked)}
|
||||||
|
hideLabel
|
||||||
|
>
|
||||||
|
{provider.name}
|
||||||
|
</Switch>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}}
|
||||||
sortGroupsBy={(a, b) => {
|
sortGroupsBy={(a, b) => {
|
||||||
const aProvider = a.items[0].provider.id
|
const aRank = providerRank(a.items[0].provider.id)
|
||||||
const bProvider = b.items[0].provider.id
|
const bRank = providerRank(b.items[0].provider.id)
|
||||||
if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
|
const aPopular = aRank >= 0
|
||||||
if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
|
const bPopular = bRank >= 0
|
||||||
return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
|
if (aPopular && !bPopular) return -1
|
||||||
|
if (!aPopular && bPopular) return 1
|
||||||
|
return aRank - bRank
|
||||||
}}
|
}}
|
||||||
onSelect={(x) => {
|
onSelect={(x) => {
|
||||||
if (!x) return
|
if (!x) return
|
||||||
const visible = local.model.visible({
|
const key = { modelID: x.id, providerID: x.provider.id }
|
||||||
modelID: x.id,
|
local.model.setVisibility(key, !local.model.visible(key))
|
||||||
providerID: x.provider.id,
|
|
||||||
})
|
|
||||||
local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, !visible)
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(i) => (
|
{(i) => (
|
||||||
@@ -57,12 +87,7 @@ export const DialogManageModels: Component = () => {
|
|||||||
<span>{i.name}</span>
|
<span>{i.name}</span>
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
<Switch
|
<Switch
|
||||||
checked={
|
checked={!!local.model.visible({ modelID: i.id, providerID: i.provider.id })}
|
||||||
!!local.model.visible({
|
|
||||||
modelID: i.id,
|
|
||||||
providerID: i.provider.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
onChange={(checked) => {
|
onChange={(checked) => {
|
||||||
local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked)
|
local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createSignal, createEffect, onMount, onCleanup } from "solid-js"
|
import { createSignal } from "solid-js"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
@@ -40,8 +40,6 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
|
|||||||
handleClose()
|
handleClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
let focusTrap: HTMLDivElement | undefined
|
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -60,27 +58,13 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
focusTrap?.focus()
|
|
||||||
document.addEventListener("keydown", handleKeyDown)
|
|
||||||
onCleanup(() => document.removeEventListener("keydown", handleKeyDown))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Refocus the trap when index changes to ensure escape always works
|
|
||||||
createEffect(() => {
|
|
||||||
index() // track index
|
|
||||||
focusTrap?.focus()
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
size="large"
|
size="large"
|
||||||
fit
|
fit
|
||||||
class="w-[min(calc(100vw-40px),720px)] h-[min(calc(100vh-40px),400px)] -mt-20 min-h-0 overflow-hidden"
|
class="w-[min(calc(100vw-40px),720px)] h-[min(calc(100vh-40px),400px)] -mt-20 min-h-0 overflow-hidden"
|
||||||
>
|
>
|
||||||
{/* Hidden element to capture initial focus and handle escape */}
|
<div class="flex flex-1 min-w-0 min-h-0" tabIndex={0} autofocus onKeyDown={handleKeyDown}>
|
||||||
<div ref={focusTrap} tabindex="0" class="absolute opacity-0 pointer-events-none" />
|
|
||||||
<div class="flex flex-1 min-w-0 min-h-0">
|
|
||||||
{/* Left side - Text content */}
|
{/* Left side - Text content */}
|
||||||
<div class="flex flex-col flex-1 min-w-0 p-8">
|
<div class="flex flex-col flex-1 min-w-0 p-8">
|
||||||
{/* Top section - feature content (fixed position from top) */}
|
{/* Top section - feature content (fixed position from top) */}
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
|
import type { ListRef } from "@opencode-ai/ui/list"
|
||||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||||
import fuzzysort from "fuzzysort"
|
import fuzzysort from "fuzzysort"
|
||||||
import { createMemo, createResource, createSignal } from "solid-js"
|
import { createMemo, createResource, createSignal } from "solid-js"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import type { ListRef } from "@opencode-ai/ui/list"
|
|
||||||
|
|
||||||
interface DialogSelectDirectoryProps {
|
interface DialogSelectDirectoryProps {
|
||||||
title?: string
|
title?: string
|
||||||
@@ -21,157 +21,131 @@ type Row = {
|
|||||||
search: string
|
search: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
function cleanInput(value: string) {
|
||||||
const sync = useGlobalSync()
|
const first = (value ?? "").split(/\r?\n/)[0] ?? ""
|
||||||
const sdk = useGlobalSDK()
|
return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
|
||||||
const dialog = useDialog()
|
}
|
||||||
const language = useLanguage()
|
|
||||||
|
|
||||||
const [filter, setFilter] = createSignal("")
|
function normalizePath(input: string) {
|
||||||
|
const v = input.replaceAll("\\", "/")
|
||||||
|
if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/")
|
||||||
|
return v.replace(/\/+/g, "/")
|
||||||
|
}
|
||||||
|
|
||||||
let list: ListRef | undefined
|
function normalizeDriveRoot(input: string) {
|
||||||
|
const v = normalizePath(input)
|
||||||
|
if (/^[A-Za-z]:$/.test(v)) return v + "/"
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
function trimTrailing(input: string) {
|
||||||
|
const v = normalizeDriveRoot(input)
|
||||||
|
if (v === "/") return v
|
||||||
|
if (v === "//") return v
|
||||||
|
if (/^[A-Za-z]:\/$/.test(v)) return v
|
||||||
|
return v.replace(/\/+$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
const [fallbackPath] = createResource(
|
function joinPath(base: string | undefined, rel: string) {
|
||||||
() => (missingBase() ? true : undefined),
|
const b = trimTrailing(base ?? "")
|
||||||
async () => {
|
const r = trimTrailing(rel).replace(/^\/+/, "")
|
||||||
return sdk.client.path
|
if (!b) return r
|
||||||
.get()
|
if (!r) return b
|
||||||
.then((x) => x.data)
|
if (b.endsWith("/")) return b + r
|
||||||
.catch(() => undefined)
|
return b + "/" + r
|
||||||
},
|
}
|
||||||
{ initialValue: undefined },
|
|
||||||
)
|
|
||||||
|
|
||||||
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
|
function rootOf(input: string) {
|
||||||
|
const v = normalizeDriveRoot(input)
|
||||||
|
if (v.startsWith("//")) return "//"
|
||||||
|
if (v.startsWith("/")) return "/"
|
||||||
|
if (/^[A-Za-z]:\//.test(v)) return v.slice(0, 3)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
const start = createMemo(
|
function parentOf(input: string) {
|
||||||
() => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
|
const v = trimTrailing(input)
|
||||||
)
|
if (v === "/") return v
|
||||||
|
if (v === "//") return v
|
||||||
|
if (/^[A-Za-z]:\/$/.test(v)) return v
|
||||||
|
|
||||||
|
const i = v.lastIndexOf("/")
|
||||||
|
if (i <= 0) return "/"
|
||||||
|
if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3)
|
||||||
|
return v.slice(0, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
function modeOf(input: string) {
|
||||||
|
const raw = normalizeDriveRoot(input.trim())
|
||||||
|
if (!raw) return "relative" as const
|
||||||
|
if (raw.startsWith("~")) return "tilde" as const
|
||||||
|
if (rootOf(raw)) return "absolute" as const
|
||||||
|
return "relative" as const
|
||||||
|
}
|
||||||
|
|
||||||
|
function tildeOf(absolute: string, home: string) {
|
||||||
|
const full = trimTrailing(absolute)
|
||||||
|
if (!home) return ""
|
||||||
|
|
||||||
|
const hn = trimTrailing(home)
|
||||||
|
const lc = full.toLowerCase()
|
||||||
|
const hc = hn.toLowerCase()
|
||||||
|
if (lc === hc) return "~"
|
||||||
|
if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayPath(path: string, input: string, home: string) {
|
||||||
|
const full = trimTrailing(path)
|
||||||
|
if (modeOf(input) === "absolute") return full
|
||||||
|
return tildeOf(full, home) || full
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRow(absolute: string, home: string): Row {
|
||||||
|
const full = trimTrailing(absolute)
|
||||||
|
const tilde = tildeOf(full, home)
|
||||||
|
const withSlash = (value: string) => {
|
||||||
|
if (!value) return ""
|
||||||
|
if (value.endsWith("/")) return value
|
||||||
|
return value + "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
const search = Array.from(
|
||||||
|
new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)),
|
||||||
|
).join("\n")
|
||||||
|
return { absolute: full, search }
|
||||||
|
}
|
||||||
|
|
||||||
|
function useDirectorySearch(args: {
|
||||||
|
sdk: ReturnType<typeof useGlobalSDK>
|
||||||
|
start: () => string | undefined
|
||||||
|
home: () => string
|
||||||
|
}) {
|
||||||
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
|
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
|
||||||
|
let current = 0
|
||||||
|
|
||||||
const clean = (value: string) => {
|
const scoped = (value: string) => {
|
||||||
const first = (value ?? "").split(/\r?\n/)[0] ?? ""
|
const base = args.start()
|
||||||
return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalize(input: string) {
|
|
||||||
const v = input.replaceAll("\\", "/")
|
|
||||||
if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/")
|
|
||||||
return v.replace(/\/+/g, "/")
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDriveRoot(input: string) {
|
|
||||||
const v = normalize(input)
|
|
||||||
if (/^[A-Za-z]:$/.test(v)) return v + "/"
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
function trimTrailing(input: string) {
|
|
||||||
const v = normalizeDriveRoot(input)
|
|
||||||
if (v === "/") return v
|
|
||||||
if (v === "//") return v
|
|
||||||
if (/^[A-Za-z]:\/$/.test(v)) return v
|
|
||||||
return v.replace(/\/+$/, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
function join(base: string | undefined, rel: string) {
|
|
||||||
const b = trimTrailing(base ?? "")
|
|
||||||
const r = trimTrailing(rel).replace(/^\/+/, "")
|
|
||||||
if (!b) return r
|
|
||||||
if (!r) return b
|
|
||||||
if (b.endsWith("/")) return b + r
|
|
||||||
return b + "/" + r
|
|
||||||
}
|
|
||||||
|
|
||||||
function rootOf(input: string) {
|
|
||||||
const v = normalizeDriveRoot(input)
|
|
||||||
if (v.startsWith("//")) return "//"
|
|
||||||
if (v.startsWith("/")) return "/"
|
|
||||||
if (/^[A-Za-z]:\//.test(v)) return v.slice(0, 3)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
function parentOf(input: string) {
|
|
||||||
const v = trimTrailing(input)
|
|
||||||
if (v === "/") return v
|
|
||||||
if (v === "//") return v
|
|
||||||
if (/^[A-Za-z]:\/$/.test(v)) return v
|
|
||||||
|
|
||||||
const i = v.lastIndexOf("/")
|
|
||||||
if (i <= 0) return "/"
|
|
||||||
if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3)
|
|
||||||
return v.slice(0, i)
|
|
||||||
}
|
|
||||||
|
|
||||||
function modeOf(input: string) {
|
|
||||||
const raw = normalizeDriveRoot(input.trim())
|
|
||||||
if (!raw) return "relative" as const
|
|
||||||
if (raw.startsWith("~")) return "tilde" as const
|
|
||||||
if (rootOf(raw)) return "absolute" as const
|
|
||||||
return "relative" as const
|
|
||||||
}
|
|
||||||
|
|
||||||
function display(path: string, input: string) {
|
|
||||||
const full = trimTrailing(path)
|
|
||||||
if (modeOf(input) === "absolute") return full
|
|
||||||
|
|
||||||
return tildeOf(full) || full
|
|
||||||
}
|
|
||||||
|
|
||||||
function tildeOf(absolute: string) {
|
|
||||||
const full = trimTrailing(absolute)
|
|
||||||
const h = home()
|
|
||||||
if (!h) return ""
|
|
||||||
|
|
||||||
const hn = trimTrailing(h)
|
|
||||||
const lc = full.toLowerCase()
|
|
||||||
const hc = hn.toLowerCase()
|
|
||||||
if (lc === hc) return "~"
|
|
||||||
if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
function row(absolute: string): Row {
|
|
||||||
const full = trimTrailing(absolute)
|
|
||||||
const tilde = tildeOf(full)
|
|
||||||
|
|
||||||
const withSlash = (value: string) => {
|
|
||||||
if (!value) return ""
|
|
||||||
if (value.endsWith("/")) return value
|
|
||||||
return value + "/"
|
|
||||||
}
|
|
||||||
|
|
||||||
const search = Array.from(
|
|
||||||
new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)),
|
|
||||||
).join("\n")
|
|
||||||
return { absolute: full, search }
|
|
||||||
}
|
|
||||||
|
|
||||||
function scoped(value: string) {
|
|
||||||
const base = start()
|
|
||||||
if (!base) return
|
if (!base) return
|
||||||
|
|
||||||
const raw = normalizeDriveRoot(value)
|
const raw = normalizeDriveRoot(value)
|
||||||
if (!raw) return { directory: trimTrailing(base), path: "" }
|
if (!raw) return { directory: trimTrailing(base), path: "" }
|
||||||
|
|
||||||
const h = home()
|
const h = args.home()
|
||||||
if (raw === "~") return { directory: trimTrailing(h ?? base), path: "" }
|
if (raw === "~") return { directory: trimTrailing(h || base), path: "" }
|
||||||
if (raw.startsWith("~/")) return { directory: trimTrailing(h ?? base), path: raw.slice(2) }
|
if (raw.startsWith("~/")) return { directory: trimTrailing(h || base), path: raw.slice(2) }
|
||||||
|
|
||||||
const root = rootOf(raw)
|
const root = rootOf(raw)
|
||||||
if (root) return { directory: trimTrailing(root), path: raw.slice(root.length) }
|
if (root) return { directory: trimTrailing(root), path: raw.slice(root.length) }
|
||||||
return { directory: trimTrailing(base), path: raw }
|
return { directory: trimTrailing(base), path: raw }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function dirs(dir: string) {
|
const dirs = async (dir: string) => {
|
||||||
const key = trimTrailing(dir)
|
const key = trimTrailing(dir)
|
||||||
const existing = cache.get(key)
|
const existing = cache.get(key)
|
||||||
if (existing) return existing
|
if (existing) return existing
|
||||||
|
|
||||||
const request = sdk.client.file
|
const request = args.sdk.client.file
|
||||||
.list({ directory: key, path: "" })
|
.list({ directory: key, path: "" })
|
||||||
.then((x) => x.data ?? [])
|
.then((x) => x.data ?? [])
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
@@ -188,32 +162,34 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|
||||||
async function match(dir: string, query: string, limit: number) {
|
const match = async (dir: string, query: string, limit: number) => {
|
||||||
const items = await dirs(dir)
|
const items = await dirs(dir)
|
||||||
if (!query) return items.slice(0, limit).map((x) => x.absolute)
|
if (!query) return items.slice(0, limit).map((x) => x.absolute)
|
||||||
return fuzzysort.go(query, items, { key: "name", limit }).map((x) => x.obj.absolute)
|
return fuzzysort.go(query, items, { key: "name", limit }).map((x) => x.obj.absolute)
|
||||||
}
|
}
|
||||||
|
|
||||||
const directories = async (filter: string) => {
|
return async (filter: string) => {
|
||||||
const value = clean(filter)
|
const token = ++current
|
||||||
|
const active = () => token === current
|
||||||
|
|
||||||
|
const value = cleanInput(filter)
|
||||||
const scopedInput = scoped(value)
|
const scopedInput = scoped(value)
|
||||||
if (!scopedInput) return [] as string[]
|
if (!scopedInput) return [] as string[]
|
||||||
|
|
||||||
const raw = normalizeDriveRoot(value)
|
const raw = normalizeDriveRoot(value)
|
||||||
const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/")
|
const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/")
|
||||||
|
|
||||||
const query = normalizeDriveRoot(scopedInput.path)
|
const query = normalizeDriveRoot(scopedInput.path)
|
||||||
|
|
||||||
const find = () =>
|
const find = () =>
|
||||||
sdk.client.find
|
args.sdk.client.find
|
||||||
.files({ directory: scopedInput.directory, query, type: "directory", limit: 50 })
|
.files({ directory: scopedInput.directory, query, type: "directory", limit: 50 })
|
||||||
.then((x) => x.data ?? [])
|
.then((x) => x.data ?? [])
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
|
|
||||||
if (!isPath) {
|
if (!isPath) {
|
||||||
const results = await find()
|
const results = await find()
|
||||||
|
if (!active()) return []
|
||||||
return results.map((rel) => join(scopedInput.directory, rel)).slice(0, 50)
|
return results.map((rel) => joinPath(scopedInput.directory, rel)).slice(0, 50)
|
||||||
}
|
}
|
||||||
|
|
||||||
const segments = query.replace(/^\/+/, "").split("/")
|
const segments = query.replace(/^\/+/, "").split("/")
|
||||||
@@ -224,17 +200,20 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
const branch = 4
|
const branch = 4
|
||||||
let paths = [scopedInput.directory]
|
let paths = [scopedInput.directory]
|
||||||
for (const part of head) {
|
for (const part of head) {
|
||||||
|
if (!active()) return []
|
||||||
if (part === "..") {
|
if (part === "..") {
|
||||||
paths = paths.map(parentOf)
|
paths = paths.map(parentOf)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = (await Promise.all(paths.map((p) => match(p, part, branch)))).flat()
|
const next = (await Promise.all(paths.map((p) => match(p, part, branch)))).flat()
|
||||||
|
if (!active()) return []
|
||||||
paths = Array.from(new Set(next)).slice(0, cap)
|
paths = Array.from(new Set(next)).slice(0, cap)
|
||||||
if (paths.length === 0) return [] as string[]
|
if (paths.length === 0) return [] as string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat()
|
const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat()
|
||||||
|
if (!active()) return []
|
||||||
const deduped = Array.from(new Set(out))
|
const deduped = Array.from(new Set(out))
|
||||||
const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : ""
|
const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : ""
|
||||||
const expand = !raw.endsWith("/")
|
const expand = !raw.endsWith("/")
|
||||||
@@ -249,13 +228,47 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
if (!target) return deduped.slice(0, 50)
|
if (!target) return deduped.slice(0, 50)
|
||||||
|
|
||||||
const children = await match(target, "", 30)
|
const children = await match(target, "", 30)
|
||||||
|
if (!active()) return []
|
||||||
const items = Array.from(new Set([...deduped, ...children]))
|
const items = Array.from(new Set([...deduped, ...children]))
|
||||||
return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50)
|
return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||||
|
const sync = useGlobalSync()
|
||||||
|
const sdk = useGlobalSDK()
|
||||||
|
const dialog = useDialog()
|
||||||
|
const language = useLanguage()
|
||||||
|
|
||||||
|
const [filter, setFilter] = createSignal("")
|
||||||
|
let list: ListRef | undefined
|
||||||
|
|
||||||
|
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
||||||
|
const [fallbackPath] = createResource(
|
||||||
|
() => (missingBase() ? true : undefined),
|
||||||
|
async () => {
|
||||||
|
return sdk.client.path
|
||||||
|
.get()
|
||||||
|
.then((x) => x.data)
|
||||||
|
.catch(() => undefined)
|
||||||
|
},
|
||||||
|
{ initialValue: undefined },
|
||||||
|
)
|
||||||
|
|
||||||
|
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
|
||||||
|
const start = createMemo(
|
||||||
|
() => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
|
||||||
|
)
|
||||||
|
|
||||||
|
const directories = useDirectorySearch({
|
||||||
|
sdk,
|
||||||
|
home,
|
||||||
|
start,
|
||||||
|
})
|
||||||
|
|
||||||
const items = async (value: string) => {
|
const items = async (value: string) => {
|
||||||
const results = await directories(value)
|
const results = await directories(value)
|
||||||
return results.map(row)
|
return results.map((absolute) => toRow(absolute, home()))
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolve(absolute: string) {
|
function resolve(absolute: string) {
|
||||||
@@ -273,7 +286,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
key={(x) => x.absolute}
|
key={(x) => x.absolute}
|
||||||
filterKeys={["search"]}
|
filterKeys={["search"]}
|
||||||
ref={(r) => (list = r)}
|
ref={(r) => (list = r)}
|
||||||
onFilter={(value) => setFilter(clean(value))}
|
onFilter={(value) => setFilter(cleanInput(value))}
|
||||||
onKeyEvent={(e, item) => {
|
onKeyEvent={(e, item) => {
|
||||||
if (e.key !== "Tab") return
|
if (e.key !== "Tab") return
|
||||||
if (e.shiftKey) return
|
if (e.shiftKey) return
|
||||||
@@ -282,7 +295,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
||||||
const value = display(item.absolute, filter())
|
const value = displayPath(item.absolute, filter(), home())
|
||||||
list?.setFilter(value.endsWith("/") ? value : value + "/")
|
list?.setFilter(value.endsWith("/") ? value : value + "/")
|
||||||
}}
|
}}
|
||||||
onSelect={(path) => {
|
onSelect={(path) => {
|
||||||
@@ -291,7 +304,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(item) => {
|
{(item) => {
|
||||||
const path = display(item.absolute, filter())
|
const path = displayPath(item.absolute, filter(), home())
|
||||||
if (path === "~") {
|
if (path === "~") {
|
||||||
return (
|
return (
|
||||||
<div class="w-full flex items-center justify-between rounded-md">
|
<div class="w-full flex items-center justify-between rounded-md">
|
||||||
|
|||||||
@@ -36,6 +36,223 @@ type Entry = {
|
|||||||
|
|
||||||
type DialogSelectFileMode = "all" | "files"
|
type DialogSelectFileMode = "all" | "files"
|
||||||
|
|
||||||
|
const ENTRY_LIMIT = 5
|
||||||
|
const COMMON_COMMAND_IDS = [
|
||||||
|
"session.new",
|
||||||
|
"workspace.new",
|
||||||
|
"session.previous",
|
||||||
|
"session.next",
|
||||||
|
"terminal.toggle",
|
||||||
|
"review.toggle",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const uniqueEntries = (items: Entry[]) => {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const out: Entry[] = []
|
||||||
|
for (const item of items) {
|
||||||
|
if (seen.has(item.id)) continue
|
||||||
|
seen.add(item.id)
|
||||||
|
out.push(item)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const createCommandEntry = (option: CommandOption, category: string): Entry => ({
|
||||||
|
id: "command:" + option.id,
|
||||||
|
type: "command",
|
||||||
|
title: option.title,
|
||||||
|
description: option.description,
|
||||||
|
keybind: option.keybind,
|
||||||
|
category,
|
||||||
|
option,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createFileEntry = (path: string, category: string): Entry => ({
|
||||||
|
id: "file:" + path,
|
||||||
|
type: "file",
|
||||||
|
title: path,
|
||||||
|
category,
|
||||||
|
path,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createSessionEntry = (
|
||||||
|
input: {
|
||||||
|
directory: string
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
archived?: number
|
||||||
|
updated?: number
|
||||||
|
},
|
||||||
|
category: string,
|
||||||
|
): Entry => ({
|
||||||
|
id: `session:${input.directory}:${input.id}`,
|
||||||
|
type: "session",
|
||||||
|
title: input.title,
|
||||||
|
description: input.description,
|
||||||
|
category,
|
||||||
|
directory: input.directory,
|
||||||
|
sessionID: input.id,
|
||||||
|
archived: input.archived,
|
||||||
|
updated: input.updated,
|
||||||
|
})
|
||||||
|
|
||||||
|
function createCommandEntries(props: {
|
||||||
|
filesOnly: () => boolean
|
||||||
|
command: ReturnType<typeof useCommand>
|
||||||
|
language: ReturnType<typeof useLanguage>
|
||||||
|
}) {
|
||||||
|
const allowed = createMemo(() => {
|
||||||
|
if (props.filesOnly()) return []
|
||||||
|
return props.command.options.filter(
|
||||||
|
(option) => !option.disabled && !option.id.startsWith("suggested.") && option.id !== "file.open",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = createMemo(() => {
|
||||||
|
const category = props.language.t("palette.group.commands")
|
||||||
|
return allowed().map((option) => createCommandEntry(option, category))
|
||||||
|
})
|
||||||
|
|
||||||
|
const picks = createMemo(() => {
|
||||||
|
const all = allowed()
|
||||||
|
const order = new Map<string, number>(COMMON_COMMAND_IDS.map((id, index) => [id, index]))
|
||||||
|
const picked = all.filter((option) => order.has(option.id))
|
||||||
|
const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
|
||||||
|
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
|
||||||
|
const category = props.language.t("palette.group.commands")
|
||||||
|
return sorted.map((option) => createCommandEntry(option, category))
|
||||||
|
})
|
||||||
|
|
||||||
|
return { allowed, list, picks }
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFileEntries(props: {
|
||||||
|
file: ReturnType<typeof useFile>
|
||||||
|
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
|
||||||
|
language: ReturnType<typeof useLanguage>
|
||||||
|
}) {
|
||||||
|
const recent = createMemo(() => {
|
||||||
|
const all = props.tabs().all()
|
||||||
|
const active = props.tabs().active()
|
||||||
|
const order = active ? [active, ...all.filter((item) => item !== active)] : all
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const category = props.language.t("palette.group.files")
|
||||||
|
const items: Entry[] = []
|
||||||
|
|
||||||
|
for (const item of order) {
|
||||||
|
const path = props.file.pathFromTab(item)
|
||||||
|
if (!path) continue
|
||||||
|
if (seen.has(path)) continue
|
||||||
|
seen.add(path)
|
||||||
|
items.push(createFileEntry(path, category))
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.slice(0, ENTRY_LIMIT)
|
||||||
|
})
|
||||||
|
|
||||||
|
const root = createMemo(() => {
|
||||||
|
const category = props.language.t("palette.group.files")
|
||||||
|
const nodes = props.file.tree.children("")
|
||||||
|
const paths = nodes
|
||||||
|
.filter((node) => node.type === "file")
|
||||||
|
.map((node) => node.path)
|
||||||
|
.sort((a, b) => a.localeCompare(b))
|
||||||
|
return paths.slice(0, ENTRY_LIMIT).map((path) => createFileEntry(path, category))
|
||||||
|
})
|
||||||
|
|
||||||
|
return { recent, root }
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSessionEntries(props: {
|
||||||
|
workspaces: () => string[]
|
||||||
|
label: (directory: string) => string
|
||||||
|
globalSDK: ReturnType<typeof useGlobalSDK>
|
||||||
|
language: ReturnType<typeof useLanguage>
|
||||||
|
}) {
|
||||||
|
const state: {
|
||||||
|
token: number
|
||||||
|
inflight: Promise<Entry[]> | undefined
|
||||||
|
cached: Entry[] | undefined
|
||||||
|
} = {
|
||||||
|
token: 0,
|
||||||
|
inflight: undefined,
|
||||||
|
cached: undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = (text: string) => {
|
||||||
|
const query = text.trim()
|
||||||
|
if (!query) {
|
||||||
|
state.token += 1
|
||||||
|
state.inflight = undefined
|
||||||
|
state.cached = undefined
|
||||||
|
return [] as Entry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.cached) return state.cached
|
||||||
|
if (state.inflight) return state.inflight
|
||||||
|
|
||||||
|
const current = state.token
|
||||||
|
const dirs = props.workspaces()
|
||||||
|
if (dirs.length === 0) return [] as Entry[]
|
||||||
|
|
||||||
|
state.inflight = Promise.all(
|
||||||
|
dirs.map((directory) => {
|
||||||
|
const description = props.label(directory)
|
||||||
|
return props.globalSDK.client.session
|
||||||
|
.list({ directory, roots: true })
|
||||||
|
.then((x) =>
|
||||||
|
(x.data ?? [])
|
||||||
|
.filter((s) => !!s?.id)
|
||||||
|
.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
title: s.title ?? props.language.t("command.session.new"),
|
||||||
|
description,
|
||||||
|
directory,
|
||||||
|
archived: s.time?.archived,
|
||||||
|
updated: s.time?.updated,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.catch(
|
||||||
|
() =>
|
||||||
|
[] as {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
directory: string
|
||||||
|
archived?: number
|
||||||
|
updated?: number
|
||||||
|
}[],
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.then((results) => {
|
||||||
|
if (state.token !== current) return [] as Entry[]
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const category = props.language.t("command.category.session")
|
||||||
|
const next = results
|
||||||
|
.flat()
|
||||||
|
.filter((item) => {
|
||||||
|
const key = `${item.directory}:${item.id}`
|
||||||
|
if (seen.has(key)) return false
|
||||||
|
seen.add(key)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
.map((item) => createSessionEntry(item, category))
|
||||||
|
state.cached = next
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
.catch(() => [] as Entry[])
|
||||||
|
.finally(() => {
|
||||||
|
state.inflight = undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
return state.inflight
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sessions }
|
||||||
|
}
|
||||||
|
|
||||||
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
|
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -52,40 +269,8 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
const view = createMemo(() => layout.view(sessionKey))
|
const view = createMemo(() => layout.view(sessionKey))
|
||||||
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
||||||
const [grouped, setGrouped] = createSignal(false)
|
const [grouped, setGrouped] = createSignal(false)
|
||||||
const common = [
|
const commandEntries = createCommandEntries({ filesOnly, command, language })
|
||||||
"session.new",
|
const fileEntries = createFileEntries({ file, tabs, language })
|
||||||
"workspace.new",
|
|
||||||
"session.previous",
|
|
||||||
"session.next",
|
|
||||||
"terminal.toggle",
|
|
||||||
"review.toggle",
|
|
||||||
]
|
|
||||||
const limit = 5
|
|
||||||
|
|
||||||
const allowed = createMemo(() => {
|
|
||||||
if (filesOnly()) return []
|
|
||||||
return command.options.filter(
|
|
||||||
(option) => !option.disabled && !option.id.startsWith("suggested.") && option.id !== "file.open",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const commandItem = (option: CommandOption): Entry => ({
|
|
||||||
id: "command:" + option.id,
|
|
||||||
type: "command",
|
|
||||||
title: option.title,
|
|
||||||
description: option.description,
|
|
||||||
keybind: option.keybind,
|
|
||||||
category: language.t("palette.group.commands"),
|
|
||||||
option,
|
|
||||||
})
|
|
||||||
|
|
||||||
const fileItem = (path: string): Entry => ({
|
|
||||||
id: "file:" + path,
|
|
||||||
type: "file",
|
|
||||||
title: path,
|
|
||||||
category: language.t("palette.group.files"),
|
|
||||||
path,
|
|
||||||
})
|
|
||||||
|
|
||||||
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
||||||
const project = createMemo(() => {
|
const project = createMemo(() => {
|
||||||
@@ -116,136 +301,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
return `${kind} : ${name || path}`
|
return `${kind} : ${name || path}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionItem = (input: {
|
const { sessions } = createSessionEntries({ workspaces, label, globalSDK, language })
|
||||||
directory: string
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
archived?: number
|
|
||||||
updated?: number
|
|
||||||
}): Entry => ({
|
|
||||||
id: `session:${input.directory}:${input.id}`,
|
|
||||||
type: "session",
|
|
||||||
title: input.title,
|
|
||||||
description: input.description,
|
|
||||||
category: language.t("command.category.session"),
|
|
||||||
directory: input.directory,
|
|
||||||
sessionID: input.id,
|
|
||||||
archived: input.archived,
|
|
||||||
updated: input.updated,
|
|
||||||
})
|
|
||||||
|
|
||||||
const list = createMemo(() => allowed().map(commandItem))
|
|
||||||
|
|
||||||
const picks = createMemo(() => {
|
|
||||||
const all = allowed()
|
|
||||||
const order = new Map(common.map((id, index) => [id, index]))
|
|
||||||
const picked = all.filter((option) => order.has(option.id))
|
|
||||||
const base = picked.length ? picked : all.slice(0, limit)
|
|
||||||
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
|
|
||||||
return sorted.map(commandItem)
|
|
||||||
})
|
|
||||||
|
|
||||||
const recent = createMemo(() => {
|
|
||||||
const all = tabs().all()
|
|
||||||
const active = tabs().active()
|
|
||||||
const order = active ? [active, ...all.filter((item) => item !== active)] : all
|
|
||||||
const seen = new Set<string>()
|
|
||||||
const items: Entry[] = []
|
|
||||||
|
|
||||||
for (const item of order) {
|
|
||||||
const path = file.pathFromTab(item)
|
|
||||||
if (!path) continue
|
|
||||||
if (seen.has(path)) continue
|
|
||||||
seen.add(path)
|
|
||||||
items.push(fileItem(path))
|
|
||||||
}
|
|
||||||
|
|
||||||
return items.slice(0, limit)
|
|
||||||
})
|
|
||||||
|
|
||||||
const root = createMemo(() => {
|
|
||||||
const nodes = file.tree.children("")
|
|
||||||
const paths = nodes
|
|
||||||
.filter((node) => node.type === "file")
|
|
||||||
.map((node) => node.path)
|
|
||||||
.sort((a, b) => a.localeCompare(b))
|
|
||||||
return paths.slice(0, limit).map(fileItem)
|
|
||||||
})
|
|
||||||
|
|
||||||
const unique = (items: Entry[]) => {
|
|
||||||
const seen = new Set<string>()
|
|
||||||
const out: Entry[] = []
|
|
||||||
for (const item of items) {
|
|
||||||
if (seen.has(item.id)) continue
|
|
||||||
seen.add(item.id)
|
|
||||||
out.push(item)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionToken = { value: 0 }
|
|
||||||
let sessionInflight: Promise<Entry[]> | undefined
|
|
||||||
let sessionAll: Entry[] | undefined
|
|
||||||
|
|
||||||
const sessions = (text: string) => {
|
|
||||||
const query = text.trim()
|
|
||||||
if (!query) {
|
|
||||||
sessionToken.value += 1
|
|
||||||
sessionInflight = undefined
|
|
||||||
sessionAll = undefined
|
|
||||||
return [] as Entry[]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sessionAll) return sessionAll
|
|
||||||
if (sessionInflight) return sessionInflight
|
|
||||||
|
|
||||||
const current = sessionToken.value
|
|
||||||
const dirs = workspaces()
|
|
||||||
if (dirs.length === 0) return [] as Entry[]
|
|
||||||
|
|
||||||
sessionInflight = Promise.all(
|
|
||||||
dirs.map((directory) => {
|
|
||||||
const description = label(directory)
|
|
||||||
return globalSDK.client.session
|
|
||||||
.list({ directory, roots: true })
|
|
||||||
.then((x) =>
|
|
||||||
(x.data ?? [])
|
|
||||||
.filter((s) => !!s?.id)
|
|
||||||
.map((s) => ({
|
|
||||||
id: s.id,
|
|
||||||
title: s.title ?? language.t("command.session.new"),
|
|
||||||
description,
|
|
||||||
directory,
|
|
||||||
archived: s.time?.archived,
|
|
||||||
updated: s.time?.updated,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.catch(() => [] as { id: string; title: string; description: string; directory: string; archived?: number }[])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.then((results) => {
|
|
||||||
if (sessionToken.value !== current) return [] as Entry[]
|
|
||||||
const seen = new Set<string>()
|
|
||||||
const next = results
|
|
||||||
.flat()
|
|
||||||
.filter((item) => {
|
|
||||||
const key = `${item.directory}:${item.id}`
|
|
||||||
if (seen.has(key)) return false
|
|
||||||
seen.add(key)
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
.map(sessionItem)
|
|
||||||
sessionAll = next
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
.catch(() => [] as Entry[])
|
|
||||||
.finally(() => {
|
|
||||||
sessionInflight = undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
return sessionInflight
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = async (text: string) => {
|
const items = async (text: string) => {
|
||||||
const query = text.trim()
|
const query = text.trim()
|
||||||
@@ -254,7 +310,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
if (!query && filesOnly()) {
|
if (!query && filesOnly()) {
|
||||||
const loaded = file.tree.state("")?.loaded
|
const loaded = file.tree.state("")?.loaded
|
||||||
const pending = loaded ? Promise.resolve() : file.tree.list("")
|
const pending = loaded ? Promise.resolve() : file.tree.list("")
|
||||||
const next = unique([...recent(), ...root()])
|
const next = uniqueEntries([...fileEntries.recent(), ...fileEntries.root()])
|
||||||
|
|
||||||
if (loaded || next.length > 0) {
|
if (loaded || next.length > 0) {
|
||||||
void pending
|
void pending
|
||||||
@@ -262,19 +318,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
}
|
}
|
||||||
|
|
||||||
await pending
|
await pending
|
||||||
return unique([...recent(), ...root()])
|
return uniqueEntries([...fileEntries.recent(), ...fileEntries.root()])
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!query) return [...picks(), ...recent()]
|
if (!query) return [...commandEntries.picks(), ...fileEntries.recent()]
|
||||||
|
|
||||||
if (filesOnly()) {
|
if (filesOnly()) {
|
||||||
const files = await file.searchFiles(query)
|
const files = await file.searchFiles(query)
|
||||||
return files.map(fileItem)
|
const category = language.t("palette.group.files")
|
||||||
|
return files.map((path) => createFileEntry(path, category))
|
||||||
}
|
}
|
||||||
|
|
||||||
const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))])
|
const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))])
|
||||||
const entries = files.map(fileItem)
|
const category = language.t("palette.group.files")
|
||||||
return [...list(), ...nextSessions, ...entries]
|
const entries = files.map((path) => createFileEntry(path, category))
|
||||||
|
return [...commandEntries.list(), ...nextSessions, ...entries]
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMove = (item: Entry | undefined) => {
|
const handleMove = (item: Entry | undefined) => {
|
||||||
@@ -289,9 +347,9 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
tabs().open(value)
|
tabs().open(value)
|
||||||
file.load(path)
|
file.load(path)
|
||||||
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
||||||
layout.fileTree.open()
|
|
||||||
layout.fileTree.setTab("all")
|
layout.fileTree.setTab("all")
|
||||||
props.onOpenFile?.(path)
|
props.onOpenFile?.(path)
|
||||||
|
tabs().setActive(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSelect = (item: Entry | undefined) => {
|
const handleSelect = (item: Entry | undefined) => {
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ import { List } from "@opencode-ai/ui/list"
|
|||||||
import { Switch } from "@opencode-ai/ui/switch"
|
import { Switch } from "@opencode-ai/ui/switch"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
||||||
|
const statusLabels = {
|
||||||
|
connected: "mcp.status.connected",
|
||||||
|
failed: "mcp.status.failed",
|
||||||
|
needs_auth: "mcp.status.needs_auth",
|
||||||
|
disabled: "mcp.status.disabled",
|
||||||
|
} as const
|
||||||
|
|
||||||
export const DialogSelectMcp: Component = () => {
|
export const DialogSelectMcp: Component = () => {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
@@ -21,15 +28,19 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
const toggle = async (name: string) => {
|
const toggle = async (name: string) => {
|
||||||
if (loading()) return
|
if (loading()) return
|
||||||
setLoading(name)
|
setLoading(name)
|
||||||
const status = sync.data.mcp[name]
|
try {
|
||||||
if (status?.status === "connected") {
|
const status = sync.data.mcp[name]
|
||||||
await sdk.client.mcp.disconnect({ name })
|
if (status?.status === "connected") {
|
||||||
} else {
|
await sdk.client.mcp.disconnect({ name })
|
||||||
await sdk.client.mcp.connect({ name })
|
} else {
|
||||||
|
await sdk.client.mcp.connect({ name })
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await sdk.client.mcp.status()
|
||||||
|
if (result.data) sync.set("mcp", result.data)
|
||||||
|
} finally {
|
||||||
|
setLoading(null)
|
||||||
}
|
}
|
||||||
const result = await sdk.client.mcp.status()
|
|
||||||
if (result.data) sync.set("mcp", result.data)
|
|
||||||
setLoading(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
|
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
|
||||||
@@ -54,6 +65,11 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
{(i) => {
|
{(i) => {
|
||||||
const mcpStatus = () => sync.data.mcp[i.name]
|
const mcpStatus = () => sync.data.mcp[i.name]
|
||||||
const status = () => mcpStatus()?.status
|
const status = () => mcpStatus()?.status
|
||||||
|
const statusLabel = () => {
|
||||||
|
const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined
|
||||||
|
if (!key) return
|
||||||
|
return language.t(key)
|
||||||
|
}
|
||||||
const error = () => {
|
const error = () => {
|
||||||
const s = mcpStatus()
|
const s = mcpStatus()
|
||||||
return s?.status === "failed" ? s.error : undefined
|
return s?.status === "failed" ? s.error : undefined
|
||||||
@@ -64,17 +80,8 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
<div class="flex flex-col gap-0.5 min-w-0">
|
<div class="flex flex-col gap-0.5 min-w-0">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="truncate">{i.name}</span>
|
<span class="truncate">{i.name}</span>
|
||||||
<Show when={status() === "connected"}>
|
<Show when={statusLabel()}>
|
||||||
<span class="text-11-regular text-text-weaker">{language.t("mcp.status.connected")}</span>
|
<span class="text-11-regular text-text-weaker">{statusLabel()}</span>
|
||||||
</Show>
|
|
||||||
<Show when={status() === "failed"}>
|
|
||||||
<span class="text-11-regular text-text-weaker">{language.t("mcp.status.failed")}</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={status() === "needs_auth"}>
|
|
||||||
<span class="text-11-regular text-text-weaker">{language.t("mcp.status.needs_auth")}</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={status() === "disabled"}>
|
|
||||||
<span class="text-11-regular text-text-weaker">{language.t("mcp.status.disabled")}</span>
|
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={loading() === i.name}>
|
<Show when={loading() === i.name}>
|
||||||
<span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span>
|
<span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { List, type ListRef } from "@opencode-ai/ui/list"
|
|||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { Tag } from "@opencode-ai/ui/tag"
|
import { Tag } from "@opencode-ai/ui/tag"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { type Component, onCleanup, onMount, Show } from "solid-js"
|
import { type Component, Show } from "solid-js"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
import { DialogConnectProvider } from "./dialog-connect-provider"
|
import { DialogConnectProvider } from "./dialog-connect-provider"
|
||||||
@@ -21,24 +21,17 @@ export const DialogSelectModelUnpaid: Component = () => {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
let listRef: ListRef | undefined
|
let listRef: ListRef | undefined
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") return
|
if (e.key === "Escape") return
|
||||||
listRef?.onKeyDown(e)
|
listRef?.onKeyDown(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
document.addEventListener("keydown", handleKey)
|
|
||||||
onCleanup(() => {
|
|
||||||
document.removeEventListener("keydown", handleKey)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
title={language.t("dialog.model.select.title")}
|
title={language.t("dialog.model.select.title")}
|
||||||
class="overflow-y-auto [&_[data-slot=dialog-body]]:overflow-visible [&_[data-slot=dialog-body]]:flex-none"
|
class="overflow-y-auto [&_[data-slot=dialog-body]]:overflow-visible [&_[data-slot=dialog-body]]:flex-none"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-3 px-2.5">
|
<div class="flex flex-col gap-3 px-2.5" onKeyDown={handleKeyDown}>
|
||||||
<div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div>
|
<div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div>
|
||||||
<List
|
<List
|
||||||
class="[&_[data-slot=list-scroll]]:overflow-visible"
|
class="[&_[data-slot=list-scroll]]:overflow-visible"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Popover as Kobalte } from "@kobalte/core/popover"
|
import { Popover as Kobalte } from "@kobalte/core/popover"
|
||||||
import { Component, ComponentProps, createEffect, createMemo, JSX, onCleanup, Show, ValidComponent } from "solid-js"
|
import { Component, ComponentProps, createMemo, JSX, Show, ValidComponent } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
@@ -15,6 +15,9 @@ import { DialogManageModels } from "./dialog-manage-models"
|
|||||||
import { ModelTooltip } from "./model-tooltip"
|
import { ModelTooltip } from "./model-tooltip"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
||||||
|
const isFree = (provider: string, cost: { input: number } | undefined) =>
|
||||||
|
provider === "opencode" && (!cost || cost.input === 0)
|
||||||
|
|
||||||
const ModelList: Component<{
|
const ModelList: Component<{
|
||||||
provider?: string
|
provider?: string
|
||||||
class?: string
|
class?: string
|
||||||
@@ -54,13 +57,7 @@ const ModelList: Component<{
|
|||||||
class="w-full"
|
class="w-full"
|
||||||
placement="right-start"
|
placement="right-start"
|
||||||
gutter={12}
|
gutter={12}
|
||||||
value={
|
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
|
||||||
<ModelTooltip
|
|
||||||
model={item}
|
|
||||||
latest={item.latest}
|
|
||||||
free={item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{node}
|
{node}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -75,7 +72,7 @@ const ModelList: Component<{
|
|||||||
{(i) => (
|
{(i) => (
|
||||||
<div class="w-full flex items-center gap-x-2 text-13-regular">
|
<div class="w-full flex items-center gap-x-2 text-13-regular">
|
||||||
<span class="truncate">{i.name}</span>
|
<span class="truncate">{i.name}</span>
|
||||||
<Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}>
|
<Show when={isFree(i.provider.id, i.cost)}>
|
||||||
<Tag>{language.t("model.tag.free")}</Tag>
|
<Tag>{language.t("model.tag.free")}</Tag>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={i.latest}>
|
<Show when={i.latest}>
|
||||||
@@ -98,13 +95,9 @@ export function ModelSelectorPopover(props: {
|
|||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
open: boolean
|
open: boolean
|
||||||
dismiss: "escape" | "outside" | null
|
dismiss: "escape" | "outside" | null
|
||||||
trigger?: HTMLElement
|
|
||||||
content?: HTMLElement
|
|
||||||
}>({
|
}>({
|
||||||
open: false,
|
open: false,
|
||||||
dismiss: null,
|
dismiss: null,
|
||||||
trigger: undefined,
|
|
||||||
content: undefined,
|
|
||||||
})
|
})
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
|
||||||
@@ -119,54 +112,6 @@ export function ModelSelectorPopover(props: {
|
|||||||
}
|
}
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!store.open) return
|
|
||||||
|
|
||||||
const inside = (node: Node | null | undefined) => {
|
|
||||||
if (!node) return false
|
|
||||||
const el = store.content
|
|
||||||
if (el && el.contains(node)) return true
|
|
||||||
const anchor = store.trigger
|
|
||||||
if (anchor && anchor.contains(node)) return true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (event.key !== "Escape") return
|
|
||||||
setStore("dismiss", "escape")
|
|
||||||
setStore("open", false)
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
}
|
|
||||||
|
|
||||||
const onPointerDown = (event: PointerEvent) => {
|
|
||||||
const target = event.target
|
|
||||||
if (!(target instanceof Node)) return
|
|
||||||
if (inside(target)) return
|
|
||||||
setStore("dismiss", "outside")
|
|
||||||
setStore("open", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onFocusIn = (event: FocusEvent) => {
|
|
||||||
if (!store.content) return
|
|
||||||
const target = event.target
|
|
||||||
if (!(target instanceof Node)) return
|
|
||||||
if (inside(target)) return
|
|
||||||
setStore("dismiss", "outside")
|
|
||||||
setStore("open", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener("keydown", onKeyDown, true)
|
|
||||||
window.addEventListener("pointerdown", onPointerDown, true)
|
|
||||||
window.addEventListener("focusin", onFocusIn, true)
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
window.removeEventListener("keydown", onKeyDown, true)
|
|
||||||
window.removeEventListener("pointerdown", onPointerDown, true)
|
|
||||||
window.removeEventListener("focusin", onFocusIn, true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Kobalte
|
<Kobalte
|
||||||
open={store.open}
|
open={store.open}
|
||||||
@@ -176,14 +121,13 @@ export function ModelSelectorPopover(props: {
|
|||||||
}}
|
}}
|
||||||
modal={false}
|
modal={false}
|
||||||
placement="top-start"
|
placement="top-start"
|
||||||
gutter={8}
|
gutter={4}
|
||||||
>
|
>
|
||||||
<Kobalte.Trigger ref={(el) => setStore("trigger", el)} as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
<Kobalte.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
||||||
{props.children}
|
{props.children}
|
||||||
</Kobalte.Trigger>
|
</Kobalte.Trigger>
|
||||||
<Kobalte.Portal>
|
<Kobalte.Portal>
|
||||||
<Kobalte.Content
|
<Kobalte.Content
|
||||||
ref={(el) => setStore("content", el)}
|
|
||||||
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
|
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
|
||||||
onEscapeKeyDown={(event) => {
|
onEscapeKeyDown={(event) => {
|
||||||
setStore("dismiss", "escape")
|
setStore("dismiss", "escape")
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ export const DialogSelectProvider: Component = () => {
|
|||||||
|
|
||||||
const popularGroup = () => language.t("dialog.provider.group.popular")
|
const popularGroup = () => language.t("dialog.provider.group.popular")
|
||||||
const otherGroup = () => language.t("dialog.provider.group.other")
|
const otherGroup = () => language.t("dialog.provider.group.other")
|
||||||
|
const customLabel = () => language.t("settings.providers.tag.custom")
|
||||||
|
const note = (id: string) => {
|
||||||
|
if (id === "anthropic") return language.t("dialog.provider.anthropic.note")
|
||||||
|
if (id === "openai") return language.t("dialog.provider.openai.note")
|
||||||
|
if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note")
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog title={language.t("command.provider.connect")} transition>
|
<Dialog title={language.t("command.provider.connect")} transition>
|
||||||
@@ -34,7 +40,7 @@ export const DialogSelectProvider: Component = () => {
|
|||||||
key={(x) => x?.id}
|
key={(x) => x?.id}
|
||||||
items={() => {
|
items={() => {
|
||||||
language.locale()
|
language.locale()
|
||||||
return [{ id: CUSTOM_ID, name: "Custom provider" }, ...providers.all()]
|
return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all()]
|
||||||
}}
|
}}
|
||||||
filterKeys={["id", "name"]}
|
filterKeys={["id", "name"]}
|
||||||
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
|
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
|
||||||
@@ -70,15 +76,7 @@ export const DialogSelectProvider: Component = () => {
|
|||||||
<Show when={i.id === "opencode"}>
|
<Show when={i.id === "opencode"}>
|
||||||
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={i.id === "anthropic"}>
|
<Show when={note(i.id)}>{(value) => <div class="text-14-regular text-text-weak">{value()}</div>}</Show>
|
||||||
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.anthropic.note")}</div>
|
|
||||||
</Show>
|
|
||||||
<Show when={i.id === "openai"}>
|
|
||||||
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.openai.note")}</div>
|
|
||||||
</Show>
|
|
||||||
<Show when={i.id.startsWith("github-copilot")}>
|
|
||||||
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.copilot.note")}</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</List>
|
</List>
|
||||||
|
|||||||
@@ -38,6 +38,64 @@ interface EditRowProps {
|
|||||||
onBlur: () => void
|
onBlur: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("common.requestFailed"),
|
||||||
|
description: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: ReturnType<typeof useLanguage>) {
|
||||||
|
const [defaultUrl, defaultUrlActions] = createResource(
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
const url = await platform.getDefaultServerUrl?.()
|
||||||
|
if (!url) return null
|
||||||
|
return normalizeServerUrl(url) ?? null
|
||||||
|
} catch (err) {
|
||||||
|
showRequestError(language, err)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ initialValue: null },
|
||||||
|
)
|
||||||
|
|
||||||
|
const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
|
||||||
|
const setDefault = async (url: string | null) => {
|
||||||
|
try {
|
||||||
|
await platform.setDefaultServerUrl?.(url)
|
||||||
|
defaultUrlActions.mutate(url)
|
||||||
|
} catch (err) {
|
||||||
|
showRequestError(language, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { defaultUrl, canDefault, setDefault }
|
||||||
|
}
|
||||||
|
|
||||||
|
function useServerPreview(fetcher: typeof fetch) {
|
||||||
|
const looksComplete = (value: string) => {
|
||||||
|
const normalized = normalizeServerUrl(value)
|
||||||
|
if (!normalized) return false
|
||||||
|
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
|
||||||
|
if (!host) return false
|
||||||
|
if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
|
||||||
|
return host.includes(".") || host.includes(":")
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewStatus = async (value: string, setStatus: (value: boolean | undefined) => void) => {
|
||||||
|
setStatus(undefined)
|
||||||
|
if (!looksComplete(value)) return
|
||||||
|
const normalized = normalizeServerUrl(value)
|
||||||
|
if (!normalized) return
|
||||||
|
const result = await checkServerHealth(normalized, fetcher)
|
||||||
|
setStatus(result.healthy)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { previewStatus }
|
||||||
|
}
|
||||||
|
|
||||||
function AddRow(props: AddRowProps) {
|
function AddRow(props: AddRowProps) {
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center px-4 min-h-14 py-3 min-w-0 flex-1">
|
<div class="flex items-center px-4 min-h-14 py-3 min-w-0 flex-1">
|
||||||
@@ -115,6 +173,10 @@ export function DialogSelectServer() {
|
|||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const fetcher = platform.fetch ?? globalThis.fetch
|
||||||
|
const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
|
||||||
|
const { previewStatus } = useServerPreview(fetcher)
|
||||||
|
let listRoot: HTMLDivElement | undefined
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
status: {} as Record<string, ServerHealth | undefined>,
|
status: {} as Record<string, ServerHealth | undefined>,
|
||||||
addServer: {
|
addServer: {
|
||||||
@@ -132,43 +194,6 @@ export function DialogSelectServer() {
|
|||||||
status: undefined as boolean | undefined,
|
status: undefined as boolean | undefined,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const [defaultUrl, defaultUrlActions] = createResource(
|
|
||||||
async () => {
|
|
||||||
try {
|
|
||||||
const url = await platform.getDefaultServerUrl?.()
|
|
||||||
if (!url) return null
|
|
||||||
return normalizeServerUrl(url) ?? null
|
|
||||||
} catch (err) {
|
|
||||||
showToast({
|
|
||||||
variant: "error",
|
|
||||||
title: language.t("common.requestFailed"),
|
|
||||||
description: err instanceof Error ? err.message : String(err),
|
|
||||||
})
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ initialValue: null },
|
|
||||||
)
|
|
||||||
const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
|
|
||||||
const fetcher = platform.fetch ?? globalThis.fetch
|
|
||||||
|
|
||||||
const looksComplete = (value: string) => {
|
|
||||||
const normalized = normalizeServerUrl(value)
|
|
||||||
if (!normalized) return false
|
|
||||||
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
|
|
||||||
if (!host) return false
|
|
||||||
if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
|
|
||||||
return host.includes(".") || host.includes(":")
|
|
||||||
}
|
|
||||||
|
|
||||||
const previewStatus = async (value: string, setStatus: (value: boolean | undefined) => void) => {
|
|
||||||
setStatus(undefined)
|
|
||||||
if (!looksComplete(value)) return
|
|
||||||
const normalized = normalizeServerUrl(value)
|
|
||||||
if (!normalized) return
|
|
||||||
const result = await checkServerHealth(normalized, fetcher)
|
|
||||||
setStatus(result.healthy)
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetAdd = () => {
|
const resetAdd = () => {
|
||||||
setStore("addServer", {
|
setStore("addServer", {
|
||||||
@@ -263,7 +288,7 @@ export function DialogSelectServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const scrollListToBottom = () => {
|
const scrollListToBottom = () => {
|
||||||
const scroll = document.querySelector<HTMLDivElement>('[data-component="list"] [data-slot="list-scroll"]')
|
const scroll = listRoot?.querySelector<HTMLDivElement>('[data-slot="list-scroll"]')
|
||||||
if (!scroll) return
|
if (!scroll) return
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
scroll.scrollTop = scroll.scrollHeight
|
scroll.scrollTop = scroll.scrollHeight
|
||||||
@@ -363,158 +388,134 @@ export function DialogSelectServer() {
|
|||||||
return (
|
return (
|
||||||
<Dialog title={language.t("dialog.server.title")}>
|
<Dialog title={language.t("dialog.server.title")}>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<List
|
<div ref={(el) => (listRoot = el)}>
|
||||||
search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }}
|
<List
|
||||||
noInitialSelection
|
search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }}
|
||||||
emptyMessage={language.t("dialog.server.empty")}
|
noInitialSelection
|
||||||
items={sortedItems}
|
emptyMessage={language.t("dialog.server.empty")}
|
||||||
key={(x) => x}
|
items={sortedItems}
|
||||||
onSelect={(x) => {
|
key={(x) => x}
|
||||||
if (x) select(x)
|
onSelect={(x) => {
|
||||||
}}
|
if (x) select(x)
|
||||||
onFilter={(value) => {
|
}}
|
||||||
if (value && store.addServer.showForm && !store.addServer.adding) {
|
onFilter={(value) => {
|
||||||
resetAdd()
|
if (value && store.addServer.showForm && !store.addServer.adding) {
|
||||||
}
|
resetAdd()
|
||||||
}}
|
}
|
||||||
divider={true}
|
}}
|
||||||
class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:max-h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent [&_[data-slot=list-item-add]]:px-0"
|
divider={true}
|
||||||
add={
|
class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:max-h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent [&_[data-slot=list-item-add]]:px-0"
|
||||||
store.addServer.showForm
|
add={
|
||||||
? {
|
store.addServer.showForm
|
||||||
render: () => (
|
? {
|
||||||
<AddRow
|
render: () => (
|
||||||
value={store.addServer.url}
|
<AddRow
|
||||||
placeholder={language.t("dialog.server.add.placeholder")}
|
value={store.addServer.url}
|
||||||
adding={store.addServer.adding}
|
placeholder={language.t("dialog.server.add.placeholder")}
|
||||||
error={store.addServer.error}
|
adding={store.addServer.adding}
|
||||||
status={store.addServer.status}
|
error={store.addServer.error}
|
||||||
onChange={handleAddChange}
|
status={store.addServer.status}
|
||||||
onKeyDown={handleAddKey}
|
onChange={handleAddChange}
|
||||||
onBlur={blurAdd}
|
onKeyDown={handleAddKey}
|
||||||
/>
|
onBlur={blurAdd}
|
||||||
),
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(i) => {
|
|
||||||
return (
|
|
||||||
<div class="flex items-center gap-3 min-w-0 flex-1 group/item">
|
|
||||||
<Show
|
|
||||||
when={store.editServer.id !== i}
|
|
||||||
fallback={
|
|
||||||
<EditRow
|
|
||||||
value={store.editServer.value}
|
|
||||||
placeholder={language.t("dialog.server.add.placeholder")}
|
|
||||||
busy={store.editServer.busy}
|
|
||||||
error={store.editServer.error}
|
|
||||||
status={store.editServer.status}
|
|
||||||
onChange={handleEditChange}
|
|
||||||
onKeyDown={(event) => handleEditKey(event, i)}
|
|
||||||
onBlur={() => handleEdit(i, store.editServer.value)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ServerRow
|
|
||||||
url={i}
|
|
||||||
status={store.status[i]}
|
|
||||||
dimmed={store.status[i]?.healthy === false}
|
|
||||||
class="flex items-center gap-3 px-4 min-w-0 flex-1"
|
|
||||||
badge={
|
|
||||||
<Show when={defaultUrl() === i}>
|
|
||||||
<span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
|
|
||||||
{language.t("dialog.server.status.default")}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
<Show when={store.editServer.id !== i}>
|
|
||||||
<div class="flex items-center justify-center gap-5 pl-4">
|
|
||||||
<Show when={current() === i}>
|
|
||||||
<p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenu.Trigger
|
|
||||||
as={IconButton}
|
|
||||||
icon="dot-grid"
|
|
||||||
variant="ghost"
|
|
||||||
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
|
|
||||||
onClick={(e: MouseEvent) => e.stopPropagation()}
|
|
||||||
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
|
|
||||||
/>
|
/>
|
||||||
<DropdownMenu.Portal>
|
),
|
||||||
<DropdownMenu.Content class="mt-1">
|
}
|
||||||
<DropdownMenu.Item
|
: undefined
|
||||||
onSelect={() => {
|
}
|
||||||
setStore("editServer", {
|
>
|
||||||
id: i,
|
{(i) => {
|
||||||
value: i,
|
return (
|
||||||
error: "",
|
<div class="flex items-center gap-3 min-w-0 flex-1 group/item">
|
||||||
status: store.status[i]?.healthy,
|
<Show
|
||||||
})
|
when={store.editServer.id !== i}
|
||||||
}}
|
fallback={
|
||||||
>
|
<EditRow
|
||||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
value={store.editServer.value}
|
||||||
</DropdownMenu.Item>
|
placeholder={language.t("dialog.server.add.placeholder")}
|
||||||
<Show when={canDefault() && defaultUrl() !== i}>
|
busy={store.editServer.busy}
|
||||||
|
error={store.editServer.error}
|
||||||
|
status={store.editServer.status}
|
||||||
|
onChange={handleEditChange}
|
||||||
|
onKeyDown={(event) => handleEditKey(event, i)}
|
||||||
|
onBlur={() => handleEdit(i, store.editServer.value)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ServerRow
|
||||||
|
url={i}
|
||||||
|
status={store.status[i]}
|
||||||
|
dimmed={store.status[i]?.healthy === false}
|
||||||
|
class="flex items-center gap-3 px-4 min-w-0 flex-1"
|
||||||
|
badge={
|
||||||
|
<Show when={defaultUrl() === i}>
|
||||||
|
<span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
|
||||||
|
{language.t("dialog.server.status.default")}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<Show when={store.editServer.id !== i}>
|
||||||
|
<div class="flex items-center justify-center gap-5 pl-4">
|
||||||
|
<Show when={current() === i}>
|
||||||
|
<p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenu.Trigger
|
||||||
|
as={IconButton}
|
||||||
|
icon="dot-grid"
|
||||||
|
variant="ghost"
|
||||||
|
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
|
||||||
|
onClick={(e: MouseEvent) => e.stopPropagation()}
|
||||||
|
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content class="mt-1">
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={async () => {
|
onSelect={() => {
|
||||||
try {
|
setStore("editServer", {
|
||||||
await platform.setDefaultServerUrl?.(i)
|
id: i,
|
||||||
defaultUrlActions.mutate(i)
|
value: i,
|
||||||
} catch (err) {
|
error: "",
|
||||||
showToast({
|
status: store.status[i]?.healthy,
|
||||||
variant: "error",
|
})
|
||||||
title: language.t("common.requestFailed"),
|
|
||||||
description: err instanceof Error ? err.message : String(err),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
||||||
{language.t("dialog.server.menu.default")}
|
|
||||||
</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</Show>
|
<Show when={canDefault() && defaultUrl() !== i}>
|
||||||
<Show when={canDefault() && defaultUrl() === i}>
|
<DropdownMenu.Item onSelect={() => setDefault(i)}>
|
||||||
|
<DropdownMenu.ItemLabel>
|
||||||
|
{language.t("dialog.server.menu.default")}
|
||||||
|
</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</Show>
|
||||||
|
<Show when={canDefault() && defaultUrl() === i}>
|
||||||
|
<DropdownMenu.Item onSelect={() => setDefault(null)}>
|
||||||
|
<DropdownMenu.ItemLabel>
|
||||||
|
{language.t("dialog.server.menu.defaultRemove")}
|
||||||
|
</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</Show>
|
||||||
|
<DropdownMenu.Separator />
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={async () => {
|
onSelect={() => handleRemove(i)}
|
||||||
try {
|
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
||||||
await platform.setDefaultServerUrl?.(null)
|
|
||||||
defaultUrlActions.mutate(null)
|
|
||||||
} catch (err) {
|
|
||||||
showToast({
|
|
||||||
variant: "error",
|
|
||||||
title: language.t("common.requestFailed"),
|
|
||||||
description: err instanceof Error ? err.message : String(err),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
|
||||||
{language.t("dialog.server.menu.defaultRemove")}
|
|
||||||
</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</Show>
|
</DropdownMenu.Content>
|
||||||
<DropdownMenu.Separator />
|
</DropdownMenu.Portal>
|
||||||
<DropdownMenu.Item
|
</DropdownMenu>
|
||||||
onSelect={() => handleRemove(i)}
|
</div>
|
||||||
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
</Show>
|
||||||
>
|
</div>
|
||||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
|
)
|
||||||
</DropdownMenu.Item>
|
}}
|
||||||
</DropdownMenu.Content>
|
</List>
|
||||||
</DropdownMenu.Portal>
|
</div>
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</List>
|
|
||||||
|
|
||||||
<div class="px-5 pb-5">
|
<div class="px-5 pb-5">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -67,15 +67,6 @@ export const DialogSettings: Component = () => {
|
|||||||
<Tabs.Content value="models" class="no-scrollbar">
|
<Tabs.Content value="models" class="no-scrollbar">
|
||||||
<SettingsModels />
|
<SettingsModels />
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
{/* <Tabs.Content value="agents" class="no-scrollbar"> */}
|
|
||||||
{/* <SettingsAgents /> */}
|
|
||||||
{/* </Tabs.Content> */}
|
|
||||||
{/* <Tabs.Content value="commands" class="no-scrollbar"> */}
|
|
||||||
{/* <SettingsCommands /> */}
|
|
||||||
{/* </Tabs.Content> */}
|
|
||||||
{/* <Tabs.Content value="mcp" class="no-scrollbar"> */}
|
|
||||||
{/* <SettingsMcp /> */}
|
|
||||||
{/* </Tabs.Content> */}
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,11 +15,14 @@ import {
|
|||||||
Switch,
|
Switch,
|
||||||
untrack,
|
untrack,
|
||||||
type ComponentProps,
|
type ComponentProps,
|
||||||
|
type JSXElement,
|
||||||
type ParentProps,
|
type ParentProps,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||||
|
|
||||||
|
const MAX_DEPTH = 128
|
||||||
|
|
||||||
function pathToFileUrl(filepath: string): string {
|
function pathToFileUrl(filepath: string): string {
|
||||||
return `file://${encodeFilePath(filepath)}`
|
return `file://${encodeFilePath(filepath)}`
|
||||||
}
|
}
|
||||||
@@ -59,6 +62,189 @@ export function dirsToExpand(input: {
|
|||||||
return [...input.filter.dirs].filter((dir) => !input.expanded(dir))
|
return [...input.filter.dirs].filter((dir) => !input.expanded(dir))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const kindLabel = (kind: Kind) => {
|
||||||
|
if (kind === "add") return "A"
|
||||||
|
if (kind === "del") return "D"
|
||||||
|
return "M"
|
||||||
|
}
|
||||||
|
|
||||||
|
const kindTextColor = (kind: Kind) => {
|
||||||
|
if (kind === "add") return "color: var(--icon-diff-add-base)"
|
||||||
|
if (kind === "del") return "color: var(--icon-diff-delete-base)"
|
||||||
|
return "color: var(--icon-diff-modified-base)"
|
||||||
|
}
|
||||||
|
|
||||||
|
const kindDotColor = (kind: Kind) => {
|
||||||
|
if (kind === "add") return "background-color: var(--icon-diff-add-base)"
|
||||||
|
if (kind === "del") return "background-color: var(--icon-diff-delete-base)"
|
||||||
|
return "background-color: var(--icon-diff-modified-base)"
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleKind = (node: FileNode, kinds?: ReadonlyMap<string, Kind>, marks?: Set<string>) => {
|
||||||
|
const kind = kinds?.get(node.path)
|
||||||
|
if (!kind) return
|
||||||
|
if (!marks?.has(node.path)) return
|
||||||
|
return kind
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildDragImage = (target: HTMLElement) => {
|
||||||
|
const icon = target.querySelector('[data-component="file-icon"]') ?? target.querySelector("svg")
|
||||||
|
const text = target.querySelector("span")
|
||||||
|
if (!icon || !text) return
|
||||||
|
|
||||||
|
const image = document.createElement("div")
|
||||||
|
image.className =
|
||||||
|
"flex items-center gap-x-2 px-2 py-1 bg-surface-raised-base rounded-md border border-border-base text-12-regular text-text-strong"
|
||||||
|
image.style.position = "absolute"
|
||||||
|
image.style.top = "-1000px"
|
||||||
|
image.innerHTML = (icon as SVGElement).outerHTML + (text as HTMLSpanElement).outerHTML
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
|
||||||
|
const withFileDragImage = (event: DragEvent) => {
|
||||||
|
const image = buildDragImage(event.currentTarget as HTMLElement)
|
||||||
|
if (!image) return
|
||||||
|
document.body.appendChild(image)
|
||||||
|
event.dataTransfer?.setDragImage(image, 0, 12)
|
||||||
|
setTimeout(() => document.body.removeChild(image), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileTreeNode = (
|
||||||
|
p: ParentProps &
|
||||||
|
ComponentProps<"div"> &
|
||||||
|
ComponentProps<"button"> & {
|
||||||
|
node: FileNode
|
||||||
|
level: number
|
||||||
|
active?: string
|
||||||
|
nodeClass?: string
|
||||||
|
draggable: boolean
|
||||||
|
kinds?: ReadonlyMap<string, Kind>
|
||||||
|
marks?: Set<string>
|
||||||
|
as?: "div" | "button"
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const [local, rest] = splitProps(p, [
|
||||||
|
"node",
|
||||||
|
"level",
|
||||||
|
"active",
|
||||||
|
"nodeClass",
|
||||||
|
"draggable",
|
||||||
|
"kinds",
|
||||||
|
"marks",
|
||||||
|
"as",
|
||||||
|
"children",
|
||||||
|
"class",
|
||||||
|
"classList",
|
||||||
|
])
|
||||||
|
const kind = () => visibleKind(local.node, local.kinds, local.marks)
|
||||||
|
const active = () => !!kind() && !local.node.ignored
|
||||||
|
const color = () => {
|
||||||
|
const value = kind()
|
||||||
|
if (!value) return
|
||||||
|
return kindTextColor(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dynamic
|
||||||
|
component={local.as ?? "div"}
|
||||||
|
classList={{
|
||||||
|
"w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-left hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
|
||||||
|
"bg-surface-base-active": local.node.path === local.active,
|
||||||
|
...(local.classList ?? {}),
|
||||||
|
[local.class ?? ""]: !!local.class,
|
||||||
|
[local.nodeClass ?? ""]: !!local.nodeClass,
|
||||||
|
}}
|
||||||
|
style={`padding-left: ${Math.max(0, 8 + local.level * 12 - (local.node.type === "file" ? 24 : 4))}px`}
|
||||||
|
draggable={local.draggable}
|
||||||
|
onDragStart={(event: DragEvent) => {
|
||||||
|
if (!local.draggable) return
|
||||||
|
event.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
|
||||||
|
event.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path))
|
||||||
|
if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy"
|
||||||
|
withFileDragImage(event)
|
||||||
|
}}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{local.children}
|
||||||
|
<span
|
||||||
|
classList={{
|
||||||
|
"flex-1 min-w-0 text-12-medium whitespace-nowrap truncate": true,
|
||||||
|
"text-text-weaker": local.node.ignored,
|
||||||
|
"text-text-weak": !local.node.ignored && !active(),
|
||||||
|
}}
|
||||||
|
style={active() ? color() : undefined}
|
||||||
|
>
|
||||||
|
{local.node.name}
|
||||||
|
</span>
|
||||||
|
{(() => {
|
||||||
|
const value = kind()
|
||||||
|
if (!value) return null
|
||||||
|
if (local.node.type === "file") {
|
||||||
|
return (
|
||||||
|
<span class="shrink-0 w-4 text-center text-12-medium" style={kindTextColor(value)}>
|
||||||
|
{kindLabel(value)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <div class="shrink-0 size-1.5 mr-1.5 rounded-full" style={kindDotColor(value)} />
|
||||||
|
})()}
|
||||||
|
</Dynamic>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileTreeNodeTooltip = (props: { enabled: boolean; node: FileNode; kind?: Kind; children: JSXElement }) => {
|
||||||
|
if (!props.enabled) return props.children
|
||||||
|
|
||||||
|
const parts = props.node.path.split("/")
|
||||||
|
const leaf = parts[parts.length - 1] ?? props.node.path
|
||||||
|
const head = parts.slice(0, -1).join("/")
|
||||||
|
const prefix = head ? `${head}/` : ""
|
||||||
|
const label =
|
||||||
|
props.kind === "add"
|
||||||
|
? "Additions"
|
||||||
|
: props.kind === "del"
|
||||||
|
? "Deletions"
|
||||||
|
: props.kind === "mix"
|
||||||
|
? "Modifications"
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
openDelay={2000}
|
||||||
|
placement="bottom-start"
|
||||||
|
class="w-full"
|
||||||
|
contentStyle={{ "max-width": "480px", width: "fit-content" }}
|
||||||
|
value={
|
||||||
|
<div class="flex items-center min-w-0 whitespace-nowrap text-12-regular">
|
||||||
|
<span
|
||||||
|
class="min-w-0 truncate text-text-invert-base"
|
||||||
|
style={{ direction: "rtl", "unicode-bidi": "plaintext" }}
|
||||||
|
>
|
||||||
|
{prefix}
|
||||||
|
</span>
|
||||||
|
<span class="shrink-0 text-text-invert-strong">{leaf}</span>
|
||||||
|
<Show when={label}>
|
||||||
|
{(text) => (
|
||||||
|
<>
|
||||||
|
<span class="mx-1 font-bold text-text-invert-strong">•</span>
|
||||||
|
<span class="shrink-0 text-text-invert-strong">{text()}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
<Show when={props.node.type === "directory" && props.node.ignored}>
|
||||||
|
<>
|
||||||
|
<span class="mx-1 font-bold text-text-invert-strong">•</span>
|
||||||
|
<span class="shrink-0 text-text-invert-strong">Ignored</span>
|
||||||
|
</>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function FileTree(props: {
|
export default function FileTree(props: {
|
||||||
path: string
|
path: string
|
||||||
class?: string
|
class?: string
|
||||||
@@ -76,12 +262,20 @@ export default function FileTree(props: {
|
|||||||
_marks?: Set<string>
|
_marks?: Set<string>
|
||||||
_deeps?: Map<string, number>
|
_deeps?: Map<string, number>
|
||||||
_kinds?: ReadonlyMap<string, Kind>
|
_kinds?: ReadonlyMap<string, Kind>
|
||||||
|
_chain?: readonly string[]
|
||||||
}) {
|
}) {
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
const level = props.level ?? 0
|
const level = props.level ?? 0
|
||||||
const draggable = () => props.draggable ?? true
|
const draggable = () => props.draggable ?? true
|
||||||
const tooltip = () => props.tooltip ?? true
|
const tooltip = () => props.tooltip ?? true
|
||||||
|
|
||||||
|
const key = (p: string) =>
|
||||||
|
file
|
||||||
|
.normalize(p)
|
||||||
|
.replace(/[\\/]+$/, "")
|
||||||
|
.replaceAll("\\", "/")
|
||||||
|
const chain = props._chain ? [...props._chain, key(props.path)] : [key(props.path)]
|
||||||
|
|
||||||
const filter = createMemo(() => {
|
const filter = createMemo(() => {
|
||||||
if (props._filter) return props._filter
|
if (props._filter) return props._filter
|
||||||
|
|
||||||
@@ -123,23 +317,45 @@ export default function FileTree(props: {
|
|||||||
|
|
||||||
const out = new Map<string, number>()
|
const out = new Map<string, number>()
|
||||||
|
|
||||||
const visit = (dir: string, lvl: number): number => {
|
const root = props.path
|
||||||
const expanded = file.tree.state(dir)?.expanded ?? false
|
if (!(file.tree.state(root)?.expanded ?? false)) return out
|
||||||
if (!expanded) return -1
|
|
||||||
|
|
||||||
const nodes = file.tree.children(dir)
|
const seen = new Set<string>()
|
||||||
const max = nodes.reduce((max, node) => {
|
const stack: { dir: string; lvl: number; i: number; kids: string[]; max: number }[] = []
|
||||||
if (node.type !== "directory") return max
|
|
||||||
const open = file.tree.state(node.path)?.expanded ?? false
|
|
||||||
if (!open) return max
|
|
||||||
return Math.max(max, visit(node.path, lvl + 1))
|
|
||||||
}, lvl)
|
|
||||||
|
|
||||||
out.set(dir, max)
|
const push = (dir: string, lvl: number) => {
|
||||||
return max
|
const id = key(dir)
|
||||||
|
if (seen.has(id)) return
|
||||||
|
seen.add(id)
|
||||||
|
|
||||||
|
const kids = file.tree
|
||||||
|
.children(dir)
|
||||||
|
.filter((node) => node.type === "directory" && (file.tree.state(node.path)?.expanded ?? false))
|
||||||
|
.map((node) => node.path)
|
||||||
|
|
||||||
|
stack.push({ dir, lvl, i: 0, kids, max: lvl })
|
||||||
|
}
|
||||||
|
|
||||||
|
push(root, level - 1)
|
||||||
|
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const top = stack[stack.length - 1]!
|
||||||
|
|
||||||
|
if (top.i < top.kids.length) {
|
||||||
|
const next = top.kids[top.i]!
|
||||||
|
top.i++
|
||||||
|
push(next, top.lvl + 1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
out.set(top.dir, top.max)
|
||||||
|
stack.pop()
|
||||||
|
|
||||||
|
const parent = stack[stack.length - 1]
|
||||||
|
if (!parent) continue
|
||||||
|
parent.max = Math.max(parent.max, top.max)
|
||||||
}
|
}
|
||||||
|
|
||||||
visit(props.path, level - 1)
|
|
||||||
return out
|
return out
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -230,178 +446,14 @@ export default function FileTree(props: {
|
|||||||
return out
|
return out
|
||||||
})
|
})
|
||||||
|
|
||||||
const Node = (
|
|
||||||
p: ParentProps &
|
|
||||||
ComponentProps<"div"> &
|
|
||||||
ComponentProps<"button"> & {
|
|
||||||
node: FileNode
|
|
||||||
as?: "div" | "button"
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
const [local, rest] = splitProps(p, ["node", "as", "children", "class", "classList"])
|
|
||||||
return (
|
|
||||||
<Dynamic
|
|
||||||
component={local.as ?? "div"}
|
|
||||||
classList={{
|
|
||||||
"w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-left hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
|
|
||||||
"bg-surface-base-active": local.node.path === props.active,
|
|
||||||
...(local.classList ?? {}),
|
|
||||||
[local.class ?? ""]: !!local.class,
|
|
||||||
[props.nodeClass ?? ""]: !!props.nodeClass,
|
|
||||||
}}
|
|
||||||
style={`padding-left: ${Math.max(0, 8 + level * 12 - (local.node.type === "file" ? 24 : 4))}px`}
|
|
||||||
draggable={draggable()}
|
|
||||||
onDragStart={(e: DragEvent) => {
|
|
||||||
if (!draggable()) return
|
|
||||||
e.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
|
|
||||||
e.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path))
|
|
||||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy"
|
|
||||||
|
|
||||||
const dragImage = document.createElement("div")
|
|
||||||
dragImage.className =
|
|
||||||
"flex items-center gap-x-2 px-2 py-1 bg-surface-raised-base rounded-md border border-border-base text-12-regular text-text-strong"
|
|
||||||
dragImage.style.position = "absolute"
|
|
||||||
dragImage.style.top = "-1000px"
|
|
||||||
|
|
||||||
const icon =
|
|
||||||
(e.currentTarget as HTMLElement).querySelector('[data-component="file-icon"]') ??
|
|
||||||
(e.currentTarget as HTMLElement).querySelector("svg")
|
|
||||||
const text = (e.currentTarget as HTMLElement).querySelector("span")
|
|
||||||
if (icon && text) {
|
|
||||||
dragImage.innerHTML = (icon as SVGElement).outerHTML + (text as HTMLSpanElement).outerHTML
|
|
||||||
}
|
|
||||||
|
|
||||||
document.body.appendChild(dragImage)
|
|
||||||
e.dataTransfer?.setDragImage(dragImage, 0, 12)
|
|
||||||
setTimeout(() => document.body.removeChild(dragImage), 0)
|
|
||||||
}}
|
|
||||||
{...rest}
|
|
||||||
>
|
|
||||||
{local.children}
|
|
||||||
{(() => {
|
|
||||||
const kind = kinds()?.get(local.node.path)
|
|
||||||
const marked = marks()?.has(local.node.path) ?? false
|
|
||||||
const active = !!kind && marked && !local.node.ignored
|
|
||||||
const color =
|
|
||||||
kind === "add"
|
|
||||||
? "color: var(--icon-diff-add-base)"
|
|
||||||
: kind === "del"
|
|
||||||
? "color: var(--icon-diff-delete-base)"
|
|
||||||
: kind === "mix"
|
|
||||||
? "color: var(--icon-warning-active)"
|
|
||||||
: undefined
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
classList={{
|
|
||||||
"flex-1 min-w-0 text-12-medium whitespace-nowrap truncate": true,
|
|
||||||
"text-text-weaker": local.node.ignored,
|
|
||||||
"text-text-weak": !local.node.ignored && !active,
|
|
||||||
}}
|
|
||||||
style={active ? color : undefined}
|
|
||||||
>
|
|
||||||
{local.node.name}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
{(() => {
|
|
||||||
const kind = kinds()?.get(local.node.path)
|
|
||||||
if (!kind) return null
|
|
||||||
if (!marks()?.has(local.node.path)) return null
|
|
||||||
|
|
||||||
if (local.node.type === "file") {
|
|
||||||
const text = kind === "add" ? "A" : kind === "del" ? "D" : "M"
|
|
||||||
const color =
|
|
||||||
kind === "add"
|
|
||||||
? "color: var(--icon-diff-add-base)"
|
|
||||||
: kind === "del"
|
|
||||||
? "color: var(--icon-diff-delete-base)"
|
|
||||||
: "color: var(--icon-warning-active)"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span class="shrink-0 w-4 text-center text-12-medium" style={color}>
|
|
||||||
{text}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (local.node.type === "directory") {
|
|
||||||
const color =
|
|
||||||
kind === "add"
|
|
||||||
? "background-color: var(--icon-diff-add-base)"
|
|
||||||
: kind === "del"
|
|
||||||
? "background-color: var(--icon-diff-delete-base)"
|
|
||||||
: "background-color: var(--icon-warning-active)"
|
|
||||||
|
|
||||||
return <div class="shrink-0 size-1.5 mr-1.5 rounded-full" style={color} />
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
})()}
|
|
||||||
</Dynamic>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={`flex flex-col gap-0.5 ${props.class ?? ""}`}>
|
<div data-component="filetree" class={`flex flex-col gap-0.5 ${props.class ?? ""}`}>
|
||||||
<For each={nodes()}>
|
<For each={nodes()}>
|
||||||
{(node) => {
|
{(node) => {
|
||||||
const expanded = () => file.tree.state(node.path)?.expanded ?? false
|
const expanded = () => file.tree.state(node.path)?.expanded ?? false
|
||||||
const deep = () => deeps().get(node.path) ?? -1
|
const deep = () => deeps().get(node.path) ?? -1
|
||||||
const Wrapper = (p: ParentProps) => {
|
const kind = () => visibleKind(node, kinds(), marks())
|
||||||
if (!tooltip()) return p.children
|
const active = () => !!kind() && !node.ignored
|
||||||
|
|
||||||
const parts = node.path.split("/")
|
|
||||||
const leaf = parts[parts.length - 1] ?? node.path
|
|
||||||
const head = parts.slice(0, -1).join("/")
|
|
||||||
const prefix = head ? `${head}/` : ""
|
|
||||||
|
|
||||||
const kind = () => kinds()?.get(node.path)
|
|
||||||
const label = () => {
|
|
||||||
const k = kind()
|
|
||||||
if (!k) return
|
|
||||||
if (k === "add") return "Additions"
|
|
||||||
if (k === "del") return "Deletions"
|
|
||||||
return "Modifications"
|
|
||||||
}
|
|
||||||
|
|
||||||
const ignored = () => node.type === "directory" && node.ignored
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip
|
|
||||||
openDelay={2000}
|
|
||||||
placement="bottom-start"
|
|
||||||
class="w-full"
|
|
||||||
contentStyle={{ "max-width": "480px", width: "fit-content" }}
|
|
||||||
value={
|
|
||||||
<div class="flex items-center min-w-0 whitespace-nowrap text-12-regular">
|
|
||||||
<span
|
|
||||||
class="min-w-0 truncate text-text-invert-base"
|
|
||||||
style={{ direction: "rtl", "unicode-bidi": "plaintext" }}
|
|
||||||
>
|
|
||||||
{prefix}
|
|
||||||
</span>
|
|
||||||
<span class="shrink-0 text-text-invert-strong">{leaf}</span>
|
|
||||||
<Show when={label()}>
|
|
||||||
{(t: () => string) => (
|
|
||||||
<>
|
|
||||||
<span class="mx-1 font-bold text-text-invert-strong">•</span>
|
|
||||||
<span class="shrink-0 text-text-invert-strong">{t()}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
<Show when={ignored()}>
|
|
||||||
<>
|
|
||||||
<span class="mx-1 font-bold text-text-invert-strong">•</span>
|
|
||||||
<span class="shrink-0 text-text-invert-strong">Ignored</span>
|
|
||||||
</>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{p.children}
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
@@ -415,13 +467,21 @@ export default function FileTree(props: {
|
|||||||
onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))}
|
onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))}
|
||||||
>
|
>
|
||||||
<Collapsible.Trigger>
|
<Collapsible.Trigger>
|
||||||
<Wrapper>
|
<FileTreeNodeTooltip enabled={tooltip()} node={node} kind={kind()}>
|
||||||
<Node node={node}>
|
<FileTreeNode
|
||||||
|
node={node}
|
||||||
|
level={level}
|
||||||
|
active={props.active}
|
||||||
|
nodeClass={props.nodeClass}
|
||||||
|
draggable={draggable()}
|
||||||
|
kinds={kinds()}
|
||||||
|
marks={marks()}
|
||||||
|
>
|
||||||
<div class="size-4 flex items-center justify-center text-icon-weak">
|
<div class="size-4 flex items-center justify-center text-icon-weak">
|
||||||
<Icon name={expanded() ? "chevron-down" : "chevron-right"} size="small" />
|
<Icon name={expanded() ? "chevron-down" : "chevron-right"} size="small" />
|
||||||
</div>
|
</div>
|
||||||
</Node>
|
</FileTreeNode>
|
||||||
</Wrapper>
|
</FileTreeNodeTooltip>
|
||||||
</Collapsible.Trigger>
|
</Collapsible.Trigger>
|
||||||
<Collapsible.Content class="relative pt-0.5">
|
<Collapsible.Content class="relative pt-0.5">
|
||||||
<div
|
<div
|
||||||
@@ -432,31 +492,71 @@ export default function FileTree(props: {
|
|||||||
}}
|
}}
|
||||||
style={`left: ${Math.max(0, 8 + level * 12 - 4) + 8}px`}
|
style={`left: ${Math.max(0, 8 + level * 12 - 4) + 8}px`}
|
||||||
/>
|
/>
|
||||||
<FileTree
|
<Show
|
||||||
path={node.path}
|
when={level < MAX_DEPTH && !chain.includes(key(node.path))}
|
||||||
level={level + 1}
|
fallback={<div class="px-2 py-1 text-12-regular text-text-weak">...</div>}
|
||||||
allowed={props.allowed}
|
>
|
||||||
modified={props.modified}
|
<FileTree
|
||||||
kinds={props.kinds}
|
path={node.path}
|
||||||
active={props.active}
|
level={level + 1}
|
||||||
draggable={props.draggable}
|
allowed={props.allowed}
|
||||||
tooltip={props.tooltip}
|
modified={props.modified}
|
||||||
onFileClick={props.onFileClick}
|
kinds={props.kinds}
|
||||||
_filter={filter()}
|
active={props.active}
|
||||||
_marks={marks()}
|
draggable={props.draggable}
|
||||||
_deeps={deeps()}
|
tooltip={props.tooltip}
|
||||||
_kinds={kinds()}
|
onFileClick={props.onFileClick}
|
||||||
/>
|
_filter={filter()}
|
||||||
|
_marks={marks()}
|
||||||
|
_deeps={deeps()}
|
||||||
|
_kinds={kinds()}
|
||||||
|
_chain={chain}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
</Collapsible.Content>
|
</Collapsible.Content>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={node.type === "file"}>
|
<Match when={node.type === "file"}>
|
||||||
<Wrapper>
|
<FileTreeNodeTooltip enabled={tooltip()} node={node} kind={kind()}>
|
||||||
<Node node={node} as="button" type="button" onClick={() => props.onFileClick?.(node)}>
|
<FileTreeNode
|
||||||
|
node={node}
|
||||||
|
level={level}
|
||||||
|
active={props.active}
|
||||||
|
nodeClass={props.nodeClass}
|
||||||
|
draggable={draggable()}
|
||||||
|
kinds={kinds()}
|
||||||
|
marks={marks()}
|
||||||
|
as="button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => props.onFileClick?.(node)}
|
||||||
|
>
|
||||||
<div class="w-4 shrink-0" />
|
<div class="w-4 shrink-0" />
|
||||||
<FileIcon node={node} class="text-icon-weak size-4" />
|
<Switch>
|
||||||
</Node>
|
<Match when={node.ignored}>
|
||||||
</Wrapper>
|
<FileIcon
|
||||||
|
node={node}
|
||||||
|
class="size-4 filetree-icon filetree-icon--mono"
|
||||||
|
style="color: var(--icon-weak-base)"
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
|
<Match when={active()}>
|
||||||
|
<FileIcon
|
||||||
|
node={node}
|
||||||
|
class="size-4 filetree-icon filetree-icon--mono"
|
||||||
|
style={kindTextColor(kind()!)}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
|
<Match when={!node.ignored}>
|
||||||
|
<span class="filetree-iconpair size-4">
|
||||||
|
<FileIcon node={node} class="size-4 filetree-icon filetree-icon--color" />
|
||||||
|
<FileIcon node={node} class="size-4 filetree-icon filetree-icon--mono" mono />
|
||||||
|
</span>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</FileTreeNode>
|
||||||
|
</FileTreeNodeTooltip>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,17 +1,26 @@
|
|||||||
import { ComponentProps, splitProps } from "solid-js"
|
import { ComponentProps, splitProps } from "solid-js"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
|
|
||||||
export interface LinkProps extends ComponentProps<"button"> {
|
export interface LinkProps extends Omit<ComponentProps<"a">, "href"> {
|
||||||
href: string
|
href: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Link(props: LinkProps) {
|
export function Link(props: LinkProps) {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const [local, rest] = splitProps(props, ["href", "children"])
|
const [local, rest] = splitProps(props, ["href", "children", "class"])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button class="text-text-strong underline" onClick={() => platform.openLink(local.href)} {...rest}>
|
<a
|
||||||
|
href={local.href}
|
||||||
|
class={`text-text-strong underline ${local.class ?? ""}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (!local.href) return
|
||||||
|
event.preventDefault()
|
||||||
|
platform.openLink(local.href)
|
||||||
|
}}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
{local.children}
|
{local.children}
|
||||||
</button>
|
</a>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import { onCleanup, onMount } from "solid-js"
|
|||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt"
|
import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { uuid } from "@/utils/uuid"
|
||||||
import { getCursorPosition } from "./editor-dom"
|
import { getCursorPosition } from "./editor-dom"
|
||||||
|
|
||||||
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
|
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
|
||||||
@@ -31,7 +32,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
|||||||
const dataUrl = reader.result as string
|
const dataUrl = reader.result as string
|
||||||
const attachment: ImageAttachmentPart = {
|
const attachment: ImageAttachmentPart = {
|
||||||
type: "image",
|
type: "image",
|
||||||
id: crypto.randomUUID?.() ?? Math.random().toString(16).slice(2),
|
id: uuid(),
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
mime: file.type,
|
mime: file.type,
|
||||||
dataUrl,
|
dataUrl,
|
||||||
@@ -88,6 +89,9 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!plainText) return
|
if (!plainText) return
|
||||||
|
const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, plainText)
|
||||||
|
if (inserted) return
|
||||||
|
|
||||||
input.addPart({ type: "text", content: plainText, start: 0, end: 0 })
|
input.addPart({ type: "text", content: plainText, start: 0, end: 0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,61 +20,67 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
|||||||
<Show when={props.items.length > 0}>
|
<Show when={props.items.length > 0}>
|
||||||
<div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar">
|
<div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar">
|
||||||
<For each={props.items}>
|
<For each={props.items}>
|
||||||
{(item) => (
|
{(item) => {
|
||||||
<Tooltip
|
const directory = getDirectory(item.path)
|
||||||
value={
|
const filename = getFilename(item.path)
|
||||||
<span class="flex max-w-[300px]">
|
const label = getFilenameTruncated(item.path, 14)
|
||||||
<span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">
|
const selected = props.active(item)
|
||||||
{getDirectory(item.path)}
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
value={
|
||||||
|
<span class="flex max-w-[300px]">
|
||||||
|
<span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">
|
||||||
|
{directory}
|
||||||
|
</span>
|
||||||
|
<span class="shrink-0">{filename}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="shrink-0">{getFilename(item.path)}</span>
|
}
|
||||||
</span>
|
placement="top"
|
||||||
}
|
openDelay={2000}
|
||||||
placement="top"
|
|
||||||
openDelay={2000}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
classList={{
|
|
||||||
"group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
|
|
||||||
"cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !props.active(item),
|
|
||||||
"cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover":
|
|
||||||
props.active(item),
|
|
||||||
"bg-background-stronger": !props.active(item),
|
|
||||||
}}
|
|
||||||
onClick={() => props.openComment(item)}
|
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-1.5">
|
<div
|
||||||
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
|
classList={{
|
||||||
<div class="flex items-center text-11-regular min-w-0 font-medium">
|
"group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 cursor-default transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
|
||||||
<span class="text-text-strong whitespace-nowrap">{getFilenameTruncated(item.path, 14)}</span>
|
"hover:bg-surface-interactive-weak": !!item.commentID && !selected,
|
||||||
<Show when={item.selection}>
|
"bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover": selected,
|
||||||
{(sel) => (
|
"bg-background-stronger": !selected,
|
||||||
<span class="text-text-weak whitespace-nowrap shrink-0">
|
}}
|
||||||
{sel().startLine === sel().endLine
|
onClick={() => props.openComment(item)}
|
||||||
? `:${sel().startLine}`
|
>
|
||||||
: `:${sel().startLine}-${sel().endLine}`}
|
<div class="flex items-center gap-1.5">
|
||||||
</span>
|
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
|
||||||
)}
|
<div class="flex items-center text-11-regular min-w-0 font-medium">
|
||||||
</Show>
|
<span class="text-text-strong whitespace-nowrap">{label}</span>
|
||||||
|
<Show when={item.selection}>
|
||||||
|
{(sel) => (
|
||||||
|
<span class="text-text-weak whitespace-nowrap shrink-0">
|
||||||
|
{sel().startLine === sel().endLine
|
||||||
|
? `:${sel().startLine}`
|
||||||
|
: `:${sel().startLine}-${sel().endLine}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
icon="close-small"
|
||||||
|
variant="ghost"
|
||||||
|
class="ml-auto size-3.5 text-text-weak hover:text-text-strong transition-all"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
props.remove(item)
|
||||||
|
}}
|
||||||
|
aria-label={props.t("prompt.context.removeFile")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<IconButton
|
<Show when={item.comment}>
|
||||||
type="button"
|
{(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
|
||||||
icon="close-small"
|
</Show>
|
||||||
variant="ghost"
|
|
||||||
class="ml-auto size-3.5 text-text-weak hover:text-text-strong transition-all"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
props.remove(item)
|
|
||||||
}}
|
|
||||||
aria-label={props.t("prompt.context.removeFile")}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<Show when={item.comment}>
|
</Tooltip>
|
||||||
{(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
|
)
|
||||||
</Show>
|
}}
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -6,12 +6,17 @@ type PromptDragOverlayProps = {
|
|||||||
label: string
|
label: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const kindToIcon = {
|
||||||
|
image: "photo",
|
||||||
|
"@mention": "link",
|
||||||
|
} as const
|
||||||
|
|
||||||
export const PromptDragOverlay: Component<PromptDragOverlayProps> = (props) => {
|
export const PromptDragOverlay: Component<PromptDragOverlayProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<Show when={props.type !== null}>
|
<Show when={props.type !== null}>
|
||||||
<div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none">
|
<div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none">
|
||||||
<div class="flex flex-col items-center gap-2 text-text-weak">
|
<div class="flex flex-col items-center gap-2 text-text-weak">
|
||||||
<Icon name={props.type === "@mention" ? "link" : "photo"} class="size-8" />
|
<Icon name={props.type ? kindToIcon[props.type] : kindToIcon.image} class="size-8" />
|
||||||
<span class="text-14-regular">{props.label}</span>
|
<span class="text-14-regular">{props.label}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,17 +2,26 @@ import { describe, expect, test } from "bun:test"
|
|||||||
import { createTextFragment, getCursorPosition, getNodeLength, getTextLength, setCursorPosition } from "./editor-dom"
|
import { createTextFragment, getCursorPosition, getNodeLength, getTextLength, setCursorPosition } from "./editor-dom"
|
||||||
|
|
||||||
describe("prompt-input editor dom", () => {
|
describe("prompt-input editor dom", () => {
|
||||||
test("createTextFragment preserves newlines with br and zero-width placeholders", () => {
|
test("createTextFragment preserves newlines with consecutive br nodes", () => {
|
||||||
const fragment = createTextFragment("foo\n\nbar")
|
const fragment = createTextFragment("foo\n\nbar")
|
||||||
const container = document.createElement("div")
|
const container = document.createElement("div")
|
||||||
container.appendChild(fragment)
|
container.appendChild(fragment)
|
||||||
|
|
||||||
expect(container.childNodes.length).toBe(5)
|
expect(container.childNodes.length).toBe(4)
|
||||||
|
expect(container.childNodes[0]?.textContent).toBe("foo")
|
||||||
|
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
|
||||||
|
expect((container.childNodes[2] as HTMLElement).tagName).toBe("BR")
|
||||||
|
expect(container.childNodes[3]?.textContent).toBe("bar")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("createTextFragment keeps trailing newline as terminal break", () => {
|
||||||
|
const fragment = createTextFragment("foo\n")
|
||||||
|
const container = document.createElement("div")
|
||||||
|
container.appendChild(fragment)
|
||||||
|
|
||||||
|
expect(container.childNodes.length).toBe(2)
|
||||||
expect(container.childNodes[0]?.textContent).toBe("foo")
|
expect(container.childNodes[0]?.textContent).toBe("foo")
|
||||||
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
|
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
|
||||||
expect(container.childNodes[2]?.textContent).toBe("\u200B")
|
|
||||||
expect((container.childNodes[3] as HTMLElement).tagName).toBe("BR")
|
|
||||||
expect(container.childNodes[4]?.textContent).toBe("bar")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("length helpers treat breaks as one char and ignore zero-width chars", () => {
|
test("length helpers treat breaks as one char and ignore zero-width chars", () => {
|
||||||
@@ -48,4 +57,21 @@ describe("prompt-input editor dom", () => {
|
|||||||
|
|
||||||
container.remove()
|
container.remove()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("setCursorPosition and getCursorPosition round-trip across blank lines", () => {
|
||||||
|
const container = document.createElement("div")
|
||||||
|
container.appendChild(document.createTextNode("a"))
|
||||||
|
container.appendChild(document.createElement("br"))
|
||||||
|
container.appendChild(document.createElement("br"))
|
||||||
|
container.appendChild(document.createTextNode("b"))
|
||||||
|
document.body.appendChild(container)
|
||||||
|
|
||||||
|
setCursorPosition(container, 2)
|
||||||
|
expect(getCursorPosition(container)).toBe(2)
|
||||||
|
|
||||||
|
setCursorPosition(container, 3)
|
||||||
|
expect(getCursorPosition(container)).toBe(3)
|
||||||
|
|
||||||
|
container.remove()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ export function createTextFragment(content: string): DocumentFragment {
|
|||||||
segments.forEach((segment, index) => {
|
segments.forEach((segment, index) => {
|
||||||
if (segment) {
|
if (segment) {
|
||||||
fragment.appendChild(document.createTextNode(segment))
|
fragment.appendChild(document.createTextNode(segment))
|
||||||
} else if (segments.length > 1) {
|
|
||||||
fragment.appendChild(document.createTextNode("\u200B"))
|
|
||||||
}
|
}
|
||||||
if (index < segments.length - 1) {
|
if (index < segments.length - 1) {
|
||||||
fragment.appendChild(document.createElement("br"))
|
fragment.appendChild(document.createElement("br"))
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { Prompt } from "@/context/prompt"
|
import type { Prompt } from "@/context/prompt"
|
||||||
import { clonePromptParts, navigatePromptHistory, prependHistoryEntry, promptLength } from "./history"
|
import {
|
||||||
|
canNavigateHistoryAtCursor,
|
||||||
|
clonePromptParts,
|
||||||
|
navigatePromptHistory,
|
||||||
|
prependHistoryEntry,
|
||||||
|
promptLength,
|
||||||
|
} from "./history"
|
||||||
|
|
||||||
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||||
|
|
||||||
@@ -66,4 +72,29 @@ describe("prompt-input history", () => {
|
|||||||
if (original[1]?.type !== "file") throw new Error("expected file")
|
if (original[1]?.type !== "file") throw new Error("expected file")
|
||||||
expect(original[1].selection?.startLine).toBe(1)
|
expect(original[1].selection?.startLine).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("canNavigateHistoryAtCursor only allows prompt boundaries", () => {
|
||||||
|
const value = "a\nb\nc"
|
||||||
|
|
||||||
|
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(true)
|
||||||
|
expect(canNavigateHistoryAtCursor("down", value, 0)).toBe(false)
|
||||||
|
|
||||||
|
expect(canNavigateHistoryAtCursor("up", value, 2)).toBe(false)
|
||||||
|
expect(canNavigateHistoryAtCursor("down", value, 2)).toBe(false)
|
||||||
|
|
||||||
|
expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false)
|
||||||
|
expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true)
|
||||||
|
|
||||||
|
expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(true)
|
||||||
|
expect(canNavigateHistoryAtCursor("down", "abc", 3)).toBe(true)
|
||||||
|
expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(false)
|
||||||
|
expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(false)
|
||||||
|
|
||||||
|
expect(canNavigateHistoryAtCursor("up", "abc", 0, true)).toBe(true)
|
||||||
|
expect(canNavigateHistoryAtCursor("up", "abc", 3, true)).toBe(true)
|
||||||
|
expect(canNavigateHistoryAtCursor("down", "abc", 0, true)).toBe(true)
|
||||||
|
expect(canNavigateHistoryAtCursor("down", "abc", 3, true)).toBe(true)
|
||||||
|
expect(canNavigateHistoryAtCursor("up", "abc", 1, true)).toBe(false)
|
||||||
|
expect(canNavigateHistoryAtCursor("down", "abc", 1, true)).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,15 @@ const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
|||||||
|
|
||||||
export const MAX_HISTORY = 100
|
export const MAX_HISTORY = 100
|
||||||
|
|
||||||
|
export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number, inHistory = false) {
|
||||||
|
const position = Math.max(0, Math.min(cursor, text.length))
|
||||||
|
const atStart = position === 0
|
||||||
|
const atEnd = position === text.length
|
||||||
|
if (inHistory) return atStart || atEnd
|
||||||
|
if (direction === "up") return position === 0
|
||||||
|
return position === text.length
|
||||||
|
}
|
||||||
|
|
||||||
export function clonePromptParts(prompt: Prompt): Prompt {
|
export function clonePromptParts(prompt: Prompt): Prompt {
|
||||||
return prompt.map((part) => {
|
return prompt.map((part) => {
|
||||||
if (part.type === "text") return { ...part }
|
if (part.type === "text") return { ...part }
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ type PromptImageAttachmentsProps = {
|
|||||||
removeLabel: string
|
removeLabel: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fallbackClass = "size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base"
|
||||||
|
const imageClass =
|
||||||
|
"size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
|
||||||
|
const removeClass =
|
||||||
|
"absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
|
||||||
|
const nameClass = "absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md"
|
||||||
|
|
||||||
export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
|
export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<Show when={props.attachments.length > 0}>
|
<Show when={props.attachments.length > 0}>
|
||||||
@@ -19,7 +26,7 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
|
|||||||
<Show
|
<Show
|
||||||
when={attachment.mime.startsWith("image/")}
|
when={attachment.mime.startsWith("image/")}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base">
|
<div class={fallbackClass}>
|
||||||
<Icon name="folder" class="size-6 text-text-weak" />
|
<Icon name="folder" class="size-6 text-text-weak" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -27,19 +34,19 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
|
|||||||
<img
|
<img
|
||||||
src={attachment.dataUrl}
|
src={attachment.dataUrl}
|
||||||
alt={attachment.filename}
|
alt={attachment.filename}
|
||||||
class="size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
|
class={imageClass}
|
||||||
onClick={() => props.onOpen(attachment)}
|
onClick={() => props.onOpen(attachment)}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => props.onRemove(attachment.id)}
|
onClick={() => props.onRemove(attachment.id)}
|
||||||
class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
|
class={removeClass}
|
||||||
aria-label={props.removeLabel}
|
aria-label={props.removeLabel}
|
||||||
>
|
>
|
||||||
<Icon name="close" class="size-3 text-text-weak" />
|
<Icon name="close" class="size-3 text-text-weak" />
|
||||||
</button>
|
</button>
|
||||||
<div class="absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md">
|
<div class={nameClass}>
|
||||||
<span class="text-10-regular text-white truncate block">{attachment.filename}</span>
|
<span class="text-10-regular text-white truncate block">{attachment.filename}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,27 +9,40 @@ describe("promptPlaceholder", () => {
|
|||||||
mode: "shell",
|
mode: "shell",
|
||||||
commentCount: 0,
|
commentCount: 0,
|
||||||
example: "example",
|
example: "example",
|
||||||
|
suggest: true,
|
||||||
t,
|
t,
|
||||||
})
|
})
|
||||||
expect(value).toBe("prompt.placeholder.shell")
|
expect(value).toBe("prompt.placeholder.shell")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("returns summarize placeholders for comment context", () => {
|
test("returns summarize placeholders for comment context", () => {
|
||||||
expect(promptPlaceholder({ mode: "normal", commentCount: 1, example: "example", t })).toBe(
|
expect(promptPlaceholder({ mode: "normal", commentCount: 1, example: "example", suggest: true, t })).toBe(
|
||||||
"prompt.placeholder.summarizeComment",
|
"prompt.placeholder.summarizeComment",
|
||||||
)
|
)
|
||||||
expect(promptPlaceholder({ mode: "normal", commentCount: 2, example: "example", t })).toBe(
|
expect(promptPlaceholder({ mode: "normal", commentCount: 2, example: "example", suggest: true, t })).toBe(
|
||||||
"prompt.placeholder.summarizeComments",
|
"prompt.placeholder.summarizeComments",
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("returns default placeholder with example", () => {
|
test("returns default placeholder with example when suggestions enabled", () => {
|
||||||
const value = promptPlaceholder({
|
const value = promptPlaceholder({
|
||||||
mode: "normal",
|
mode: "normal",
|
||||||
commentCount: 0,
|
commentCount: 0,
|
||||||
example: "translated-example",
|
example: "translated-example",
|
||||||
|
suggest: true,
|
||||||
t,
|
t,
|
||||||
})
|
})
|
||||||
expect(value).toBe("prompt.placeholder.normal:translated-example")
|
expect(value).toBe("prompt.placeholder.normal:translated-example")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("returns simple placeholder when suggestions disabled", () => {
|
||||||
|
const value = promptPlaceholder({
|
||||||
|
mode: "normal",
|
||||||
|
commentCount: 0,
|
||||||
|
example: "translated-example",
|
||||||
|
suggest: false,
|
||||||
|
t,
|
||||||
|
})
|
||||||
|
expect(value).toBe("prompt.placeholder.simple")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ type PromptPlaceholderInput = {
|
|||||||
mode: "normal" | "shell"
|
mode: "normal" | "shell"
|
||||||
commentCount: number
|
commentCount: number
|
||||||
example: string
|
example: string
|
||||||
|
suggest: boolean
|
||||||
t: (key: string, params?: Record<string, string>) => string
|
t: (key: string, params?: Record<string, string>) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9,5 +10,6 @@ export function promptPlaceholder(input: PromptPlaceholderInput) {
|
|||||||
if (input.mode === "shell") return input.t("prompt.placeholder.shell")
|
if (input.mode === "shell") return input.t("prompt.placeholder.shell")
|
||||||
if (input.commentCount > 1) return input.t("prompt.placeholder.summarizeComments")
|
if (input.commentCount > 1) return input.t("prompt.placeholder.summarizeComments")
|
||||||
if (input.commentCount === 1) return input.t("prompt.placeholder.summarizeComment")
|
if (input.commentCount === 1) return input.t("prompt.placeholder.summarizeComment")
|
||||||
|
if (!input.suggest) return input.t("prompt.placeholder.simple")
|
||||||
return input.t("prompt.placeholder.normal", { example: input.example })
|
return input.t("prompt.placeholder.normal", { example: input.example })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,9 +40,9 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
|
|||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
if (props.popover === "slash") props.setSlashPopoverRef(el)
|
if (props.popover === "slash") props.setSlashPopoverRef(el)
|
||||||
}}
|
}}
|
||||||
class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10
|
class="absolute inset-x-0 -top-2 -translate-y-full origin-bottom-left max-h-80 min-h-10
|
||||||
overflow-auto no-scrollbar flex flex-col p-2 rounded-md
|
overflow-auto no-scrollbar flex flex-col p-2 rounded-[12px]
|
||||||
border border-border-base bg-surface-raised-stronger-non-alpha shadow-md"
|
bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-lg-border-base)]"
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
<Switch>
|
<Switch>
|
||||||
@@ -52,47 +52,44 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
|
|||||||
fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyResults")}</div>}
|
fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyResults")}</div>}
|
||||||
>
|
>
|
||||||
<For each={props.atFlat.slice(0, 10)}>
|
<For each={props.atFlat.slice(0, 10)}>
|
||||||
{(item) => (
|
{(item) => {
|
||||||
<button
|
const key = props.atKey(item)
|
||||||
classList={{
|
|
||||||
"w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true,
|
if (item.type === "agent") {
|
||||||
"bg-surface-raised-base-hover": props.atActive === props.atKey(item),
|
return (
|
||||||
}}
|
<button
|
||||||
onClick={() => props.onAtSelect(item)}
|
class="w-full flex items-center gap-x-2 rounded-md px-2 py-0.5"
|
||||||
onMouseEnter={() => props.setAtActive(props.atKey(item))}
|
classList={{ "bg-surface-raised-base-hover": props.atActive === key }}
|
||||||
>
|
onClick={() => props.onAtSelect(item)}
|
||||||
<Show
|
onMouseEnter={() => props.setAtActive(key)}
|
||||||
when={item.type === "agent"}
|
>
|
||||||
fallback={
|
<Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
|
||||||
<>
|
<span class="text-14-regular text-text-strong whitespace-nowrap">@{item.name}</span>
|
||||||
<FileIcon
|
</button>
|
||||||
node={{ path: item.type === "file" ? item.path : "", type: "file" }}
|
)
|
||||||
class="shrink-0 size-4"
|
}
|
||||||
/>
|
|
||||||
<div class="flex items-center text-14-regular min-w-0">
|
const isDirectory = item.path.endsWith("/")
|
||||||
<span class="text-text-weak whitespace-nowrap truncate min-w-0">
|
const directory = isDirectory ? item.path : getDirectory(item.path)
|
||||||
{item.type === "file"
|
const filename = isDirectory ? "" : getFilename(item.path)
|
||||||
? item.path.endsWith("/")
|
|
||||||
? item.path
|
return (
|
||||||
: getDirectory(item.path)
|
<button
|
||||||
: ""}
|
class="w-full flex items-center gap-x-2 rounded-md px-2 py-0.5"
|
||||||
</span>
|
classList={{ "bg-surface-raised-base-hover": props.atActive === key }}
|
||||||
<Show when={item.type === "file" && !item.path.endsWith("/")}>
|
onClick={() => props.onAtSelect(item)}
|
||||||
<span class="text-text-strong whitespace-nowrap">
|
onMouseEnter={() => props.setAtActive(key)}
|
||||||
{item.type === "file" ? getFilename(item.path) : ""}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
|
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" />
|
||||||
<span class="text-14-regular text-text-strong whitespace-nowrap">
|
<div class="flex items-center text-14-regular min-w-0">
|
||||||
@{item.type === "agent" ? item.name : ""}
|
<span class="text-text-weak whitespace-nowrap truncate min-w-0">{directory}</span>
|
||||||
</span>
|
<Show when={!isDirectory}>
|
||||||
</Show>
|
<span class="text-text-strong whitespace-nowrap">{filename}</span>
|
||||||
</button>
|
</Show>
|
||||||
)}
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}}
|
||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
</Match>
|
</Match>
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
queued.abort.abort()
|
queued.abort.abort()
|
||||||
queued.cleanup()
|
queued.cleanup()
|
||||||
pending.delete(sessionID)
|
pending.delete(sessionID)
|
||||||
|
globalSync.todo.set(sessionID, undefined)
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
return sdk.client.session
|
return sdk.client.session
|
||||||
@@ -87,6 +88,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
sessionID,
|
sessionID,
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
globalSync.todo.set(sessionID, undefined)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const restoreCommentItems = (items: CommentItem[]) => {
|
const restoreCommentItems = (items: CommentItem[]) => {
|
||||||
@@ -385,7 +389,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
const send = async () => {
|
const send = async () => {
|
||||||
const ok = await waitForWorktree()
|
const ok = await waitForWorktree()
|
||||||
if (!ok) return
|
if (!ok) return
|
||||||
await client.session.prompt({
|
await client.session.promptAsync({
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
agent,
|
agent,
|
||||||
model,
|
model,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { For, Show, createMemo, type Component } from "solid-js"
|
import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||||
@@ -12,25 +13,98 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
const questions = createMemo(() => props.request.questions)
|
const questions = createMemo(() => props.request.questions)
|
||||||
const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
|
const total = createMemo(() => questions().length)
|
||||||
|
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
tab: 0,
|
tab: 0,
|
||||||
answers: [] as QuestionAnswer[],
|
answers: [] as QuestionAnswer[],
|
||||||
custom: [] as string[],
|
custom: [] as string[],
|
||||||
|
customOn: [] as boolean[],
|
||||||
editing: false,
|
editing: false,
|
||||||
sending: false,
|
sending: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let root: HTMLDivElement | undefined
|
||||||
|
|
||||||
const question = createMemo(() => questions()[store.tab])
|
const question = createMemo(() => questions()[store.tab])
|
||||||
const confirm = createMemo(() => !single() && store.tab === questions().length)
|
|
||||||
const options = createMemo(() => question()?.options ?? [])
|
const options = createMemo(() => question()?.options ?? [])
|
||||||
const input = createMemo(() => store.custom[store.tab] ?? "")
|
const input = createMemo(() => store.custom[store.tab] ?? "")
|
||||||
|
const on = createMemo(() => store.customOn[store.tab] === true)
|
||||||
const multi = createMemo(() => question()?.multiple === true)
|
const multi = createMemo(() => question()?.multiple === true)
|
||||||
const customPicked = createMemo(() => {
|
|
||||||
const value = input()
|
const summary = createMemo(() => {
|
||||||
if (!value) return false
|
const n = Math.min(store.tab + 1, total())
|
||||||
return store.answers[store.tab]?.includes(value) ?? false
|
return `${n} of ${total()} questions`
|
||||||
|
})
|
||||||
|
|
||||||
|
const last = createMemo(() => store.tab >= total() - 1)
|
||||||
|
|
||||||
|
const customUpdate = (value: string, selected: boolean = on()) => {
|
||||||
|
const prev = input().trim()
|
||||||
|
const next = value.trim()
|
||||||
|
|
||||||
|
setStore("custom", store.tab, value)
|
||||||
|
if (!selected) return
|
||||||
|
|
||||||
|
if (multi()) {
|
||||||
|
setStore("answers", store.tab, (current = []) => {
|
||||||
|
const removed = prev ? current.filter((item) => item.trim() !== prev) : current
|
||||||
|
if (!next) return removed
|
||||||
|
if (removed.some((item) => item.trim() === next)) return removed
|
||||||
|
return [...removed, next]
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setStore("answers", store.tab, next ? [next] : [])
|
||||||
|
}
|
||||||
|
|
||||||
|
const measure = () => {
|
||||||
|
if (!root) return
|
||||||
|
|
||||||
|
const scroller = document.querySelector(".session-scroller")
|
||||||
|
const head = scroller instanceof HTMLElement ? scroller.firstElementChild : undefined
|
||||||
|
const top =
|
||||||
|
head instanceof HTMLElement && head.classList.contains("sticky") ? head.getBoundingClientRect().bottom : 0
|
||||||
|
if (!top) {
|
||||||
|
root.style.removeProperty("--question-prompt-max-height")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const dock = root.closest('[data-component="session-prompt-dock"]')
|
||||||
|
if (!(dock instanceof HTMLElement)) return
|
||||||
|
|
||||||
|
const dockBottom = dock.getBoundingClientRect().bottom
|
||||||
|
const below = Math.max(0, dockBottom - root.getBoundingClientRect().bottom)
|
||||||
|
const gap = 8
|
||||||
|
const max = Math.max(240, Math.floor(dockBottom - top - gap - below))
|
||||||
|
root.style.setProperty("--question-prompt-max-height", `${max}px`)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
let raf: number | undefined
|
||||||
|
const update = () => {
|
||||||
|
if (raf !== undefined) cancelAnimationFrame(raf)
|
||||||
|
raf = requestAnimationFrame(() => {
|
||||||
|
raf = undefined
|
||||||
|
measure()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
update()
|
||||||
|
window.addEventListener("resize", update)
|
||||||
|
|
||||||
|
const dock = root?.closest('[data-component="session-prompt-dock"]')
|
||||||
|
const scroller = document.querySelector(".session-scroller")
|
||||||
|
const observer = new ResizeObserver(update)
|
||||||
|
if (dock instanceof HTMLElement) observer.observe(dock)
|
||||||
|
if (scroller instanceof HTMLElement) observer.observe(scroller)
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
window.removeEventListener("resize", update)
|
||||||
|
observer.disconnect()
|
||||||
|
if (raf !== undefined) cancelAnimationFrame(raf)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const fail = (err: unknown) => {
|
const fail = (err: unknown) => {
|
||||||
@@ -38,71 +112,83 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
|
|||||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||||
}
|
}
|
||||||
|
|
||||||
const reply = (answers: QuestionAnswer[]) => {
|
const reply = async (answers: QuestionAnswer[]) => {
|
||||||
if (store.sending) return
|
if (store.sending) return
|
||||||
|
|
||||||
setStore("sending", true)
|
setStore("sending", true)
|
||||||
sdk.client.question
|
try {
|
||||||
.reply({ requestID: props.request.id, answers })
|
await sdk.client.question.reply({ requestID: props.request.id, answers })
|
||||||
.catch(fail)
|
} catch (err) {
|
||||||
.finally(() => setStore("sending", false))
|
fail(err)
|
||||||
|
} finally {
|
||||||
|
setStore("sending", false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const reject = () => {
|
const reject = async () => {
|
||||||
if (store.sending) return
|
if (store.sending) return
|
||||||
|
|
||||||
setStore("sending", true)
|
setStore("sending", true)
|
||||||
sdk.client.question
|
try {
|
||||||
.reject({ requestID: props.request.id })
|
await sdk.client.question.reject({ requestID: props.request.id })
|
||||||
.catch(fail)
|
} catch (err) {
|
||||||
.finally(() => setStore("sending", false))
|
fail(err)
|
||||||
|
} finally {
|
||||||
|
setStore("sending", false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const submit = () => {
|
const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? []))
|
||||||
reply(questions().map((_, i) => store.answers[i] ?? []))
|
|
||||||
}
|
|
||||||
|
|
||||||
const pick = (answer: string, custom: boolean = false) => {
|
const pick = (answer: string, custom: boolean = false) => {
|
||||||
const answers = [...store.answers]
|
setStore("answers", store.tab, [answer])
|
||||||
answers[store.tab] = [answer]
|
if (custom) setStore("custom", store.tab, answer)
|
||||||
setStore("answers", answers)
|
if (!custom) setStore("customOn", store.tab, false)
|
||||||
|
setStore("editing", false)
|
||||||
if (custom) {
|
|
||||||
const inputs = [...store.custom]
|
|
||||||
inputs[store.tab] = answer
|
|
||||||
setStore("custom", inputs)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (single()) {
|
|
||||||
reply([[answer]])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setStore("tab", store.tab + 1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggle = (answer: string) => {
|
const toggle = (answer: string) => {
|
||||||
const existing = store.answers[store.tab] ?? []
|
setStore("answers", store.tab, (current = []) => {
|
||||||
const next = [...existing]
|
if (current.includes(answer)) return current.filter((item) => item !== answer)
|
||||||
const index = next.indexOf(answer)
|
return [...current, answer]
|
||||||
if (index === -1) next.push(answer)
|
})
|
||||||
if (index !== -1) next.splice(index, 1)
|
|
||||||
|
|
||||||
const answers = [...store.answers]
|
|
||||||
answers[store.tab] = next
|
|
||||||
setStore("answers", answers)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectTab = (index: number) => {
|
const customToggle = () => {
|
||||||
setStore("tab", index)
|
if (store.sending) return
|
||||||
|
|
||||||
|
if (!multi()) {
|
||||||
|
setStore("customOn", store.tab, true)
|
||||||
|
setStore("editing", true)
|
||||||
|
customUpdate(input(), true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = !on()
|
||||||
|
setStore("customOn", store.tab, next)
|
||||||
|
if (next) {
|
||||||
|
setStore("editing", true)
|
||||||
|
customUpdate(input(), true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = input().trim()
|
||||||
|
if (value) setStore("answers", store.tab, (current = []) => current.filter((item) => item.trim() !== value))
|
||||||
setStore("editing", false)
|
setStore("editing", false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const customOpen = () => {
|
||||||
|
if (store.sending) return
|
||||||
|
if (!on()) setStore("customOn", store.tab, true)
|
||||||
|
setStore("editing", true)
|
||||||
|
customUpdate(input(), true)
|
||||||
|
}
|
||||||
|
|
||||||
const selectOption = (optIndex: number) => {
|
const selectOption = (optIndex: number) => {
|
||||||
if (store.sending) return
|
if (store.sending) return
|
||||||
|
|
||||||
if (optIndex === options().length) {
|
if (optIndex === options().length) {
|
||||||
setStore("editing", true)
|
customOpen()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,181 +201,225 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
|
|||||||
pick(opt.label)
|
pick(opt.label)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCustomSubmit = (e: Event) => {
|
const commitCustom = () => {
|
||||||
e.preventDefault()
|
setStore("editing", false)
|
||||||
|
customUpdate(input())
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = () => {
|
||||||
if (store.sending) return
|
if (store.sending) return
|
||||||
|
if (store.editing) commitCustom()
|
||||||
|
|
||||||
const value = input().trim()
|
if (store.tab >= total() - 1) {
|
||||||
if (!value) {
|
submit()
|
||||||
setStore("editing", false)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (multi()) {
|
setStore("tab", store.tab + 1)
|
||||||
const existing = store.answers[store.tab] ?? []
|
setStore("editing", false)
|
||||||
const next = [...existing]
|
}
|
||||||
if (!next.includes(value)) next.push(value)
|
|
||||||
|
|
||||||
const answers = [...store.answers]
|
const back = () => {
|
||||||
answers[store.tab] = next
|
if (store.sending) return
|
||||||
setStore("answers", answers)
|
if (store.tab <= 0) return
|
||||||
setStore("editing", false)
|
setStore("tab", store.tab - 1)
|
||||||
return
|
setStore("editing", false)
|
||||||
}
|
}
|
||||||
|
|
||||||
pick(value, true)
|
const jump = (tab: number) => {
|
||||||
|
if (store.sending) return
|
||||||
|
setStore("tab", tab)
|
||||||
setStore("editing", false)
|
setStore("editing", false)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-component="question-prompt">
|
<DockPrompt
|
||||||
<Show when={!single()}>
|
kind="question"
|
||||||
<div data-slot="question-tabs">
|
ref={(el) => (root = el)}
|
||||||
<For each={questions()}>
|
header={
|
||||||
{(q, index) => {
|
<>
|
||||||
const active = () => index() === store.tab
|
<div data-slot="question-header-title">{summary()}</div>
|
||||||
const answered = () => (store.answers[index()]?.length ?? 0) > 0
|
<div data-slot="question-progress">
|
||||||
return (
|
<For each={questions()}>
|
||||||
|
{(_, i) => (
|
||||||
<button
|
<button
|
||||||
data-slot="question-tab"
|
type="button"
|
||||||
data-active={active()}
|
data-slot="question-progress-segment"
|
||||||
data-answered={answered()}
|
data-active={i() === store.tab}
|
||||||
|
data-answered={
|
||||||
|
(store.answers[i()]?.length ?? 0) > 0 ||
|
||||||
|
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
|
||||||
|
}
|
||||||
disabled={store.sending}
|
disabled={store.sending}
|
||||||
onClick={() => selectTab(index())}
|
onClick={() => jump(i())}
|
||||||
>
|
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
|
||||||
{q.header}
|
/>
|
||||||
</button>
|
)}
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
<button
|
|
||||||
data-slot="question-tab"
|
|
||||||
data-active={confirm()}
|
|
||||||
disabled={store.sending}
|
|
||||||
onClick={() => selectTab(questions().length)}
|
|
||||||
>
|
|
||||||
{language.t("ui.common.confirm")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={!confirm()}>
|
|
||||||
<div data-slot="question-content">
|
|
||||||
<div data-slot="question-text">
|
|
||||||
{question()?.question}
|
|
||||||
{multi() ? " " + language.t("ui.question.multiHint") : ""}
|
|
||||||
</div>
|
|
||||||
<div data-slot="question-options">
|
|
||||||
<For each={options()}>
|
|
||||||
{(opt, i) => {
|
|
||||||
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
data-slot="question-option"
|
|
||||||
data-picked={picked()}
|
|
||||||
disabled={store.sending}
|
|
||||||
onClick={() => selectOption(i())}
|
|
||||||
>
|
|
||||||
<span data-slot="option-label">{opt.label}</span>
|
|
||||||
<Show when={opt.description}>
|
|
||||||
<span data-slot="option-description">{opt.description}</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={picked()}>
|
|
||||||
<Icon name="check-small" size="normal" />
|
|
||||||
</Show>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
</For>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" size="large" disabled={store.sending} onClick={reject}>
|
||||||
|
{language.t("ui.common.dismiss")}
|
||||||
|
</Button>
|
||||||
|
<div data-slot="question-footer-actions">
|
||||||
|
<Show when={store.tab > 0}>
|
||||||
|
<Button variant="secondary" size="large" disabled={store.sending} onClick={back}>
|
||||||
|
{language.t("ui.common.back")}
|
||||||
|
</Button>
|
||||||
|
</Show>
|
||||||
|
<Button variant={last() ? "primary" : "secondary"} size="large" disabled={store.sending} onClick={next}>
|
||||||
|
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div data-slot="question-text">{question()?.question}</div>
|
||||||
|
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
|
||||||
|
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
|
||||||
|
</Show>
|
||||||
|
<div data-slot="question-options">
|
||||||
|
<For each={options()}>
|
||||||
|
{(opt, i) => {
|
||||||
|
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
data-slot="question-option"
|
||||||
|
data-picked={picked()}
|
||||||
|
role={multi() ? "checkbox" : "radio"}
|
||||||
|
aria-checked={picked()}
|
||||||
|
disabled={store.sending}
|
||||||
|
onClick={() => selectOption(i())}
|
||||||
|
>
|
||||||
|
<span data-slot="question-option-check" aria-hidden="true">
|
||||||
|
<span
|
||||||
|
data-slot="question-option-box"
|
||||||
|
data-type={multi() ? "checkbox" : "radio"}
|
||||||
|
data-picked={picked()}
|
||||||
|
>
|
||||||
|
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||||
|
<Icon name="check-small" size="small" />
|
||||||
|
</Show>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span data-slot="question-option-main">
|
||||||
|
<span data-slot="option-label">{opt.label}</span>
|
||||||
|
<Show when={opt.description}>
|
||||||
|
<span data-slot="option-description">{opt.description}</span>
|
||||||
|
</Show>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
|
||||||
|
<Show
|
||||||
|
when={store.editing}
|
||||||
|
fallback={
|
||||||
<button
|
<button
|
||||||
data-slot="question-option"
|
data-slot="question-option"
|
||||||
data-picked={customPicked()}
|
data-custom="true"
|
||||||
|
data-picked={on()}
|
||||||
|
role={multi() ? "checkbox" : "radio"}
|
||||||
|
aria-checked={on()}
|
||||||
disabled={store.sending}
|
disabled={store.sending}
|
||||||
onClick={() => selectOption(options().length)}
|
onClick={customOpen}
|
||||||
>
|
>
|
||||||
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
<span
|
||||||
<Show when={!store.editing && input()}>
|
data-slot="question-option-check"
|
||||||
<span data-slot="option-description">{input()}</span>
|
aria-hidden="true"
|
||||||
</Show>
|
onClick={(e) => {
|
||||||
<Show when={customPicked()}>
|
e.preventDefault()
|
||||||
<Icon name="check-small" size="normal" />
|
e.stopPropagation()
|
||||||
</Show>
|
customToggle()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
|
||||||
|
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||||
|
<Icon name="check-small" size="small" />
|
||||||
|
</Show>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span data-slot="question-option-main">
|
||||||
|
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
||||||
|
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<Show when={store.editing}>
|
}
|
||||||
<form data-slot="custom-input-form" onSubmit={handleCustomSubmit}>
|
>
|
||||||
<input
|
<form
|
||||||
ref={(el) => setTimeout(() => el.focus(), 0)}
|
data-slot="question-option"
|
||||||
type="text"
|
data-custom="true"
|
||||||
data-slot="custom-input"
|
data-picked={on()}
|
||||||
placeholder={language.t("ui.question.custom.placeholder")}
|
role={multi() ? "checkbox" : "radio"}
|
||||||
value={input()}
|
aria-checked={on()}
|
||||||
disabled={store.sending}
|
onMouseDown={(e) => {
|
||||||
onInput={(e) => {
|
if (store.sending) {
|
||||||
const inputs = [...store.custom]
|
e.preventDefault()
|
||||||
inputs[store.tab] = e.currentTarget.value
|
return
|
||||||
setStore("custom", inputs)
|
}
|
||||||
}}
|
if (e.target instanceof HTMLTextAreaElement) return
|
||||||
/>
|
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
|
||||||
<Button type="submit" variant="primary" size="small" disabled={store.sending}>
|
if (input instanceof HTMLTextAreaElement) input.focus()
|
||||||
{multi() ? language.t("ui.common.add") : language.t("ui.common.submit")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="small"
|
|
||||||
disabled={store.sending}
|
|
||||||
onClick={() => setStore("editing", false)}
|
|
||||||
>
|
|
||||||
{language.t("ui.common.cancel")}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={confirm()}>
|
|
||||||
<div data-slot="question-review">
|
|
||||||
<div data-slot="review-title">{language.t("ui.messagePart.review.title")}</div>
|
|
||||||
<For each={questions()}>
|
|
||||||
{(q, index) => {
|
|
||||||
const value = () => store.answers[index()]?.join(", ") ?? ""
|
|
||||||
const answered = () => Boolean(value())
|
|
||||||
return (
|
|
||||||
<div data-slot="review-item">
|
|
||||||
<span data-slot="review-label">{q.question}</span>
|
|
||||||
<span data-slot="review-value" data-answered={answered()}>
|
|
||||||
{answered() ? value() : language.t("ui.question.review.notAnswered")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
}}
|
||||||
</For>
|
onSubmit={(e) => {
|
||||||
</div>
|
e.preventDefault()
|
||||||
</Show>
|
commitCustom()
|
||||||
|
}}
|
||||||
<div data-slot="question-actions">
|
>
|
||||||
<Button variant="ghost" size="small" onClick={reject} disabled={store.sending}>
|
<span
|
||||||
{language.t("ui.common.dismiss")}
|
data-slot="question-option-check"
|
||||||
</Button>
|
aria-hidden="true"
|
||||||
<Show when={!single()}>
|
onClick={(e) => {
|
||||||
<Show when={confirm()}>
|
e.preventDefault()
|
||||||
<Button variant="primary" size="small" onClick={submit} disabled={store.sending}>
|
e.stopPropagation()
|
||||||
{language.t("ui.common.submit")}
|
customToggle()
|
||||||
</Button>
|
}}
|
||||||
</Show>
|
|
||||||
<Show when={!confirm() && multi()}>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="small"
|
|
||||||
onClick={() => selectTab(store.tab + 1)}
|
|
||||||
disabled={store.sending || (store.answers[store.tab]?.length ?? 0) === 0}
|
|
||||||
>
|
>
|
||||||
{language.t("ui.common.next")}
|
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
|
||||||
</Button>
|
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||||
</Show>
|
<Icon name="check-small" size="small" />
|
||||||
|
</Show>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span data-slot="question-option-main">
|
||||||
|
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
||||||
|
<textarea
|
||||||
|
ref={(el) =>
|
||||||
|
setTimeout(() => {
|
||||||
|
el.focus()
|
||||||
|
el.style.height = "0px"
|
||||||
|
el.style.height = `${el.scrollHeight}px`
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
data-slot="question-custom-input"
|
||||||
|
placeholder={language.t("ui.question.custom.placeholder")}
|
||||||
|
value={input()}
|
||||||
|
rows={1}
|
||||||
|
disabled={store.sending}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault()
|
||||||
|
setStore("editing", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key !== "Enter" || e.shiftKey) return
|
||||||
|
e.preventDefault()
|
||||||
|
commitCustom()
|
||||||
|
}}
|
||||||
|
onInput={(e) => {
|
||||||
|
customUpdate(e.currentTarget.value)
|
||||||
|
e.currentTarget.style.height = "0px"
|
||||||
|
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</form>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DockPrompt>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { JSXElement, ParentProps, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
|
import { JSXElement, ParentProps, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||||
import { serverDisplayName } from "@/context/server"
|
import { serverDisplayName } from "@/context/server"
|
||||||
import type { ServerHealth } from "@/utils/server-health"
|
import type { ServerHealth } from "@/utils/server-health"
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ export function ServerRow(props: ServerRowProps) {
|
|||||||
const [truncated, setTruncated] = createSignal(false)
|
const [truncated, setTruncated] = createSignal(false)
|
||||||
let nameRef: HTMLSpanElement | undefined
|
let nameRef: HTMLSpanElement | undefined
|
||||||
let versionRef: HTMLSpanElement | undefined
|
let versionRef: HTMLSpanElement | undefined
|
||||||
|
const name = createMemo(() => serverDisplayName(props.url))
|
||||||
|
|
||||||
const check = () => {
|
const check = () => {
|
||||||
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
|
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
|
||||||
@@ -25,25 +26,24 @@ export function ServerRow(props: ServerRowProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
name()
|
||||||
props.url
|
props.url
|
||||||
props.status?.version
|
props.status?.version
|
||||||
if (typeof requestAnimationFrame === "function") {
|
queueMicrotask(check)
|
||||||
requestAnimationFrame(check)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
check()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
check()
|
check()
|
||||||
if (typeof window === "undefined") return
|
if (typeof ResizeObserver !== "function") return
|
||||||
window.addEventListener("resize", check)
|
const observer = new ResizeObserver(check)
|
||||||
onCleanup(() => window.removeEventListener("resize", check))
|
if (nameRef) observer.observe(nameRef)
|
||||||
|
if (versionRef) observer.observe(versionRef)
|
||||||
|
onCleanup(() => observer.disconnect())
|
||||||
})
|
})
|
||||||
|
|
||||||
const tooltipValue = () => (
|
const tooltipValue = () => (
|
||||||
<span class="flex items-center gap-2">
|
<span class="flex items-center gap-2">
|
||||||
<span>{serverDisplayName(props.url)}</span>
|
<span>{name()}</span>
|
||||||
<Show when={props.status?.version}>
|
<Show when={props.status?.version}>
|
||||||
<span class="text-text-invert-base">{props.status?.version}</span>
|
<span class="text-text-invert-base">{props.status?.version}</span>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -62,7 +62,7 @@ export function ServerRow(props: ServerRowProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span ref={nameRef} class={props.nameClass ?? "truncate"}>
|
<span ref={nameRef} class={props.nameClass ?? "truncate"}>
|
||||||
{serverDisplayName(props.url)}
|
{name()}
|
||||||
</span>
|
</span>
|
||||||
<Show when={props.status?.version}>
|
<Show when={props.status?.version}>
|
||||||
<span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}>
|
<span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Match, Show, Switch, createMemo } from "solid-js"
|
import { Match, Show, Switch, createMemo } from "solid-js"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip"
|
||||||
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
|
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
@@ -11,6 +11,18 @@ import { getSessionContextMetrics } from "@/components/session/session-context-m
|
|||||||
|
|
||||||
interface SessionContextUsageProps {
|
interface SessionContextUsageProps {
|
||||||
variant?: "button" | "indicator"
|
variant?: "button" | "indicator"
|
||||||
|
placement?: TooltipProps["placement"]
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSessionContext(args: {
|
||||||
|
view: ReturnType<ReturnType<typeof useLayout>["view"]>
|
||||||
|
layout: ReturnType<typeof useLayout>
|
||||||
|
tabs: ReturnType<ReturnType<typeof useLayout>["tabs"]>
|
||||||
|
}) {
|
||||||
|
if (!args.view.reviewPanel.opened()) args.view.reviewPanel.open()
|
||||||
|
if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") args.layout.fileTree.setTab("all")
|
||||||
|
args.tabs.open("context")
|
||||||
|
args.tabs.setActive("context")
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SessionContextUsage(props: SessionContextUsageProps) {
|
export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||||
@@ -41,11 +53,16 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||||||
|
|
||||||
const openContext = () => {
|
const openContext = () => {
|
||||||
if (!params.id) return
|
if (!params.id) return
|
||||||
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
|
||||||
layout.fileTree.open()
|
if (tabs().active() === "context") {
|
||||||
layout.fileTree.setTab("all")
|
tabs().close("context")
|
||||||
tabs().open("context")
|
return
|
||||||
tabs().setActive("context")
|
}
|
||||||
|
openSessionContext({
|
||||||
|
view: view(),
|
||||||
|
layout,
|
||||||
|
tabs: tabs(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const circle = () => (
|
const circle = () => (
|
||||||
@@ -79,7 +96,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={params.id}>
|
<Show when={params.id}>
|
||||||
<Tooltip value={tooltipValue()} placement="top">
|
<Tooltip value={tooltipValue()} placement={props.placement ?? "top"}>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={variant() === "indicator"}>{circle()}</Match>
|
<Match when={variant() === "indicator"}>{circle()}</Match>
|
||||||
<Match when={true}>
|
<Match when={true}>
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import type { Todo } from "@opencode-ai/sdk/v2"
|
||||||
|
import { Checkbox } from "@opencode-ai/ui/checkbox"
|
||||||
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
|
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
|
||||||
|
function dot(status: Todo["status"]) {
|
||||||
|
if (status !== "in_progress") return undefined
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 12 12"
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
fill="currentColor"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="block"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="6"
|
||||||
|
cy="6"
|
||||||
|
r="3"
|
||||||
|
style={{
|
||||||
|
animation: "var(--animate-pulse-scale)",
|
||||||
|
"transform-origin": "center",
|
||||||
|
"transform-box": "fill-box",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseLabel: string; expandLabel: string }) {
|
||||||
|
const [store, setStore] = createStore({
|
||||||
|
collapsed: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggle = () => setStore("collapsed", (value) => !value)
|
||||||
|
|
||||||
|
const summary = createMemo(() => {
|
||||||
|
const total = props.todos.length
|
||||||
|
if (total === 0) return ""
|
||||||
|
const completed = props.todos.filter((todo) => todo.status === "completed").length
|
||||||
|
return `${completed} of ${total} ${props.title.toLowerCase()} completed`
|
||||||
|
})
|
||||||
|
|
||||||
|
const active = createMemo(
|
||||||
|
() =>
|
||||||
|
props.todos.find((todo) => todo.status === "in_progress") ??
|
||||||
|
props.todos.find((todo) => todo.status === "pending") ??
|
||||||
|
props.todos.filter((todo) => todo.status === "completed").at(-1) ??
|
||||||
|
props.todos[0],
|
||||||
|
)
|
||||||
|
|
||||||
|
const preview = createMemo(() => active()?.content ?? "")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"bg-background-base border border-border-weak-base relative z-0 rounded-[12px] overflow-clip": true,
|
||||||
|
"h-[78px]": store.collapsed,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="pl-3 pr-2 py-2 flex items-center gap-2"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={toggle}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== "Enter" && event.key !== " ") return
|
||||||
|
event.preventDefault()
|
||||||
|
toggle()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span class="text-14-regular text-text-strong cursor-default">{summary()}</span>
|
||||||
|
<Show when={store.collapsed}>
|
||||||
|
<div class="ml-1 flex-1 min-w-0">
|
||||||
|
<Show when={preview()}>
|
||||||
|
<div class="text-14-regular text-text-base truncate cursor-default">{preview()}</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<div classList={{ "ml-auto": !store.collapsed, "ml-1": store.collapsed }}>
|
||||||
|
<IconButton
|
||||||
|
icon="chevron-down"
|
||||||
|
size="normal"
|
||||||
|
variant="ghost"
|
||||||
|
classList={{ "rotate-180": !store.collapsed }}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
}}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
toggle()
|
||||||
|
}}
|
||||||
|
aria-label={store.collapsed ? props.expandLabel : props.collapseLabel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div hidden={store.collapsed}>
|
||||||
|
<TodoList todos={props.todos} open={!store.collapsed} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TodoList(props: { todos: Todo[]; open: boolean }) {
|
||||||
|
const [stuck, setStuck] = createSignal(false)
|
||||||
|
const [scrolling, setScrolling] = createSignal(false)
|
||||||
|
let scrollRef!: HTMLDivElement
|
||||||
|
let timer: number | undefined
|
||||||
|
|
||||||
|
const inProgress = createMemo(() => props.todos.findIndex((todo) => todo.status === "in_progress"))
|
||||||
|
|
||||||
|
const ensure = () => {
|
||||||
|
if (!props.open) return
|
||||||
|
if (scrolling()) return
|
||||||
|
if (!scrollRef || scrollRef.offsetParent === null) return
|
||||||
|
|
||||||
|
const el = scrollRef.querySelector("[data-in-progress]")
|
||||||
|
if (!(el instanceof HTMLElement)) return
|
||||||
|
|
||||||
|
const topFade = 16
|
||||||
|
const bottomFade = 44
|
||||||
|
const container = scrollRef.getBoundingClientRect()
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
const top = rect.top - container.top + scrollRef.scrollTop
|
||||||
|
const bottom = rect.bottom - container.top + scrollRef.scrollTop
|
||||||
|
const viewTop = scrollRef.scrollTop + topFade
|
||||||
|
const viewBottom = scrollRef.scrollTop + scrollRef.clientHeight - bottomFade
|
||||||
|
|
||||||
|
if (top < viewTop) {
|
||||||
|
scrollRef.scrollTop = Math.max(0, top - topFade)
|
||||||
|
} else if (bottom > viewBottom) {
|
||||||
|
scrollRef.scrollTop = bottom - (scrollRef.clientHeight - bottomFade)
|
||||||
|
}
|
||||||
|
|
||||||
|
setStuck(scrollRef.scrollTop > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on([() => props.open, inProgress], () => {
|
||||||
|
if (!props.open || inProgress() < 0) return
|
||||||
|
requestAnimationFrame(ensure)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (!timer) return
|
||||||
|
window.clearTimeout(timer)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="relative">
|
||||||
|
<div
|
||||||
|
class="px-3 pb-11 flex flex-col gap-1.5 max-h-42 overflow-y-auto no-scrollbar"
|
||||||
|
ref={scrollRef}
|
||||||
|
style={{ "overflow-anchor": "none" }}
|
||||||
|
onScroll={(e) => {
|
||||||
|
setStuck(e.currentTarget.scrollTop > 0)
|
||||||
|
setScrolling(true)
|
||||||
|
if (timer) window.clearTimeout(timer)
|
||||||
|
timer = window.setTimeout(() => {
|
||||||
|
setScrolling(false)
|
||||||
|
if (inProgress() < 0) return
|
||||||
|
requestAnimationFrame(ensure)
|
||||||
|
}, 250)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<For each={props.todos}>
|
||||||
|
{(todo) => (
|
||||||
|
<Checkbox
|
||||||
|
readOnly
|
||||||
|
checked={todo.status === "completed"}
|
||||||
|
indeterminate={todo.status === "in_progress"}
|
||||||
|
data-in-progress={todo.status === "in_progress" ? "" : undefined}
|
||||||
|
icon={dot(todo.status)}
|
||||||
|
style={{ "--checkbox-align": "flex-start", "--checkbox-offset": "1px" }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-14-regular min-w-0 break-words"
|
||||||
|
classList={{
|
||||||
|
"text-text-weak": todo.status === "completed" || todo.status === "cancelled",
|
||||||
|
"text-text-strong": todo.status !== "completed" && todo.status !== "cancelled",
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
"line-height": "var(--line-height-normal)",
|
||||||
|
"text-decoration":
|
||||||
|
todo.status === "completed" || todo.status === "cancelled" ? "line-through" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{todo.content}
|
||||||
|
</span>
|
||||||
|
</Checkbox>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute top-0 left-0 right-0 h-4 transition-opacity duration-150"
|
||||||
|
style={{
|
||||||
|
background: "linear-gradient(to bottom, var(--background-base), transparent)",
|
||||||
|
opacity: stuck() ? 1 : 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { estimateSessionContextBreakdown } from "./session-context-breakdown"
|
||||||
|
|
||||||
|
const user = (id: string) => {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
role: "user",
|
||||||
|
time: { created: 1 },
|
||||||
|
} as unknown as Message
|
||||||
|
}
|
||||||
|
|
||||||
|
const assistant = (id: string) => {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
role: "assistant",
|
||||||
|
time: { created: 1 },
|
||||||
|
} as unknown as Message
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("estimateSessionContextBreakdown", () => {
|
||||||
|
test("estimates tokens and keeps remaining tokens as other", () => {
|
||||||
|
const messages = [user("u1"), assistant("a1")]
|
||||||
|
const parts = {
|
||||||
|
u1: [{ type: "text", text: "hello world" }] as unknown as Part[],
|
||||||
|
a1: [{ type: "text", text: "assistant response" }] as unknown as Part[],
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = estimateSessionContextBreakdown({
|
||||||
|
messages,
|
||||||
|
parts,
|
||||||
|
input: 20,
|
||||||
|
systemPrompt: "system prompt",
|
||||||
|
})
|
||||||
|
|
||||||
|
const map = Object.fromEntries(output.map((segment) => [segment.key, segment.tokens]))
|
||||||
|
expect(map.system).toBe(4)
|
||||||
|
expect(map.user).toBe(3)
|
||||||
|
expect(map.assistant).toBe(5)
|
||||||
|
expect(map.other).toBe(8)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("scales segments when estimates exceed input", () => {
|
||||||
|
const messages = [user("u1"), assistant("a1")]
|
||||||
|
const parts = {
|
||||||
|
u1: [{ type: "text", text: "x".repeat(400) }] as unknown as Part[],
|
||||||
|
a1: [{ type: "text", text: "y".repeat(400) }] as unknown as Part[],
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = estimateSessionContextBreakdown({
|
||||||
|
messages,
|
||||||
|
parts,
|
||||||
|
input: 10,
|
||||||
|
systemPrompt: "z".repeat(200),
|
||||||
|
})
|
||||||
|
|
||||||
|
const total = output.reduce((sum, segment) => sum + segment.tokens, 0)
|
||||||
|
expect(total).toBeLessThanOrEqual(10)
|
||||||
|
expect(output.every((segment) => segment.width <= 100)).toBeTrue()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
|
export type SessionContextBreakdownKey = "system" | "user" | "assistant" | "tool" | "other"
|
||||||
|
|
||||||
|
export type SessionContextBreakdownSegment = {
|
||||||
|
key: SessionContextBreakdownKey
|
||||||
|
tokens: number
|
||||||
|
width: number
|
||||||
|
percent: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const estimateTokens = (chars: number) => Math.ceil(chars / 4)
|
||||||
|
const toPercent = (tokens: number, input: number) => (tokens / input) * 100
|
||||||
|
const toPercentLabel = (tokens: number, input: number) => Math.round(toPercent(tokens, input) * 10) / 10
|
||||||
|
|
||||||
|
const charsFromUserPart = (part: Part) => {
|
||||||
|
if (part.type === "text") return part.text.length
|
||||||
|
if (part.type === "file") return part.source?.text.value.length ?? 0
|
||||||
|
if (part.type === "agent") return part.source?.value.length ?? 0
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const charsFromAssistantPart = (part: Part) => {
|
||||||
|
if (part.type === "text") return { assistant: part.text.length, tool: 0 }
|
||||||
|
if (part.type === "reasoning") return { assistant: part.text.length, tool: 0 }
|
||||||
|
if (part.type !== "tool") return { assistant: 0, tool: 0 }
|
||||||
|
|
||||||
|
const input = Object.keys(part.state.input).length * 16
|
||||||
|
if (part.state.status === "pending") return { assistant: 0, tool: input + part.state.raw.length }
|
||||||
|
if (part.state.status === "completed") return { assistant: 0, tool: input + part.state.output.length }
|
||||||
|
if (part.state.status === "error") return { assistant: 0, tool: input + part.state.error.length }
|
||||||
|
return { assistant: 0, tool: input }
|
||||||
|
}
|
||||||
|
|
||||||
|
const build = (
|
||||||
|
tokens: { system: number; user: number; assistant: number; tool: number; other: number },
|
||||||
|
input: number,
|
||||||
|
) => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "system",
|
||||||
|
tokens: tokens.system,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "user",
|
||||||
|
tokens: tokens.user,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "assistant",
|
||||||
|
tokens: tokens.assistant,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "tool",
|
||||||
|
tokens: tokens.tool,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "other",
|
||||||
|
tokens: tokens.other,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
.filter((x) => x.tokens > 0)
|
||||||
|
.map((x) => ({
|
||||||
|
key: x.key,
|
||||||
|
tokens: x.tokens,
|
||||||
|
width: toPercent(x.tokens, input),
|
||||||
|
percent: toPercentLabel(x.tokens, input),
|
||||||
|
})) as SessionContextBreakdownSegment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function estimateSessionContextBreakdown(args: {
|
||||||
|
messages: Message[]
|
||||||
|
parts: Record<string, Part[] | undefined>
|
||||||
|
input: number
|
||||||
|
systemPrompt?: string
|
||||||
|
}) {
|
||||||
|
if (!args.input) return []
|
||||||
|
|
||||||
|
const counts = args.messages.reduce(
|
||||||
|
(acc, msg) => {
|
||||||
|
const parts = args.parts[msg.id] ?? []
|
||||||
|
if (msg.role === "user") {
|
||||||
|
const user = parts.reduce((sum, part) => sum + charsFromUserPart(part), 0)
|
||||||
|
return { ...acc, user: acc.user + user }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.role !== "assistant") return acc
|
||||||
|
const assistant = parts.reduce(
|
||||||
|
(sum, part) => {
|
||||||
|
const next = charsFromAssistantPart(part)
|
||||||
|
return {
|
||||||
|
assistant: sum.assistant + next.assistant,
|
||||||
|
tool: sum.tool + next.tool,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ assistant: 0, tool: 0 },
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
...acc,
|
||||||
|
assistant: acc.assistant + assistant.assistant,
|
||||||
|
tool: acc.tool + assistant.tool,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
system: args.systemPrompt?.length ?? 0,
|
||||||
|
user: 0,
|
||||||
|
assistant: 0,
|
||||||
|
tool: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const tokens = {
|
||||||
|
system: estimateTokens(counts.system),
|
||||||
|
user: estimateTokens(counts.user),
|
||||||
|
assistant: estimateTokens(counts.assistant),
|
||||||
|
tool: estimateTokens(counts.tool),
|
||||||
|
}
|
||||||
|
const estimated = tokens.system + tokens.user + tokens.assistant + tokens.tool
|
||||||
|
|
||||||
|
if (estimated <= args.input) {
|
||||||
|
return build({ ...tokens, other: args.input - estimated }, args.input)
|
||||||
|
}
|
||||||
|
|
||||||
|
const scale = args.input / estimated
|
||||||
|
const scaled = {
|
||||||
|
system: Math.floor(tokens.system * scale),
|
||||||
|
user: Math.floor(tokens.user * scale),
|
||||||
|
assistant: Math.floor(tokens.assistant * scale),
|
||||||
|
tool: Math.floor(tokens.tool * scale),
|
||||||
|
}
|
||||||
|
const total = scaled.system + scaled.user + scaled.assistant + scaled.tool
|
||||||
|
return build({ ...scaled, other: Math.max(0, args.input - total) }, args.input)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { DateTime } from "luxon"
|
||||||
|
|
||||||
|
export function createSessionContextFormatter(locale: string) {
|
||||||
|
return {
|
||||||
|
number(value: number | null | undefined) {
|
||||||
|
if (value === undefined) return "—"
|
||||||
|
if (value === null) return "—"
|
||||||
|
return value.toLocaleString(locale)
|
||||||
|
},
|
||||||
|
percent(value: number | null | undefined) {
|
||||||
|
if (value === undefined) return "—"
|
||||||
|
if (value === null) return "—"
|
||||||
|
return value.toLocaleString(locale) + "%"
|
||||||
|
},
|
||||||
|
time(value: number | undefined) {
|
||||||
|
if (!value) return "—"
|
||||||
|
return DateTime.fromMillis(value).setLocale(locale).toLocaleString(DateTime.DATETIME_MED)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -91,4 +91,11 @@ describe("getSessionContextMetrics", () => {
|
|||||||
expect(two.context?.message.id).toBe("a2")
|
expect(two.context?.message.id).toBe("a2")
|
||||||
expect(two.totalCost).toBe(1)
|
expect(two.totalCost).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("returns empty metrics when inputs are undefined", () => {
|
||||||
|
const metrics = getSessionContextMetrics(undefined, undefined)
|
||||||
|
|
||||||
|
expect(metrics.totalCost).toBe(0)
|
||||||
|
expect(metrics.context).toBeUndefined()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ const lastAssistantWithTokens = (messages: Message[]) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const build = (messages: Message[], providers: Provider[]): Metrics => {
|
const build = (messages: Message[] = [], providers: Provider[] = []): Metrics => {
|
||||||
const totalCost = messages.reduce((sum, msg) => sum + (msg.role === "assistant" ? msg.cost : 0), 0)
|
const totalCost = messages.reduce((sum, msg) => sum + (msg.role === "assistant" ? msg.cost : 0), 0)
|
||||||
const message = lastAssistantWithTokens(messages)
|
const message = lastAssistantWithTokens(messages)
|
||||||
if (!message) return { totalCost, context: undefined }
|
if (!message) return { totalCost, context: undefined }
|
||||||
@@ -77,6 +77,6 @@ const build = (messages: Message[], providers: Provider[]): Metrics => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSessionContextMetrics(messages: Message[], providers: Provider[]) {
|
export function getSessionContextMetrics(messages: Message[] = [], providers: Provider[] = []) {
|
||||||
return build(messages, providers)
|
return build(messages, providers)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
|
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
|
||||||
import type { JSX } from "solid-js"
|
import type { JSX } from "solid-js"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { DateTime } from "luxon"
|
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { checksum } from "@opencode-ai/util/encode"
|
import { checksum } from "@opencode-ai/util/encode"
|
||||||
@@ -14,6 +13,8 @@ import { Markdown } from "@opencode-ai/ui/markdown"
|
|||||||
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { getSessionContextMetrics } from "./session-context-metrics"
|
import { getSessionContextMetrics } from "./session-context-metrics"
|
||||||
|
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
|
||||||
|
import { createSessionContextFormatter } from "./session-context-format"
|
||||||
|
|
||||||
interface SessionContextTabProps {
|
interface SessionContextTabProps {
|
||||||
messages: () => Message[]
|
messages: () => Message[]
|
||||||
@@ -22,6 +23,74 @@ interface SessionContextTabProps {
|
|||||||
info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]>
|
info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BREAKDOWN_COLOR: Record<SessionContextBreakdownKey, string> = {
|
||||||
|
system: "var(--syntax-info)",
|
||||||
|
user: "var(--syntax-success)",
|
||||||
|
assistant: "var(--syntax-property)",
|
||||||
|
tool: "var(--syntax-warning)",
|
||||||
|
other: "var(--syntax-comment)",
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat(props: { label: string; value: JSX.Element }) {
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="text-12-regular text-text-weak">{props.label}</div>
|
||||||
|
<div class="text-12-medium text-text-strong">{props.value}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RawMessageContent(props: { message: Message; getParts: (id: string) => Part[]; onRendered: () => void }) {
|
||||||
|
const file = createMemo(() => {
|
||||||
|
const parts = props.getParts(props.message.id)
|
||||||
|
const contents = JSON.stringify({ message: props.message, parts }, null, 2)
|
||||||
|
return {
|
||||||
|
name: `${props.message.role}-${props.message.id}.json`,
|
||||||
|
contents,
|
||||||
|
cacheKey: checksum(contents),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Code
|
||||||
|
file={file()}
|
||||||
|
overflow="wrap"
|
||||||
|
class="select-text"
|
||||||
|
onRendered={() => requestAnimationFrame(props.onRendered)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RawMessage(props: {
|
||||||
|
message: Message
|
||||||
|
getParts: (id: string) => Part[]
|
||||||
|
onRendered: () => void
|
||||||
|
time: (value: number | undefined) => string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Accordion.Item value={props.message.id}>
|
||||||
|
<StickyAccordionHeader>
|
||||||
|
<Accordion.Trigger>
|
||||||
|
<div class="flex items-center justify-between gap-2 w-full">
|
||||||
|
<div class="min-w-0 truncate">
|
||||||
|
{props.message.role} <span class="text-text-base">• {props.message.id}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="shrink-0 text-12-regular text-text-weak">{props.time(props.message.time.created)}</div>
|
||||||
|
<Icon name="chevron-grabber-vertical" size="small" class="shrink-0 text-text-weak" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Accordion.Trigger>
|
||||||
|
</StickyAccordionHeader>
|
||||||
|
<Accordion.Content class="bg-background-base">
|
||||||
|
<div class="p-3">
|
||||||
|
<RawMessageContent message={props.message} getParts={props.getParts} onRendered={props.onRendered} />
|
||||||
|
</div>
|
||||||
|
</Accordion.Content>
|
||||||
|
</Accordion.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function SessionContextTab(props: SessionContextTabProps) {
|
export function SessionContextTab(props: SessionContextTabProps) {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
@@ -37,6 +106,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
|||||||
|
|
||||||
const metrics = createMemo(() => getSessionContextMetrics(props.messages(), sync.data.provider.all))
|
const metrics = createMemo(() => getSessionContextMetrics(props.messages(), sync.data.provider.all))
|
||||||
const ctx = createMemo(() => metrics().context)
|
const ctx = createMemo(() => metrics().context)
|
||||||
|
const formatter = createMemo(() => createSessionContextFormatter(language.locale()))
|
||||||
|
|
||||||
const cost = createMemo(() => {
|
const cost = createMemo(() => {
|
||||||
return usd().format(metrics().totalCost)
|
return usd().format(metrics().totalCost)
|
||||||
@@ -62,23 +132,6 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
|||||||
return trimmed
|
return trimmed
|
||||||
})
|
})
|
||||||
|
|
||||||
const number = (value: number | null | undefined) => {
|
|
||||||
if (value === undefined) return "—"
|
|
||||||
if (value === null) return "—"
|
|
||||||
return value.toLocaleString(language.locale())
|
|
||||||
}
|
|
||||||
|
|
||||||
const percent = (value: number | null | undefined) => {
|
|
||||||
if (value === undefined) return "—"
|
|
||||||
if (value === null) return "—"
|
|
||||||
return value.toLocaleString(language.locale()) + "%"
|
|
||||||
}
|
|
||||||
|
|
||||||
const time = (value: number | undefined) => {
|
|
||||||
if (!value) return "—"
|
|
||||||
return DateTime.fromMillis(value).setLocale(language.locale()).toLocaleString(DateTime.DATETIME_MED)
|
|
||||||
}
|
|
||||||
|
|
||||||
const providerLabel = createMemo(() => {
|
const providerLabel = createMemo(() => {
|
||||||
const c = ctx()
|
const c = ctx()
|
||||||
if (!c) return "—"
|
if (!c) return "—"
|
||||||
@@ -96,197 +149,51 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
|||||||
() => [ctx()?.message.id, ctx()?.input, props.messages().length, systemPrompt()],
|
() => [ctx()?.message.id, ctx()?.input, props.messages().length, systemPrompt()],
|
||||||
() => {
|
() => {
|
||||||
const c = ctx()
|
const c = ctx()
|
||||||
if (!c) return []
|
if (!c?.input) return []
|
||||||
const input = c.input
|
return estimateSessionContextBreakdown({
|
||||||
if (!input) return []
|
messages: props.messages(),
|
||||||
|
parts: sync.data.part as Record<string, Part[] | undefined>,
|
||||||
const out = {
|
input: c.input,
|
||||||
system: systemPrompt()?.length ?? 0,
|
systemPrompt: systemPrompt(),
|
||||||
user: 0,
|
})
|
||||||
assistant: 0,
|
|
||||||
tool: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const msg of props.messages()) {
|
|
||||||
const parts = (sync.data.part[msg.id] ?? []) as Part[]
|
|
||||||
|
|
||||||
if (msg.role === "user") {
|
|
||||||
for (const part of parts) {
|
|
||||||
if (part.type === "text") out.user += part.text.length
|
|
||||||
if (part.type === "file") out.user += part.source?.text.value.length ?? 0
|
|
||||||
if (part.type === "agent") out.user += part.source?.value.length ?? 0
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.role === "assistant") {
|
|
||||||
for (const part of parts) {
|
|
||||||
if (part.type === "text") out.assistant += part.text.length
|
|
||||||
if (part.type === "reasoning") out.assistant += part.text.length
|
|
||||||
if (part.type === "tool") {
|
|
||||||
out.tool += Object.keys(part.state.input).length * 16
|
|
||||||
if (part.state.status === "pending") out.tool += part.state.raw.length
|
|
||||||
if (part.state.status === "completed") out.tool += part.state.output.length
|
|
||||||
if (part.state.status === "error") out.tool += part.state.error.length
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const estimateTokens = (chars: number) => Math.ceil(chars / 4)
|
|
||||||
const system = estimateTokens(out.system)
|
|
||||||
const user = estimateTokens(out.user)
|
|
||||||
const assistant = estimateTokens(out.assistant)
|
|
||||||
const tool = estimateTokens(out.tool)
|
|
||||||
const estimated = system + user + assistant + tool
|
|
||||||
|
|
||||||
const pct = (tokens: number) => (tokens / input) * 100
|
|
||||||
const pctLabel = (tokens: number) => (Math.round(pct(tokens) * 10) / 10).toString() + "%"
|
|
||||||
|
|
||||||
const build = (tokens: { system: number; user: number; assistant: number; tool: number; other: number }) => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
key: "system",
|
|
||||||
label: language.t("context.breakdown.system"),
|
|
||||||
tokens: tokens.system,
|
|
||||||
width: pct(tokens.system),
|
|
||||||
percent: pctLabel(tokens.system),
|
|
||||||
color: "var(--syntax-info)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "user",
|
|
||||||
label: language.t("context.breakdown.user"),
|
|
||||||
tokens: tokens.user,
|
|
||||||
width: pct(tokens.user),
|
|
||||||
percent: pctLabel(tokens.user),
|
|
||||||
color: "var(--syntax-success)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "assistant",
|
|
||||||
label: language.t("context.breakdown.assistant"),
|
|
||||||
tokens: tokens.assistant,
|
|
||||||
width: pct(tokens.assistant),
|
|
||||||
percent: pctLabel(tokens.assistant),
|
|
||||||
color: "var(--syntax-property)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "tool",
|
|
||||||
label: language.t("context.breakdown.tool"),
|
|
||||||
tokens: tokens.tool,
|
|
||||||
width: pct(tokens.tool),
|
|
||||||
percent: pctLabel(tokens.tool),
|
|
||||||
color: "var(--syntax-warning)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "other",
|
|
||||||
label: language.t("context.breakdown.other"),
|
|
||||||
tokens: tokens.other,
|
|
||||||
width: pct(tokens.other),
|
|
||||||
percent: pctLabel(tokens.other),
|
|
||||||
color: "var(--syntax-comment)",
|
|
||||||
},
|
|
||||||
].filter((x) => x.tokens > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (estimated <= input) {
|
|
||||||
return build({ system, user, assistant, tool, other: input - estimated })
|
|
||||||
}
|
|
||||||
|
|
||||||
const scale = input / estimated
|
|
||||||
const scaled = {
|
|
||||||
system: Math.floor(system * scale),
|
|
||||||
user: Math.floor(user * scale),
|
|
||||||
assistant: Math.floor(assistant * scale),
|
|
||||||
tool: Math.floor(tool * scale),
|
|
||||||
}
|
|
||||||
const scaledTotal = scaled.system + scaled.user + scaled.assistant + scaled.tool
|
|
||||||
return build({ ...scaled, other: Math.max(0, input - scaledTotal) })
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
function Stat(statProps: { label: string; value: JSX.Element }) {
|
const breakdownLabel = (key: SessionContextBreakdownKey) => {
|
||||||
return (
|
if (key === "system") return language.t("context.breakdown.system")
|
||||||
<div class="flex flex-col gap-1">
|
if (key === "user") return language.t("context.breakdown.user")
|
||||||
<div class="text-12-regular text-text-weak">{statProps.label}</div>
|
if (key === "assistant") return language.t("context.breakdown.assistant")
|
||||||
<div class="text-12-medium text-text-strong">{statProps.value}</div>
|
if (key === "tool") return language.t("context.breakdown.tool")
|
||||||
</div>
|
return language.t("context.breakdown.other")
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const stats = createMemo(() => {
|
const stats = [
|
||||||
const c = ctx()
|
{ label: "context.stats.session", value: () => props.info()?.title ?? params.id ?? "—" },
|
||||||
const count = counts()
|
{ label: "context.stats.messages", value: () => counts().all.toLocaleString(language.locale()) },
|
||||||
return [
|
{ label: "context.stats.provider", value: providerLabel },
|
||||||
{ label: language.t("context.stats.session"), value: props.info()?.title ?? params.id ?? "—" },
|
{ label: "context.stats.model", value: modelLabel },
|
||||||
{ label: language.t("context.stats.messages"), value: count.all.toLocaleString(language.locale()) },
|
{ label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
|
||||||
{ label: language.t("context.stats.provider"), value: providerLabel() },
|
{ label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) },
|
||||||
{ label: language.t("context.stats.model"), value: modelLabel() },
|
{ label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) },
|
||||||
{ label: language.t("context.stats.limit"), value: number(c?.limit) },
|
{ label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) },
|
||||||
{ label: language.t("context.stats.totalTokens"), value: number(c?.total) },
|
{ label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.output) },
|
||||||
{ label: language.t("context.stats.usage"), value: percent(c?.usage) },
|
{ label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.reasoning) },
|
||||||
{ label: language.t("context.stats.inputTokens"), value: number(c?.input) },
|
{
|
||||||
{ label: language.t("context.stats.outputTokens"), value: number(c?.output) },
|
label: "context.stats.cacheTokens",
|
||||||
{ label: language.t("context.stats.reasoningTokens"), value: number(c?.reasoning) },
|
value: () => `${formatter().number(ctx()?.cacheRead)} / ${formatter().number(ctx()?.cacheWrite)}`,
|
||||||
{
|
},
|
||||||
label: language.t("context.stats.cacheTokens"),
|
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.locale()) },
|
||||||
value: `${number(c?.cacheRead)} / ${number(c?.cacheWrite)}`,
|
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.locale()) },
|
||||||
},
|
{ label: "context.stats.totalCost", value: cost },
|
||||||
{ label: language.t("context.stats.userMessages"), value: count.user.toLocaleString(language.locale()) },
|
{ label: "context.stats.sessionCreated", value: () => formatter().time(props.info()?.time.created) },
|
||||||
{
|
{ label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
|
||||||
label: language.t("context.stats.assistantMessages"),
|
] satisfies { label: string; value: () => JSX.Element }[]
|
||||||
value: count.assistant.toLocaleString(language.locale()),
|
|
||||||
},
|
|
||||||
{ label: language.t("context.stats.totalCost"), value: cost() },
|
|
||||||
{ label: language.t("context.stats.sessionCreated"), value: time(props.info()?.time.created) },
|
|
||||||
{ label: language.t("context.stats.lastActivity"), value: time(c?.message.time.created) },
|
|
||||||
] satisfies { label: string; value: JSX.Element }[]
|
|
||||||
})
|
|
||||||
|
|
||||||
function RawMessageContent(msgProps: { message: Message }) {
|
|
||||||
const file = createMemo(() => {
|
|
||||||
const parts = (sync.data.part[msgProps.message.id] ?? []) as Part[]
|
|
||||||
const contents = JSON.stringify({ message: msgProps.message, parts }, null, 2)
|
|
||||||
return {
|
|
||||||
name: `${msgProps.message.role}-${msgProps.message.id}.json`,
|
|
||||||
contents,
|
|
||||||
cacheKey: checksum(contents),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Code file={file()} overflow="wrap" class="select-text" onRendered={() => requestAnimationFrame(restoreScroll)} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function RawMessage(msgProps: { message: Message }) {
|
|
||||||
return (
|
|
||||||
<Accordion.Item value={msgProps.message.id}>
|
|
||||||
<StickyAccordionHeader>
|
|
||||||
<Accordion.Trigger>
|
|
||||||
<div class="flex items-center justify-between gap-2 w-full">
|
|
||||||
<div class="min-w-0 truncate">
|
|
||||||
{msgProps.message.role} <span class="text-text-base">• {msgProps.message.id}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="shrink-0 text-12-regular text-text-weak">{time(msgProps.message.time.created)}</div>
|
|
||||||
<Icon name="chevron-grabber-vertical" size="small" class="shrink-0 text-text-weak" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Accordion.Trigger>
|
|
||||||
</StickyAccordionHeader>
|
|
||||||
<Accordion.Content class="bg-background-base">
|
|
||||||
<div class="p-3">
|
|
||||||
<RawMessageContent message={msgProps.message} />
|
|
||||||
</div>
|
|
||||||
</Accordion.Content>
|
|
||||||
</Accordion.Item>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let scroll: HTMLDivElement | undefined
|
let scroll: HTMLDivElement | undefined
|
||||||
let frame: number | undefined
|
let frame: number | undefined
|
||||||
let pending: { x: number; y: number } | undefined
|
let pending: { x: number; y: number } | undefined
|
||||||
|
const getParts = (id: string) => (sync.data.part[id] ?? []) as Part[]
|
||||||
|
|
||||||
const restoreScroll = () => {
|
const restoreScroll = () => {
|
||||||
const el = scroll
|
const el = scroll
|
||||||
@@ -343,7 +250,9 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
|||||||
>
|
>
|
||||||
<div class="px-6 pt-4 flex flex-col gap-10">
|
<div class="px-6 pt-4 flex flex-col gap-10">
|
||||||
<div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
|
||||||
<For each={stats()}>{(stat) => <Stat label={stat.label} value={stat.value} />}</For>
|
<For each={stats}>
|
||||||
|
{(stat) => <Stat label={language.t(stat.label as Parameters<typeof language.t>[0])} value={stat.value()} />}
|
||||||
|
</For>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={breakdown().length > 0}>
|
<Show when={breakdown().length > 0}>
|
||||||
@@ -356,7 +265,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
|||||||
class="h-full"
|
class="h-full"
|
||||||
style={{
|
style={{
|
||||||
width: `${segment.width}%`,
|
width: `${segment.width}%`,
|
||||||
"background-color": segment.color,
|
"background-color": BREAKDOWN_COLOR[segment.key],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -366,9 +275,9 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
|||||||
<For each={breakdown()}>
|
<For each={breakdown()}>
|
||||||
{(segment) => (
|
{(segment) => (
|
||||||
<div class="flex items-center gap-1 text-11-regular text-text-weak">
|
<div class="flex items-center gap-1 text-11-regular text-text-weak">
|
||||||
<div class="size-2 rounded-sm" style={{ "background-color": segment.color }} />
|
<div class="size-2 rounded-sm" style={{ "background-color": BREAKDOWN_COLOR[segment.key] }} />
|
||||||
<div>{segment.label}</div>
|
<div>{breakdownLabel(segment.key)}</div>
|
||||||
<div class="text-text-weaker">{segment.percent}</div>
|
<div class="text-text-weaker">{segment.percent.toLocaleString(language.locale())}%</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
@@ -391,7 +300,11 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
|||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
|
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
|
||||||
<Accordion multiple>
|
<Accordion multiple>
|
||||||
<For each={props.messages()}>{(message) => <RawMessage message={message} />}</For>
|
<For each={props.messages()}>
|
||||||
|
{(message) => (
|
||||||
|
<RawMessage message={message} getParts={getParts} onRendered={restoreScroll} time={formatter().time} />
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user