Compare commits
293 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 253105bcf5 | |||
| bd0ba5ab88 | |||
| ea993976b0 | |||
| 4c11ccd334 | |||
| d766ca23e8 | |||
| fe4589d335 | |||
| 6036a1d611 | |||
| a8341e2b8b | |||
| 73115efab1 | |||
| a45fa7a93c | |||
| ae15c91455 | |||
| 52f16c496b | |||
| 24d9f45506 | |||
| 2404d70a33 | |||
| 26f1cc87ca | |||
| 860e47edea | |||
| e2378f2237 | |||
| 9e197a5b67 | |||
| 5f4041c58f | |||
| d56e81f02b | |||
| 6022d12ea2 | |||
| f7ef1c286f | |||
| b35c6b9fff | |||
| 30ec02e82d | |||
| bc9522d5d8 | |||
| b6e80e72f6 | |||
| 2ded2aa2d9 | |||
| 30dc0cbe58 | |||
| 2bd0c9c6d2 | |||
| f9229889a1 | |||
| eb4f55bdf6 | |||
| 9ee4e2e3d4 | |||
| decb6ff2d3 | |||
| b9de71dbfa | |||
| 124e355a3c | |||
| 095fe68786 | |||
| afc67caa48 | |||
| 189b7f1172 | |||
| cc955098cd | |||
| 8699e896e6 | |||
| ca4cb85dcd | |||
| 88474e0653 | |||
| 5667a7ed16 | |||
| 2ae3231ff9 | |||
| d92fc25e26 | |||
| c8c0373f1d | |||
| bccee29d2f | |||
| ad307f7f89 | |||
| eac11c0753 | |||
| 0e804c302c | |||
| fb88cb0aa3 | |||
| 8fc6a25142 | |||
| 5079ba7ce5 | |||
| 19cb211b62 | |||
| b2440e92e7 | |||
| 125624489b | |||
| 991f85c907 | |||
| c00fbbdcae | |||
| d4e9c60af7 | |||
| 0691815c0a | |||
| 985fd4d9a8 | |||
| 87fa8dc70c | |||
| a782e3dac2 | |||
| 70da3a9399 | |||
| 1024537b47 | |||
| 2a4f21a694 | |||
| 5f61945090 | |||
| 41ce56494b | |||
| 172aeaaf14 | |||
| bd69c5aca8 | |||
| 6a7eeb39c3 | |||
| 35a608cd53 | |||
| 6e19200fca | |||
| fe45a76c55 | |||
| bdac22cb07 | |||
| 5a507023a6 | |||
| c398485213 | |||
| bc9ff7e99f | |||
| 7447460b5a | |||
| 5345c828ca | |||
| edeaab321a | |||
| 478ead6a05 | |||
| 468201190e | |||
| cc0d460904 | |||
| f8ab0de0ad | |||
| 322363f11b | |||
| acd33c2fc5 | |||
| b6fba03a7d | |||
| fbced21b8e | |||
| e7cb5d8345 | |||
| 918739057d | |||
| e3a7096e44 | |||
| c148f10bbd | |||
| 06495ea964 | |||
| b64cecb079 | |||
| e10bb58cb3 | |||
| 89167ae387 | |||
| 4b429029df | |||
| c7e5d29109 | |||
| a564267b29 | |||
| 594bdb43c2 | |||
| ea66c02633 | |||
| 925ce6503e | |||
| 8a28d34fe9 | |||
| 8bea479df9 | |||
| 468d919a92 | |||
| 7e5527379d | |||
| fcbc78180b | |||
| bab1ca54e4 | |||
| d644e0b8a7 | |||
| e54ec45002 | |||
| 4b94d98f89 | |||
| d0043a4a78 | |||
| 26ebf85b0e | |||
| 53481f9790 | |||
| eadc2a8535 | |||
| 00a5ec5bd2 | |||
| 0b6b9062d9 | |||
| c450549d0f | |||
| 1ba0155943 | |||
| 3d332a06b5 | |||
| f709e0b48b | |||
| 57e1bffbd5 | |||
| f321661b4c | |||
| 7ecdc1b5d8 | |||
| 39917a35ce | |||
| bfe3f03e03 | |||
| d01af65dbc | |||
| 80305813f5 | |||
| 061877e275 | |||
| 05e6c3d8a0 | |||
| 093fbca711 | |||
| 81deea855f | |||
| 5c67bebf86 | |||
| 9cc1f2884f | |||
| f2b547cc45 | |||
| 70310a37b3 | |||
| eb7f4e20df | |||
| ea21bfd3c6 | |||
| 22d5be9bf8 | |||
| 1c878c662b | |||
| 55d154d4ac | |||
| f5c7a94abe | |||
| 7ec3900208 | |||
| 5d95846df1 | |||
| d47feb9969 | |||
| 8f135d13e3 | |||
| f9ab4102f6 | |||
| f9117bcc7f | |||
| 6e712f9faf | |||
| b207ed2b7b | |||
| 945de4eddc | |||
| cd655177d9 | |||
| 8f90497fc4 | |||
| 9659efca46 | |||
| d0377a95cf | |||
| 3b20bf6d4f | |||
| c3e52580b0 | |||
| 2badfcdcf4 | |||
| f589fc2327 | |||
| d3b6545e7c | |||
| 3f911b22b0 | |||
| 5199141369 | |||
| d86d3e7ea1 | |||
| fe8d29cb2b | |||
| edd6198999 | |||
| c3b2c27997 | |||
| 679aeb29f0 | |||
| 190413580f | |||
| 8c9fbc7717 | |||
| 9d3fdda674 | |||
| 449994f120 | |||
| d772fff776 | |||
| 71b43fd02e | |||
| f40b91ab7a | |||
| 6404bd006d | |||
| 75157e515c | |||
| 5a96ee8e1b | |||
| ee6ceb4c64 | |||
| 9d53628e19 | |||
| 869b476145 | |||
| 223d487787 | |||
| 5ead6d7dd5 | |||
| a98454217f | |||
| cbb75d8577 | |||
| 4ab992a9a9 | |||
| 80b0a93d64 | |||
| e749d48534 | |||
| d7e873f807 | |||
| c23510346b | |||
| f9c5df05a1 | |||
| 02b4d1e2fc | |||
| 0b36eb8760 | |||
| 36bec9948c | |||
| 2db73c39df | |||
| 6107666d04 | |||
| cc2bd7141f | |||
| ee442975df | |||
| 9b1a508657 | |||
| 288c977596 | |||
| 6b799b304c | |||
| 92c126d875 | |||
| 7123fbeb47 | |||
| 84bb692193 | |||
| 079095d7a9 | |||
| 28e1d67ea4 | |||
| c1940d1d2c | |||
| 869f629c14 | |||
| a55943e469 | |||
| 84d95a0d2a | |||
| 7dfed8ca35 | |||
| 38ea0fc051 | |||
| 9223b6ed8f | |||
| f8528c52d9 | |||
| d63ce40af2 | |||
| 5acdd70587 | |||
| b04df6c0d2 | |||
| f1cbdf441c | |||
| 9420d80b73 | |||
| c21161b75e | |||
| aaff066457 | |||
| c7fbf9de44 | |||
| d88c17dad0 | |||
| f57c3f7cf6 | |||
| 2460108223 | |||
| 84e8eea52e | |||
| 9efc2eaf2e | |||
| 37e2644452 | |||
| 22a78cf13f | |||
| 2e9806b320 | |||
| ba839d4446 | |||
| 2bec21d81d | |||
| e5271f3d1a | |||
| 1edb23c2c7 | |||
| b1e6b9c7c9 | |||
| 20cb5a7c56 | |||
| b1d44482bc | |||
| e11102c9df | |||
| 7be9dc8e49 | |||
| 824e035815 | |||
| d652b94a14 | |||
| ebef2ea2d0 | |||
| b5b8a0555d | |||
| ae6154e1c3 | |||
| 0e19ca21ed | |||
| baaff81a06 | |||
| ffa5689885 | |||
| 0e409842e8 | |||
| 5a7a725787 | |||
| f277512938 | |||
| 4ceabdffa0 | |||
| c87480cf93 | |||
| 0df6fc1226 | |||
| 32ba2e02aa | |||
| 1ffc8be2b6 | |||
| 5f2945ae71 | |||
| 65baf76df6 | |||
| 3b6c0ec0b3 | |||
| e9d902d844 | |||
| e8b4f593a6 | |||
| fc4f281408 | |||
| f8c4f713a5 | |||
| 63c8874d2d | |||
| 71076d5c68 | |||
| 0319043b49 | |||
| e0334d5569 | |||
| ff6a93f355 | |||
| 733b21e22b | |||
| 3c3d6b65c2 | |||
| 9ca48d3a39 | |||
| 16f9edc1a0 | |||
| 8c2aec43b8 | |||
| 2564801bde | |||
| 7c99a03493 | |||
| 0e0460f6c0 | |||
| 8acd537d1d | |||
| 40c206c2f9 | |||
| 259c722208 | |||
| e618cbc447 | |||
| abd99aeb7d | |||
| ad5fc76b11 | |||
| ff1f4d6bf9 | |||
| 170ea9c32b | |||
| 65ced67432 | |||
| 9f46068c57 | |||
| 479cf2fa4f | |||
| 39c54f367f | |||
| 8c71107a93 | |||
| ef10097329 | |||
| 36ee4b5ede | |||
| ae84d5a734 | |||
| cd53770734 | |||
| 4b1eca73eb |
@@ -63,6 +63,9 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Install OpenCode
|
||||
run: curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
- name: Publish
|
||||
run: |
|
||||
./script/publish.ts
|
||||
@@ -71,3 +74,4 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
|
||||
AUR_KEY: ${{ secrets.AUR_KEY }}
|
||||
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
name: snapshot
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- opentui
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- run: git fetch --force --tags
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ">=1.24.0"
|
||||
cache: true
|
||||
cache-dependency-path: go.sum
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.2.21
|
||||
|
||||
- name: Cache ~/.bun
|
||||
id: cache-bun
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.bun
|
||||
key: ${{ runner.os }}-bun-1-2-21-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-1-2-21-
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Publish
|
||||
run: |
|
||||
./packages/opencode/script/publish.ts
|
||||
env:
|
||||
OPENCODE_SNAPSHOT: true
|
||||
OPENCODE_TAG: ${{ github.ref_name }}
|
||||
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
|
||||
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
@@ -0,0 +1,32 @@
|
||||
name: test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- production
|
||||
pull_request:
|
||||
branches-ignore:
|
||||
- production
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: 1.2.21
|
||||
|
||||
- name: run
|
||||
run: |
|
||||
git config --global user.email "bot@opencode.ai"
|
||||
git config --global user.name "opencode"
|
||||
bun install
|
||||
bun turbo test
|
||||
env:
|
||||
CI: true
|
||||
@@ -9,3 +9,4 @@ openapi.json
|
||||
playground
|
||||
tmp
|
||||
dist
|
||||
.turbo
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
bun run typecheck
|
||||
@@ -7,3 +7,6 @@ core:
|
||||
ci:
|
||||
ignore:
|
||||
wip:
|
||||
|
||||
prefer to explain WHY something was done from an end user perspective instead of
|
||||
WHAT was done.
|
||||
|
||||
@@ -14,3 +14,34 @@
|
||||
## Debugging
|
||||
|
||||
- To test opencode in the `packages/opencode` directory you can run `bun dev`
|
||||
|
||||
## Tool Calling
|
||||
|
||||
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. Here is an example illustrating how to execute 3 parallel file reads in this chat environnement:
|
||||
|
||||
json
|
||||
{
|
||||
"recipient_name": "multi_tool_use.parallel",
|
||||
"parameters": {
|
||||
"tool_uses": [
|
||||
{
|
||||
"recipient_name": "functions.read",
|
||||
"parameters": {
|
||||
"filePath": "path/to/file.tsx"
|
||||
}
|
||||
},
|
||||
{
|
||||
"recipient_name": "functions.read",
|
||||
"parameters": {
|
||||
"filePath": "path/to/file.ts"
|
||||
}
|
||||
},
|
||||
{
|
||||
"recipient_name": "functions.read",
|
||||
"parameters": {
|
||||
"filePath": "path/to/file.md"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<p align="center">
|
||||
<a href="https://opencode.ai">
|
||||
<picture>
|
||||
<source srcset="packages/web/src/assets/logo-ornate-dark.svg" media="(prefers-color-scheme: dark)">
|
||||
<source srcset="packages/web/src/assets/logo-ornate-light.svg" media="(prefers-color-scheme: light)">
|
||||
<img src="packages/web/src/assets/logo-ornate-light.svg" alt="opencode logo">
|
||||
<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 coding agent, built for the terminal.</p>
|
||||
<p align="center">The AI coding agent built for the terminal.</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/sst/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/sst/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
[](https://opencode.ai)
|
||||
|
||||
---
|
||||
|
||||
@@ -50,11 +50,11 @@ XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
### Documentation
|
||||
|
||||
For more info on how to configure opencode [**head over to our docs**](https://opencode.ai/docs).
|
||||
For more info on how to configure OpenCode [**head over to our docs**](https://opencode.ai/docs).
|
||||
|
||||
### Contributing
|
||||
|
||||
opencode is an opinionated tool so any fundamental feature needs to go through a
|
||||
OpenCode is an opinionated tool so any fundamental feature needs to go through a
|
||||
design process with the core team.
|
||||
|
||||
> [!IMPORTANT]
|
||||
@@ -74,7 +74,7 @@ Take a look at the git history to see what kind of PRs we end up merging.
|
||||
> [!NOTE]
|
||||
> If you do not follow the above guidelines we might close your PR.
|
||||
|
||||
To run opencode locally you need.
|
||||
To run OpenCode locally you need.
|
||||
|
||||
- Bun
|
||||
- Golang 1.24.x
|
||||
@@ -88,7 +88,7 @@ $ bun dev
|
||||
|
||||
#### Development Notes
|
||||
|
||||
**API Client**: After making changes to the TypeScript API endpoints in `packages/opencode/src/server/server.ts`, you will need the opencode team to generate a new stainless sdk for the clients.
|
||||
**API Client**: After making changes to the TypeScript API endpoints in `packages/opencode/src/server/server.ts`, you will need the OpenCode team to generate a new stainless sdk for the clients.
|
||||
|
||||
### FAQ
|
||||
|
||||
@@ -97,9 +97,9 @@ $ bun dev
|
||||
It's very similar to Claude Code in terms of capability. Here are the key differences:
|
||||
|
||||
- 100% open source
|
||||
- Not coupled to any provider. Although Anthropic is recommended, opencode can be used with OpenAI, Google or even local models. As models evolve the gaps between them will close and pricing will drop so being provider-agnostic is important.
|
||||
- A focus on TUI. opencode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal.
|
||||
- A client/server architecture. This for example can allow opencode to run on your computer, while you can drive it remotely from a mobile app. Meaning that the TUI frontend is just one of the possible clients.
|
||||
- Not coupled to any provider. Although Anthropic is recommended, OpenCode can be used with OpenAI, Google or even local models. As models evolve the gaps between them will close and pricing will drop so being provider-agnostic is important.
|
||||
- A focus on TUI. OpenCode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal.
|
||||
- A client/server architecture. This for example can allow OpenCode to run on your computer, while you can drive it remotely from a mobile app. Meaning that the TUI frontend is just one of the possible clients.
|
||||
|
||||
#### What's the other repo?
|
||||
|
||||
|
||||
@@ -1,83 +1,99 @@
|
||||
# Download Stats
|
||||
|
||||
| Date | GitHub Downloads | npm Downloads | Total |
|
||||
| ---------- | ---------------- | ---------------- | ----------------- |
|
||||
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
|
||||
| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) |
|
||||
| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) |
|
||||
| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) |
|
||||
| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) |
|
||||
| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) |
|
||||
| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) |
|
||||
| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) |
|
||||
| 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) |
|
||||
| 2025-07-09 | 40,924 (+2,872) | 67,935 (+3,467) | 108,859 (+6,339) |
|
||||
| 2025-07-10 | 43,796 (+2,872) | 71,402 (+3,467) | 115,198 (+6,339) |
|
||||
| 2025-07-11 | 46,982 (+3,186) | 77,462 (+6,060) | 124,444 (+9,246) |
|
||||
| 2025-07-12 | 49,302 (+2,320) | 82,177 (+4,715) | 131,479 (+7,035) |
|
||||
| 2025-07-13 | 50,803 (+1,501) | 86,394 (+4,217) | 137,197 (+5,718) |
|
||||
| 2025-07-14 | 53,283 (+2,480) | 87,860 (+1,466) | 141,143 (+3,946) |
|
||||
| 2025-07-15 | 57,590 (+4,307) | 91,036 (+3,176) | 148,626 (+7,483) |
|
||||
| 2025-07-16 | 62,313 (+4,723) | 95,258 (+4,222) | 157,571 (+8,945) |
|
||||
| 2025-07-17 | 66,684 (+4,371) | 100,048 (+4,790) | 166,732 (+9,161) |
|
||||
| 2025-07-18 | 70,379 (+3,695) | 102,587 (+2,539) | 172,966 (+6,234) |
|
||||
| 2025-07-19 | 73,497 (+3,117) | 105,904 (+3,317) | 179,401 (+6,434) |
|
||||
| 2025-07-20 | 76,453 (+2,956) | 109,044 (+3,140) | 185,497 (+6,096) |
|
||||
| 2025-07-21 | 80,197 (+3,744) | 113,537 (+4,493) | 193,734 (+8,237) |
|
||||
| 2025-07-22 | 84,251 (+4,054) | 118,073 (+4,536) | 202,324 (+8,590) |
|
||||
| 2025-07-23 | 88,589 (+4,338) | 121,436 (+3,363) | 210,025 (+7,701) |
|
||||
| 2025-07-24 | 92,469 (+3,880) | 124,091 (+2,655) | 216,560 (+6,535) |
|
||||
| 2025-07-25 | 96,417 (+3,948) | 126,985 (+2,894) | 223,402 (+6,842) |
|
||||
| 2025-07-26 | 100,646 (+4,229) | 131,411 (+4,426) | 232,057 (+8,655) |
|
||||
| 2025-07-27 | 102,644 (+1,998) | 134,736 (+3,325) | 237,380 (+5,323) |
|
||||
| 2025-07-28 | 105,446 (+2,802) | 136,016 (+1,280) | 241,462 (+4,082) |
|
||||
| 2025-07-29 | 108,998 (+3,552) | 137,542 (+1,526) | 246,540 (+5,078) |
|
||||
| 2025-07-30 | 113,544 (+4,546) | 140,317 (+2,775) | 253,861 (+7,321) |
|
||||
| 2025-07-31 | 118,339 (+4,795) | 143,344 (+3,027) | 261,683 (+7,822) |
|
||||
| 2025-08-01 | 123,539 (+5,200) | 146,680 (+3,336) | 270,219 (+8,536) |
|
||||
| 2025-08-02 | 127,864 (+4,325) | 149,236 (+2,556) | 277,100 (+6,881) |
|
||||
| 2025-08-03 | 131,397 (+3,533) | 150,451 (+1,215) | 281,848 (+4,748) |
|
||||
| 2025-08-04 | 136,266 (+4,869) | 153,260 (+2,809) | 289,526 (+7,678) |
|
||||
| 2025-08-05 | 141,596 (+5,330) | 155,752 (+2,492) | 297,348 (+7,822) |
|
||||
| 2025-08-06 | 147,067 (+5,471) | 158,309 (+2,557) | 305,376 (+8,028) |
|
||||
| 2025-08-07 | 152,591 (+5,524) | 160,889 (+2,580) | 313,480 (+8,104) |
|
||||
| 2025-08-08 | 158,187 (+5,596) | 163,448 (+2,559) | 321,635 (+8,155) |
|
||||
| 2025-08-09 | 162,770 (+4,583) | 165,721 (+2,273) | 328,491 (+6,856) |
|
||||
| 2025-08-10 | 165,695 (+2,925) | 167,109 (+1,388) | 332,804 (+4,313) |
|
||||
| 2025-08-11 | 169,297 (+3,602) | 167,953 (+844) | 337,250 (+4,446) |
|
||||
| 2025-08-12 | 176,307 (+7,010) | 171,876 (+3,923) | 348,183 (+10,933) |
|
||||
| 2025-08-13 | 182,997 (+6,690) | 177,182 (+5,306) | 360,179 (+11,996) |
|
||||
| 2025-08-14 | 189,063 (+6,066) | 179,741 (+2,559) | 368,804 (+8,625) |
|
||||
| 2025-08-15 | 193,608 (+4,545) | 181,792 (+2,051) | 375,400 (+6,596) |
|
||||
| 2025-08-16 | 198,118 (+4,510) | 184,558 (+2,766) | 382,676 (+7,276) |
|
||||
| 2025-08-17 | 201,299 (+3,181) | 186,269 (+1,711) | 387,568 (+4,892) |
|
||||
| 2025-08-18 | 204,559 (+3,260) | 187,399 (+1,130) | 391,958 (+4,390) |
|
||||
| 2025-08-19 | 209,814 (+5,255) | 189,668 (+2,269) | 399,482 (+7,524) |
|
||||
| 2025-08-20 | 214,497 (+4,683) | 191,481 (+1,813) | 405,978 (+6,496) |
|
||||
| 2025-08-21 | 220,465 (+5,968) | 194,784 (+3,303) | 415,249 (+9,271) |
|
||||
| 2025-08-22 | 225,899 (+5,434) | 197,204 (+2,420) | 423,103 (+7,854) |
|
||||
| 2025-08-23 | 229,005 (+3,106) | 199,238 (+2,034) | 428,243 (+5,140) |
|
||||
| 2025-08-24 | 232,098 (+3,093) | 201,157 (+1,919) | 433,255 (+5,012) |
|
||||
| 2025-08-25 | 236,607 (+4,509) | 202,650 (+1,493) | 439,257 (+6,002) |
|
||||
| 2025-08-26 | 242,783 (+6,176) | 205,242 (+2,592) | 448,025 (+8,768) |
|
||||
| 2025-08-27 | 248,409 (+5,626) | 205,242 (+0) | 453,651 (+5,626) |
|
||||
| 2025-08-28 | 252,796 (+4,387) | 205,242 (+0) | 458,038 (+4,387) |
|
||||
| 2025-08-29 | 256,045 (+3,249) | 211,075 (+5,833) | 467,120 (+9,082) |
|
||||
| 2025-08-30 | 258,863 (+2,818) | 212,397 (+1,322) | 471,260 (+4,140) |
|
||||
| 2025-08-31 | 262,004 (+3,141) | 213,944 (+1,547) | 475,948 (+4,688) |
|
||||
| 2025-09-01 | 265,359 (+3,355) | 215,115 (+1,171) | 480,474 (+4,526) |
|
||||
| 2025-09-02 | 270,483 (+5,124) | 217,075 (+1,960) | 487,558 (+7,084) |
|
||||
| 2025-09-03 | 274,793 (+4,310) | 219,755 (+2,680) | 494,548 (+6,990) |
|
||||
| 2025-09-04 | 280,430 (+5,637) | 222,103 (+2,348) | 502,533 (+7,985) |
|
||||
| 2025-09-05 | 283,769 (+3,339) | 223,793 (+1,690) | 507,562 (+5,029) |
|
||||
| 2025-09-06 | 286,245 (+2,476) | 225,036 (+1,243) | 511,281 (+3,719) |
|
||||
| 2025-09-07 | 288,623 (+2,378) | 225,866 (+830) | 514,489 (+3,208) |
|
||||
| 2025-09-08 | 293,341 (+4,718) | 227,073 (+1,207) | 520,414 (+5,925) |
|
||||
| 2025-09-09 | 300,036 (+6,695) | 229,788 (+2,715) | 529,824 (+9,410) |
|
||||
| 2025-09-10 | 307,287 (+7,251) | 233,435 (+3,647) | 540,722 (+10,898) |
|
||||
| 2025-09-11 | 314,083 (+6,796) | 237,356 (+3,921) | 551,439 (+10,717) |
|
||||
| 2025-09-12 | 321,046 (+6,963) | 240,728 (+3,372) | 561,774 (+10,335) |
|
||||
| 2025-09-13 | 324,894 (+3,848) | 245,539 (+4,811) | 570,433 (+8,659) |
|
||||
| 2025-09-14 | 328,876 (+3,982) | 248,245 (+2,706) | 577,121 (+6,688) |
|
||||
| 2025-09-15 | 334,201 (+5,325) | 250,983 (+2,738) | 585,184 (+8,063) |
|
||||
| 2025-09-16 | 342,609 (+8,408) | 255,264 (+4,281) | 597,873 (+12,689) |
|
||||
| Date | GitHub Downloads | npm Downloads | Total |
|
||||
| ---------- | ---------------- | ----------------- | ----------------- |
|
||||
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
|
||||
| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) |
|
||||
| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) |
|
||||
| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) |
|
||||
| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) |
|
||||
| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) |
|
||||
| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) |
|
||||
| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) |
|
||||
| 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) |
|
||||
| 2025-07-09 | 40,924 (+2,872) | 67,935 (+3,467) | 108,859 (+6,339) |
|
||||
| 2025-07-10 | 43,796 (+2,872) | 71,402 (+3,467) | 115,198 (+6,339) |
|
||||
| 2025-07-11 | 46,982 (+3,186) | 77,462 (+6,060) | 124,444 (+9,246) |
|
||||
| 2025-07-12 | 49,302 (+2,320) | 82,177 (+4,715) | 131,479 (+7,035) |
|
||||
| 2025-07-13 | 50,803 (+1,501) | 86,394 (+4,217) | 137,197 (+5,718) |
|
||||
| 2025-07-14 | 53,283 (+2,480) | 87,860 (+1,466) | 141,143 (+3,946) |
|
||||
| 2025-07-15 | 57,590 (+4,307) | 91,036 (+3,176) | 148,626 (+7,483) |
|
||||
| 2025-07-16 | 62,313 (+4,723) | 95,258 (+4,222) | 157,571 (+8,945) |
|
||||
| 2025-07-17 | 66,684 (+4,371) | 100,048 (+4,790) | 166,732 (+9,161) |
|
||||
| 2025-07-18 | 70,379 (+3,695) | 102,587 (+2,539) | 172,966 (+6,234) |
|
||||
| 2025-07-19 | 73,497 (+3,117) | 105,904 (+3,317) | 179,401 (+6,434) |
|
||||
| 2025-07-20 | 76,453 (+2,956) | 109,044 (+3,140) | 185,497 (+6,096) |
|
||||
| 2025-07-21 | 80,197 (+3,744) | 113,537 (+4,493) | 193,734 (+8,237) |
|
||||
| 2025-07-22 | 84,251 (+4,054) | 118,073 (+4,536) | 202,324 (+8,590) |
|
||||
| 2025-07-23 | 88,589 (+4,338) | 121,436 (+3,363) | 210,025 (+7,701) |
|
||||
| 2025-07-24 | 92,469 (+3,880) | 124,091 (+2,655) | 216,560 (+6,535) |
|
||||
| 2025-07-25 | 96,417 (+3,948) | 126,985 (+2,894) | 223,402 (+6,842) |
|
||||
| 2025-07-26 | 100,646 (+4,229) | 131,411 (+4,426) | 232,057 (+8,655) |
|
||||
| 2025-07-27 | 102,644 (+1,998) | 134,736 (+3,325) | 237,380 (+5,323) |
|
||||
| 2025-07-28 | 105,446 (+2,802) | 136,016 (+1,280) | 241,462 (+4,082) |
|
||||
| 2025-07-29 | 108,998 (+3,552) | 137,542 (+1,526) | 246,540 (+5,078) |
|
||||
| 2025-07-30 | 113,544 (+4,546) | 140,317 (+2,775) | 253,861 (+7,321) |
|
||||
| 2025-07-31 | 118,339 (+4,795) | 143,344 (+3,027) | 261,683 (+7,822) |
|
||||
| 2025-08-01 | 123,539 (+5,200) | 146,680 (+3,336) | 270,219 (+8,536) |
|
||||
| 2025-08-02 | 127,864 (+4,325) | 149,236 (+2,556) | 277,100 (+6,881) |
|
||||
| 2025-08-03 | 131,397 (+3,533) | 150,451 (+1,215) | 281,848 (+4,748) |
|
||||
| 2025-08-04 | 136,266 (+4,869) | 153,260 (+2,809) | 289,526 (+7,678) |
|
||||
| 2025-08-05 | 141,596 (+5,330) | 155,752 (+2,492) | 297,348 (+7,822) |
|
||||
| 2025-08-06 | 147,067 (+5,471) | 158,309 (+2,557) | 305,376 (+8,028) |
|
||||
| 2025-08-07 | 152,591 (+5,524) | 160,889 (+2,580) | 313,480 (+8,104) |
|
||||
| 2025-08-08 | 158,187 (+5,596) | 163,448 (+2,559) | 321,635 (+8,155) |
|
||||
| 2025-08-09 | 162,770 (+4,583) | 165,721 (+2,273) | 328,491 (+6,856) |
|
||||
| 2025-08-10 | 165,695 (+2,925) | 167,109 (+1,388) | 332,804 (+4,313) |
|
||||
| 2025-08-11 | 169,297 (+3,602) | 167,953 (+844) | 337,250 (+4,446) |
|
||||
| 2025-08-12 | 176,307 (+7,010) | 171,876 (+3,923) | 348,183 (+10,933) |
|
||||
| 2025-08-13 | 182,997 (+6,690) | 177,182 (+5,306) | 360,179 (+11,996) |
|
||||
| 2025-08-14 | 189,063 (+6,066) | 179,741 (+2,559) | 368,804 (+8,625) |
|
||||
| 2025-08-15 | 193,608 (+4,545) | 181,792 (+2,051) | 375,400 (+6,596) |
|
||||
| 2025-08-16 | 198,118 (+4,510) | 184,558 (+2,766) | 382,676 (+7,276) |
|
||||
| 2025-08-17 | 201,299 (+3,181) | 186,269 (+1,711) | 387,568 (+4,892) |
|
||||
| 2025-08-18 | 204,559 (+3,260) | 187,399 (+1,130) | 391,958 (+4,390) |
|
||||
| 2025-08-19 | 209,814 (+5,255) | 189,668 (+2,269) | 399,482 (+7,524) |
|
||||
| 2025-08-20 | 214,497 (+4,683) | 191,481 (+1,813) | 405,978 (+6,496) |
|
||||
| 2025-08-21 | 220,465 (+5,968) | 194,784 (+3,303) | 415,249 (+9,271) |
|
||||
| 2025-08-22 | 225,899 (+5,434) | 197,204 (+2,420) | 423,103 (+7,854) |
|
||||
| 2025-08-23 | 229,005 (+3,106) | 199,238 (+2,034) | 428,243 (+5,140) |
|
||||
| 2025-08-24 | 232,098 (+3,093) | 201,157 (+1,919) | 433,255 (+5,012) |
|
||||
| 2025-08-25 | 236,607 (+4,509) | 202,650 (+1,493) | 439,257 (+6,002) |
|
||||
| 2025-08-26 | 242,783 (+6,176) | 205,242 (+2,592) | 448,025 (+8,768) |
|
||||
| 2025-08-27 | 248,409 (+5,626) | 205,242 (+0) | 453,651 (+5,626) |
|
||||
| 2025-08-28 | 252,796 (+4,387) | 205,242 (+0) | 458,038 (+4,387) |
|
||||
| 2025-08-29 | 256,045 (+3,249) | 211,075 (+5,833) | 467,120 (+9,082) |
|
||||
| 2025-08-30 | 258,863 (+2,818) | 212,397 (+1,322) | 471,260 (+4,140) |
|
||||
| 2025-08-31 | 262,004 (+3,141) | 213,944 (+1,547) | 475,948 (+4,688) |
|
||||
| 2025-09-01 | 265,359 (+3,355) | 215,115 (+1,171) | 480,474 (+4,526) |
|
||||
| 2025-09-02 | 270,483 (+5,124) | 217,075 (+1,960) | 487,558 (+7,084) |
|
||||
| 2025-09-03 | 274,793 (+4,310) | 219,755 (+2,680) | 494,548 (+6,990) |
|
||||
| 2025-09-04 | 280,430 (+5,637) | 222,103 (+2,348) | 502,533 (+7,985) |
|
||||
| 2025-09-05 | 283,769 (+3,339) | 223,793 (+1,690) | 507,562 (+5,029) |
|
||||
| 2025-09-06 | 286,245 (+2,476) | 225,036 (+1,243) | 511,281 (+3,719) |
|
||||
| 2025-09-07 | 288,623 (+2,378) | 225,866 (+830) | 514,489 (+3,208) |
|
||||
| 2025-09-08 | 293,341 (+4,718) | 227,073 (+1,207) | 520,414 (+5,925) |
|
||||
| 2025-09-09 | 300,036 (+6,695) | 229,788 (+2,715) | 529,824 (+9,410) |
|
||||
| 2025-09-10 | 307,287 (+7,251) | 233,435 (+3,647) | 540,722 (+10,898) |
|
||||
| 2025-09-11 | 314,083 (+6,796) | 237,356 (+3,921) | 551,439 (+10,717) |
|
||||
| 2025-09-12 | 321,046 (+6,963) | 240,728 (+3,372) | 561,774 (+10,335) |
|
||||
| 2025-09-13 | 324,894 (+3,848) | 245,539 (+4,811) | 570,433 (+8,659) |
|
||||
| 2025-09-14 | 328,876 (+3,982) | 248,245 (+2,706) | 577,121 (+6,688) |
|
||||
| 2025-09-15 | 334,201 (+5,325) | 250,983 (+2,738) | 585,184 (+8,063) |
|
||||
| 2025-09-16 | 342,609 (+8,408) | 255,264 (+4,281) | 597,873 (+12,689) |
|
||||
| 2025-09-17 | 351,117 (+8,508) | 260,970 (+5,706) | 612,087 (+14,214) |
|
||||
| 2025-09-18 | 358,717 (+7,600) | 266,922 (+5,952) | 625,639 (+13,552) |
|
||||
| 2025-09-19 | 365,401 (+6,684) | 271,859 (+4,937) | 637,260 (+11,621) |
|
||||
| 2025-09-20 | 372,092 (+6,691) | 276,917 (+5,058) | 649,009 (+11,749) |
|
||||
| 2025-09-21 | 377,079 (+4,987) | 280,261 (+3,344) | 657,340 (+8,331) |
|
||||
| 2025-09-22 | 382,492 (+5,413) | 284,009 (+3,748) | 666,501 (+9,161) |
|
||||
| 2025-09-23 | 387,008 (+4,516) | 289,129 (+5,120) | 676,137 (+9,636) |
|
||||
| 2025-09-24 | 393,325 (+6,317) | 294,927 (+5,798) | 688,252 (+12,115) |
|
||||
| 2025-09-25 | 398,879 (+5,554) | 301,663 (+6,736) | 700,542 (+12,290) |
|
||||
| 2025-09-26 | 404,334 (+5,455) | 306,713 (+5,050) | 711,047 (+10,505) |
|
||||
| 2025-09-27 | 411,618 (+7,284) | 317,763 (+11,050) | 729,381 (+18,334) |
|
||||
| 2025-09-28 | 414,910 (+3,292) | 322,522 (+4,759) | 737,432 (+8,051) |
|
||||
| 2025-09-29 | 419,919 (+5,009) | 328,033 (+5,511) | 747,952 (+10,520) |
|
||||
| 2025-09-30 | 427,991 (+8,072) | 336,472 (+8,439) | 764,463 (+16,511) |
|
||||
| 2025-10-01 | 433,591 (+5,600) | 341,742 (+5,270) | 775,333 (+10,870) |
|
||||
| 2025-10-02 | 440,852 (+7,261) | 348,099 (+6,357) | 788,951 (+13,618) |
|
||||
|
||||
|
Before Width: | Height: | Size: 17 KiB |
@@ -1,2 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 212 B |
@@ -1,2 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 512 512"><rect width="336" height="336" x="128" y="128" fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="32" rx="57" ry="57"/><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32" d="m383.5 128l.5-24a56.16 56.16 0 0 0-56-56H112a64.19 64.19 0 0 0-64 64v216a56.16 56.16 0 0 0 56 56h24"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 443 B |
|
Before Width: | Height: | Size: 902 KiB |
|
Before Width: | Height: | Size: 456 KiB |
|
Before Width: | Height: | Size: 998 KiB |
|
Before Width: | Height: | Size: 592 KiB |
@@ -1,19 +0,0 @@
|
||||
<svg width="289" height="50" viewBox="0 0 289 50" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.5 16.5H24.5V33H8.5V16.5Z" fill="white" fill-opacity="0.2"/>
|
||||
<path d="M48.5 16.5H64.5V33H48.5V16.5Z" fill="white" fill-opacity="0.2"/>
|
||||
<path d="M120.5 16.5H136.5V33H120.5V16.5Z" fill="white" fill-opacity="0.2"/>
|
||||
<path d="M160.5 16.5H176.5V33H160.5V16.5Z" fill="white" fill-opacity="0.2"/>
|
||||
<path d="M192.5 16.5H208.5V33H192.5V16.5Z" fill="white" fill-opacity="0.2"/>
|
||||
<path d="M232.5 16.5H248.5V33H232.5V16.5Z" fill="white" fill-opacity="0.2"/>
|
||||
<path d="M264.5 0H288.5V8.5H272.5V16.5H288.5V25H272.5V33H288.5V41.5H264.5V0Z" fill="white" fill-opacity="0.95"/>
|
||||
<path d="M248.5 0H224.5V41.5H248.5V33H232.5V8.5H248.5V0Z" fill="white" fill-opacity="0.95"/>
|
||||
<path d="M256.5 8.5H248.5V33H256.5V8.5Z" fill="white" fill-opacity="0.95"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M184.5 0H216.5V41.5H184.5V0ZM208.5 8.5H192.5V33H208.5V8.5Z" fill="white" fill-opacity="0.95"/>
|
||||
<path d="M144.5 8.5H136.5V41.5H144.5V8.5Z" fill="white" fill-opacity="0.5"/>
|
||||
<path d="M136.5 0H112.5V41.5H120.5V8.5H136.5V0Z" fill="white" fill-opacity="0.5"/>
|
||||
<path d="M80.5 0H104.5V8.5H88.5V16.5H104.5V25H88.5V33H104.5V41.5H80.5V0Z" fill="white" fill-opacity="0.5"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M40.5 0H72.5V41.5H48.5V49.5H40.5V0ZM64.5 8.5H48.5V33H64.5V8.5Z" fill="white" fill-opacity="0.5"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.5 0H32.5V41.5955H0.5V0ZM24.5 8.5H8.5V33H24.5V8.5Z" fill="white" fill-opacity="0.5"/>
|
||||
<path d="M152.5 0H176.5V8.5H160.5V33H176.5V41.5H152.5V0Z" fill="white" fill-opacity="0.95"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1,18 +0,0 @@
|
||||
<svg width="288" height="50" viewBox="0 0 288 50" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 16.5H24V33H8V16.5Z" fill="black" fill-opacity="0.15"/>
|
||||
<path d="M48 16.5H64V33H48V16.5Z" fill="black" fill-opacity="0.15"/>
|
||||
<path d="M120 16.5H136V33H120V16.5Z" fill="black" fill-opacity="0.15"/>
|
||||
<path d="M160 16.5H176V33H160V16.5Z" fill="black" fill-opacity="0.15"/>
|
||||
<path d="M192 16.5H208V33H192V16.5Z" fill="black" fill-opacity="0.15"/>
|
||||
<path d="M232 16.5H248V33H232V16.5Z" fill="black" fill-opacity="0.15"/>
|
||||
<path d="M264 0H288V8.5H272V16.5H288V25H272V33H288V41.5H264V0Z" fill="black" fill-opacity="0.95"/>
|
||||
<path d="M248 0H224V41.5H248V33H232V8.5H248V0Z" fill="black" fill-opacity="0.95"/>
|
||||
<path d="M256 8.5H248V33H256V8.5Z" fill="black" fill-opacity="0.95"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M184 0H216V41.5H184V0ZM208 8.5H192V33H208V8.5Z" fill="black" fill-opacity="0.95"/>
|
||||
<path d="M144 8.5H136V41.5H144V8.5Z" fill="black" fill-opacity="0.55"/>
|
||||
<path d="M136 0H112V41.5H120V8.5H136V0Z" fill="black" fill-opacity="0.55"/>
|
||||
<path d="M80 0H104V8.5H88V16.5H104V25H88V33H104V41.5H80V0Z" fill="black" fill-opacity="0.55"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M40 0H72V41.5H48V49.5H40V0ZM64 8.5H48V33H64V8.5Z" fill="black" fill-opacity="0.55"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0H32V41.5955H0V0ZM24 8.5H8V33H24V8.5Z" fill="black" fill-opacity="0.55"/>
|
||||
<path d="M152 0H176V8.5H160V33H176V41.5H152V0Z" fill="black" fill-opacity="0.95"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,12 +0,0 @@
|
||||
<svg width="289" height="50" viewBox="0 0 289 50" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M264.5 0H288.5V8.5H272.5V16.5H288.5V25H272.5V33H288.5V41.5H264.5V0Z" fill="black"/>
|
||||
<path d="M248.5 0H224.5V41.5H248.5V33H232.5V8.5H248.5V0Z" fill="black"/>
|
||||
<path d="M256.5 8.5H248.5V33H256.5V8.5Z" fill="black"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M184.5 0H216.5V41.5H184.5V0ZM208.5 8.5H192.5V33H208.5V8.5Z" fill="black"/>
|
||||
<path d="M144.5 8.5H136.5V41.5H144.5V8.5Z" fill="black"/>
|
||||
<path d="M136.5 0H112.5V41.5H120.5V8.5H136.5V0Z" fill="black"/>
|
||||
<path d="M80.5 0H104.5V8.5H88.5V16.5H104.5V25H88.5V33H104.5V41.5H80.5V0Z" fill="black"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M40.5 0H72.5V41.5H48.5V49.5H40.5V0ZM64.5 8.5H48.5V33H64.5V8.5Z" fill="black"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.5 0H32.5V41.5955H0.5V0ZM24.5 8.5H8.5V33H24.5V8.5Z" fill="black"/>
|
||||
<path d="M152.5 0H176.5V8.5H160.5V33H176.5V41.5H152.5V0Z" fill="black"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 981 B |
@@ -1,82 +0,0 @@
|
||||
import { JSX } from "solid-js"
|
||||
|
||||
export function IconLogo(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox="0 0 289 50" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M264.5 0H288.5V8.5H272.5V16.5H288.5V25H272.5V33H288.5V41.5H264.5V0Z" fill="currentColor" />
|
||||
<path d="M248.5 0H224.5V41.5H248.5V33H232.5V8.5H248.5V0Z" fill="currentColor" />
|
||||
<path d="M256.5 8.5H248.5V33H256.5V8.5Z" fill="currentColor" />
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M184.5 0H216.5V41.5H184.5V0ZM208.5 8.5H192.5V33H208.5V8.5Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path d="M144.5 8.5H136.5V41.5H144.5V8.5Z" fill="currentColor" />
|
||||
<path d="M136.5 0H112.5V41.5H120.5V8.5H136.5V0Z" fill="currentColor" />
|
||||
<path d="M80.5 0H104.5V8.5H88.5V16.5H104.5V25H88.5V33H104.5V41.5H80.5V0Z" fill="currentColor" />
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M40.5 0H72.5V41.5H48.5V49.5H40.5V0ZM64.5 8.5H48.5V33H64.5V8.5Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M0.5 0H32.5V41.5955H0.5V0ZM24.5 8.5H8.5V33H24.5V8.5Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path d="M152.5 0H176.5V8.5H160.5V33H176.5V41.5H152.5V0Z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function IconCopy(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox="0 0 512 512">
|
||||
<rect
|
||||
width="336"
|
||||
height="336"
|
||||
x="128"
|
||||
y="128"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="32"
|
||||
rx="57"
|
||||
ry="57"
|
||||
></rect>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="32"
|
||||
d="m383.5 128l.5-24a56.16 56.16 0 0 0-56-56H112a64.19 64.19 0 0 0-64 64v216a56.16 56.16 0 0 0 56 56h24"
|
||||
></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function IconCheck(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z"
|
||||
></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function IconCreditCard(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 14H4V8h16v10z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,504 +0,0 @@
|
||||
[data-page="home"] {
|
||||
--color-text: hsl(224, 10%, 10%);
|
||||
--color-text-secondary: hsl(224, 7%, 46%);
|
||||
--color-text-dimmed: hsl(224, 6%, 63%);
|
||||
--color-text-inverted: hsl(0, 0%, 100%);
|
||||
|
||||
--color-border: hsl(224, 6%, 77%);
|
||||
}
|
||||
|
||||
[data-page="home"] {
|
||||
@media (prefers-color-scheme: dark) {
|
||||
--color-text: hsl(0, 0%, 100%);
|
||||
--color-text-secondary: hsl(224, 6%, 66%);
|
||||
--color-text-dimmed: hsl(224, 7%, 46%);
|
||||
--color-text-inverted: hsl(224, 10%, 10%);
|
||||
|
||||
--color-border: hsl(224, 6%, 36%);
|
||||
}
|
||||
}
|
||||
|
||||
[data-page="home"] {
|
||||
--padding: 3rem;
|
||||
--vertical-padding: 1.5rem;
|
||||
--heading-font-size: 1.375rem;
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
--padding: 1rem;
|
||||
--vertical-padding: 0.75rem;
|
||||
--heading-font-size: 1rem;
|
||||
}
|
||||
|
||||
display: flex;
|
||||
gap: var(--vertical-padding);
|
||||
flex-direction: column;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--color-text);
|
||||
padding: calc(var(--padding) + 1rem);
|
||||
|
||||
a {
|
||||
color: var(--color-text);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: var(--space-0-75);
|
||||
text-decoration-thickness: 1px;
|
||||
}
|
||||
|
||||
[data-component="content"] {
|
||||
max-width: 67.5rem;
|
||||
margin: 0 auto;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
[data-component="top"] {
|
||||
padding: calc(var(--padding) * 1.5) var(--padding) var(--padding);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: calc(var(--vertical-padding) / 2);
|
||||
|
||||
img {
|
||||
height: auto;
|
||||
width: clamp(200px, 85vw, 552px);
|
||||
}
|
||||
|
||||
[data-slot="logo dark"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
[data-slot="logo light"] {
|
||||
display: none;
|
||||
}
|
||||
[data-slot="logo dark"] {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="title"] {
|
||||
line-height: 1.25;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
font-size: var(--heading-font-size);
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
[data-slot="login"] {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border-width: 0 0 1px 1px;
|
||||
border-style: solid;
|
||||
border-color: var(--color-border);
|
||||
background-color: var(--color-bg);
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
a {
|
||||
display: block;
|
||||
padding: 0.5rem 1rem calc(0.5rem + 4px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="cta"] {
|
||||
border-top: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
|
||||
& > div + div {
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
[data-slot="left"] {
|
||||
flex: 0 0 auto;
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
padding: var(--vertical-padding) 2rem;
|
||||
text-transform: uppercase;
|
||||
font-size: 1.125rem;
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
font-size: 1rem;
|
||||
padding-bottom: calc(var(--vertical-padding) + 4px);
|
||||
}
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="center"] {
|
||||
display: none;
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
display: block;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: var(--vertical-padding) 0.5rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
border-left: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="right"] {
|
||||
flex: 1;
|
||||
padding: var(--vertical-padding) 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 50rem) {
|
||||
flex-direction: column;
|
||||
|
||||
[data-slot="right"] {
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="command"] {
|
||||
all: unset;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 1.125rem;
|
||||
font-family: var(--font-mono);
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
|
||||
& > span {
|
||||
@media (max-width: 24rem) {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
@media (max-width: 56rem) {
|
||||
[data-slot="protocol"] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@media (max-width: 38rem) {
|
||||
text-align: center;
|
||||
span:first-child {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="highlight"] {
|
||||
color: var(--color-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="features"] {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding: var(--padding);
|
||||
|
||||
[data-slot="list"] {
|
||||
padding-left: var(--space-4);
|
||||
margin: 0;
|
||||
list-style: disc;
|
||||
|
||||
li {
|
||||
margin-bottom: var(--space-4);
|
||||
line-height: 1.6;
|
||||
|
||||
strong {
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
label {
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.03125rem;
|
||||
background: var(--color-border);
|
||||
padding: 0.125rem 0.375rem;
|
||||
color: var(--color-text-inverted);
|
||||
}
|
||||
}
|
||||
|
||||
li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="install"] {
|
||||
border-top: 1px solid var(--color-border);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="method"] {
|
||||
display: flex;
|
||||
padding: calc(var(--vertical-padding) / 2) calc(var(--padding) / 2) calc(var(--vertical-padding) / 2 + 0.125rem);
|
||||
flex-direction: column;
|
||||
text-align: left;
|
||||
gap: var(--space-2-5);
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
gap: 0.3125rem;
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
border-left: 1px solid var(--color-border);
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
&:nth-child(4) {
|
||||
border-top: 1px solid var(--color-border);
|
||||
border-left: 1px solid var(--color-border);
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
border-left: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="title"] {
|
||||
letter-spacing: -0.03125rem;
|
||||
text-transform: uppercase;
|
||||
font-weight: normal;
|
||||
font-size: 1rem;
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-dimmed);
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="button"] {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--color-text-secondary);
|
||||
gap: var(--space-2-5);
|
||||
font-size: 1rem;
|
||||
|
||||
@media (max-width: 24rem) {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: var(--color-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="screenshots"] {
|
||||
border-top: 1px solid var(--color-border);
|
||||
|
||||
figure {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--padding) / 4);
|
||||
padding: calc(var(--padding) / 2);
|
||||
border-width: 0;
|
||||
border-style: solid;
|
||||
border-color: var(--color-border);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
& > div,
|
||||
figcaption {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
& > div {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
a {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
figcaption {
|
||||
letter-spacing: -0.03125rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-dimmed);
|
||||
flex-shrink: 0;
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& > [data-slot="left"] figure {
|
||||
height: var(--images-height);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
& > [data-slot="right"] figure {
|
||||
height: calc(var(--images-height) / 2);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
& > [data-slot="left"] img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
& > [data-slot="right"] img {
|
||||
width: 100%;
|
||||
height: calc(100% - 2rem);
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
& {
|
||||
--images-height: auto;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
}
|
||||
|
||||
& > [data-slot="left"] {
|
||||
grid-row: 1;
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
& > [data-slot="right"] {
|
||||
grid-row: 2;
|
||||
grid-column: 1;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
|
||||
& > [data-slot="row1"],
|
||||
& > [data-slot="row2"] {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
& > [data-slot="left"] figure,
|
||||
& > [data-slot="right"] figure {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
& > [data-slot="left"] img,
|
||||
& > [data-slot="right"] img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="copy-status"] {
|
||||
@media (max-width: 38rem) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-slot="copy"] {
|
||||
display: block;
|
||||
width: var(--space-4);
|
||||
height: var(--space-4);
|
||||
color: var(--color-text-dimmed);
|
||||
|
||||
[data-copied] & {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="check"] {
|
||||
display: none;
|
||||
width: var(--space-4);
|
||||
height: var(--space-4);
|
||||
color: var(--color-text);
|
||||
|
||||
[data-copied] & {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="footer"] {
|
||||
border-top: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
[data-slot="cell"] {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
padding: var(--vertical-padding) 0.5rem;
|
||||
}
|
||||
|
||||
[data-slot="cell"] + [data-slot="cell"] {
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Mobile: third column on its own row */
|
||||
@media (max-width: 30rem) {
|
||||
flex-wrap: wrap;
|
||||
|
||||
[data-slot="cell"]:nth-child(1),
|
||||
[data-slot="cell"]:nth-child(2) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
[data-slot="cell"]:nth-child(3) {
|
||||
flex: 1 0 100%;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="legal"] {
|
||||
color: var(--color-text-dimmed);
|
||||
text-align: center;
|
||||
|
||||
a {
|
||||
color: var(--color-text-dimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,787 +0,0 @@
|
||||
[data-page="workspace-[id]"] {
|
||||
max-width: 64rem;
|
||||
padding: var(--space-10) var(--space-4);
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-10);
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
padding-top: var(--space-4);
|
||||
padding-bottom: var(--space-4);
|
||||
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
[data-slot="sections"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-16);
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
|
||||
/* Section titles */
|
||||
[data-slot="section-title"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
|
||||
h2 {
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.03125rem;
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
font-size: var(--font-size-md);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
section:not(:last-child) {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding-bottom: var(--space-16);
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
padding-bottom: var(--space-8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="empty-state"] {
|
||||
padding: var(--space-20) var(--space-6);
|
||||
text-align: center;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
|
||||
p {
|
||||
line-height: 1.5;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
/* Title section */
|
||||
[data-component="title-section"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding-bottom: var(--space-8);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
padding-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.03125rem;
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
font-size: var(--font-size-xl);
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.5;
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--color-text-muted);
|
||||
|
||||
a {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* API Keys Section */
|
||||
[data-component="api-keys-section"] {
|
||||
[data-slot="create-form"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
|
||||
[data-slot="input-container"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-mono);
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-text-disabled);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="form-actions"] {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
[data-slot="form-error"] {
|
||||
color: var(--color-danger);
|
||||
font-size: var(--font-size-sm);
|
||||
margin-top: var(--space-1);
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="api-keys-table"] {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
[data-slot="api-keys-table-element"] {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
thead {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
th {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
text-align: left;
|
||||
font-weight: normal;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--color-border-muted);
|
||||
color: var(--color-text-muted);
|
||||
font-family: var(--font-mono);
|
||||
|
||||
&[data-slot="key-name"] {
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&[data-slot="key-value"] {
|
||||
font-family: var(--font-mono);
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 400;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: var(--color-text-muted);
|
||||
font-family: var(--font-mono);
|
||||
border-radius: var(--border-radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
text-transform: none;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--color-bg-surface);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
span {
|
||||
font-family: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&[data-slot="key-date"] {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&[data-slot="key-actions"] {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
&:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
th {
|
||||
&:nth-child(3) /* Date */ {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
&:nth-child(3) /* Date */ {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Billing Section */
|
||||
[data-component="billing-section"] {
|
||||
[data-slot="section-content"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
[data-slot="reload-error"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
|
||||
p {
|
||||
color: var(--color-danger);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
[data-slot="create-form"] {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
[data-slot="payment"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
min-width: 14.5rem;
|
||||
width: fit-content;
|
||||
|
||||
[data-slot="credit-card"] {
|
||||
padding: var(--space-3-5) var(--space-4);
|
||||
background-color: var(--color-bg-surface);
|
||||
border-radius: var(--border-radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
[data-slot="card-icon"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
[data-slot="card-details"] {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-1);
|
||||
|
||||
[data-slot="secret"] {
|
||||
position: relative;
|
||||
bottom: 2px;
|
||||
font-size: var(--font-size-lg);
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
[data-slot="number"] {
|
||||
font-size: var(--font-size-3xl);
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="button-row"] {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
|
||||
[data-slot="create-form"] {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Make Enable Billing button full width when it's the only button */
|
||||
> button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
[data-slot="usage"] {
|
||||
p {
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-secondary);
|
||||
b {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Monthly Limit Section */
|
||||
[data-component="monthly-limit-section"] {
|
||||
[data-slot="section-content"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
[data-slot="balance"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
min-width: 15rem;
|
||||
width: fit-content;
|
||||
|
||||
[data-slot="amount"] {
|
||||
padding: var(--space-3-5) var(--space-4);
|
||||
background-color: var(--color-bg-surface);
|
||||
border-radius: var(--border-radius-sm);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-1);
|
||||
justify-content: flex-end;
|
||||
|
||||
[data-slot="currency"] {
|
||||
position: relative;
|
||||
bottom: 2px;
|
||||
font-size: var(--font-size-lg);
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
[data-slot="value"] {
|
||||
font-size: var(--font-size-3xl);
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="create-form"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-1);
|
||||
|
||||
[data-slot="input-container"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-mono);
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-text-disabled);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="form-actions"] {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
[data-slot="form-error"] {
|
||||
color: var(--color-danger);
|
||||
font-size: var(--font-size-sm);
|
||||
margin-top: var(--space-1);
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="usage-status"] {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
/* Payments Section */
|
||||
[data-component="payments-section"] {
|
||||
[data-slot="payments-table"] {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
[data-slot="payments-table-element"] {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
thead {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
th {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
text-align: left;
|
||||
font-weight: normal;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--color-border-muted);
|
||||
color: var(--color-text-muted);
|
||||
font-family: var(--font-mono);
|
||||
|
||||
&[data-slot="payment-date"] {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&[data-slot="payment-id"] {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 400;
|
||||
color: var(--color-text-muted);
|
||||
max-width: 200px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
&[data-slot="payment-amount"] {
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
&:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
th {
|
||||
&:nth-child(2) /* Payment ID */ {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
&:nth-child(2) /* Payment ID */ {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Usage Section */
|
||||
[data-component="usage-section"] {
|
||||
[data-slot="usage-table"] {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
[data-slot="usage-table-element"] {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
thead {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
th {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
text-align: left;
|
||||
font-weight: normal;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--color-border-muted);
|
||||
color: var(--color-text-muted);
|
||||
font-family: var(--font-mono);
|
||||
|
||||
&[data-slot="usage-date"] {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&[data-slot="usage-model"] {
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 400;
|
||||
color: var(--color-text-secondary);
|
||||
max-width: 200px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
&[data-slot="usage-cost"] {
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
&:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
th {
|
||||
&:nth-child(2) /* Model */ {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
&:nth-child(2) /* Model */ {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="new-user-sections"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
padding: var(--space-6);
|
||||
background-color: var(--color-bg-surface);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
gap: var(--space-8);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
[data-component="feature-grid"] {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: var(--space-6);
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
[data-slot="feature"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
|
||||
h3 {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.025rem;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="api-key-highlight"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
|
||||
[data-slot="section-title"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
|
||||
h2 {
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.03125rem;
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
font-size: var(--font-size-md);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="key-display"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
|
||||
[data-slot="key-container"] {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
border: 2px solid var(--color-accent);
|
||||
border-radius: var(--border-radius-sm);
|
||||
align-items: center;
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
[data-slot="key-value"] {
|
||||
flex: 1;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-bg);
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--border-radius-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
word-break: break-all;
|
||||
line-height: 1.4;
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
font-size: var(--font-size-xs);
|
||||
padding: var(--space-2-5);
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
min-width: 130px;
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
justify-content: center;
|
||||
padding: var(--space-2-5) var(--space-3);
|
||||
font-size: var(--font-size-xs);
|
||||
min-width: 96px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="next-steps"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
|
||||
ol {
|
||||
margin: 0;
|
||||
padding-left: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
list-style-position: inside;
|
||||
|
||||
li {
|
||||
font-size: var(--font-size-md);
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,702 +0,0 @@
|
||||
import "./[id].css"
|
||||
import { Billing } from "@opencode/cloud-core/billing.js"
|
||||
import { Key } from "@opencode/cloud-core/key.js"
|
||||
import { json, query, action, useParams, useAction, createAsync, useSubmission } from "@solidjs/router"
|
||||
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { IconCopy, IconCheck, IconCreditCard } from "~/component/icon"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
function formatDateForTable(date: Date) {
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
}
|
||||
return date.toLocaleDateString("en-GB", options).replace(",", ",")
|
||||
}
|
||||
|
||||
function formatDateUTC(date: Date) {
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
timeZoneName: "short",
|
||||
timeZone: "UTC",
|
||||
}
|
||||
return date.toLocaleDateString("en-US", options)
|
||||
}
|
||||
|
||||
/////////////////////////////////////
|
||||
// Keys related queries and actions
|
||||
/////////////////////////////////////
|
||||
|
||||
const listKeys = query(async (workspaceID: string) => {
|
||||
"use server"
|
||||
return withActor(() => Key.list(), workspaceID)
|
||||
}, "key.list")
|
||||
|
||||
const createKey = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const name = form.get("name")?.toString().trim()
|
||||
if (!name) return { error: "Name is required" }
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
if (!workspaceID) return { error: "Workspace ID is required" }
|
||||
return json(
|
||||
await withActor(
|
||||
() =>
|
||||
Key.create({ name })
|
||||
.then((data) => ({ error: undefined, data }))
|
||||
.catch((e) => ({ error: e.message as string })),
|
||||
workspaceID,
|
||||
),
|
||||
{ revalidate: listKeys.key },
|
||||
)
|
||||
}, "key.create")
|
||||
|
||||
const removeKey = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const id = form.get("id")?.toString()
|
||||
if (!id) return { error: "ID is required" }
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
if (!workspaceID) return { error: "Workspace ID is required" }
|
||||
return json(await withActor(() => Key.remove({ id }), workspaceID), { revalidate: listKeys.key })
|
||||
}, "key.remove")
|
||||
|
||||
/////////////////////////////////////
|
||||
// Billing related queries and actions
|
||||
/////////////////////////////////////
|
||||
|
||||
const getBillingInfo = query(async (workspaceID: string) => {
|
||||
"use server"
|
||||
return withActor(async () => {
|
||||
return await Billing.get()
|
||||
}, workspaceID)
|
||||
}, "billing.get")
|
||||
|
||||
const getUsageInfo = query(async (workspaceID: string) => {
|
||||
"use server"
|
||||
return withActor(async () => {
|
||||
return await Billing.usages()
|
||||
}, workspaceID)
|
||||
}, "usage.list")
|
||||
|
||||
const getPaymentsInfo = query(async (workspaceID: string) => {
|
||||
"use server"
|
||||
return withActor(async () => {
|
||||
return await Billing.payments()
|
||||
}, workspaceID)
|
||||
}, "payment.list")
|
||||
|
||||
const setMonthlyLimit = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const limit = form.get("limit")?.toString()
|
||||
if (!limit) return { error: "Limit is required" }
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
if (!workspaceID) return { error: "Workspace ID is required" }
|
||||
return json(
|
||||
await withActor(
|
||||
() =>
|
||||
Billing.setMonthlyLimit(parseInt(limit))
|
||||
.then((data) => ({ error: undefined, data }))
|
||||
.catch((e) => ({ error: e.message as string })),
|
||||
workspaceID,
|
||||
),
|
||||
{ revalidate: getBillingInfo.key },
|
||||
)
|
||||
}, "billing.setMonthlyLimit")
|
||||
|
||||
const reload = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
if (!workspaceID) return { error: "Workspace ID is required" }
|
||||
return json(await withActor(() => Billing.reload(), workspaceID), { revalidate: getBillingInfo.key })
|
||||
}, "billing.reload")
|
||||
|
||||
const disableReload = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
if (!workspaceID) return { error: "Workspace ID is required" }
|
||||
return json(await withActor(() => Billing.disableReload(), workspaceID), { revalidate: getBillingInfo.key })
|
||||
}, "billing.disableReload")
|
||||
|
||||
const createCheckoutUrl = action(async (workspaceID: string, successUrl: string, cancelUrl: string) => {
|
||||
"use server"
|
||||
return withActor(() => Billing.generateCheckoutUrl({ successUrl, cancelUrl }), workspaceID)
|
||||
}, "checkoutUrl")
|
||||
|
||||
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
|
||||
"use server"
|
||||
return withActor(() => Billing.generateSessionUrl({ returnUrl }), workspaceID)
|
||||
}, "sessionUrl")
|
||||
|
||||
function KeySection() {
|
||||
const params = useParams()
|
||||
const keys = createAsync(() => listKeys(params.id))
|
||||
|
||||
function formatKey(key: string) {
|
||||
if (key.length <= 11) return key
|
||||
return `${key.slice(0, 7)}...${key.slice(-4)}`
|
||||
}
|
||||
|
||||
return (
|
||||
<section data-component="api-keys-section">
|
||||
<div data-slot="section-title">
|
||||
<h2>API Keys</h2>
|
||||
<p>Manage your API keys for accessing opencode services.</p>
|
||||
</div>
|
||||
<KeyCreateForm />
|
||||
<div data-slot="api-keys-table">
|
||||
<Show
|
||||
when={keys()?.length}
|
||||
fallback={
|
||||
<div data-component="empty-state">
|
||||
<p>Create an opencode Gateway API key</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<table data-slot="api-keys-table-element">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Key</th>
|
||||
<th>Created</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={keys()!}>
|
||||
{(key) => {
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
// const submission = useSubmission(removeKey, ([fd]) => fd.get("id")?.toString() === key.id)
|
||||
return (
|
||||
<tr>
|
||||
<td data-slot="key-name">{key.name}</td>
|
||||
<td data-slot="key-value">
|
||||
<button
|
||||
data-color="ghost"
|
||||
disabled={copied()}
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(key.key)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1000)
|
||||
}}
|
||||
title="Copy API key"
|
||||
>
|
||||
<span>{formatKey(key.key)}</span>
|
||||
<Show when={copied()} fallback={<IconCopy style={{ width: "14px", height: "14px" }} />}>
|
||||
<IconCheck style={{ width: "14px", height: "14px" }} />
|
||||
</Show>
|
||||
</button>
|
||||
</td>
|
||||
<td data-slot="key-date" title={formatDateUTC(key.timeCreated)}>
|
||||
{formatDateForTable(key.timeCreated)}
|
||||
</td>
|
||||
<td data-slot="key-actions">
|
||||
<form action={removeKey} method="post">
|
||||
<input type="hidden" name="id" value={key.id} />
|
||||
<input type="hidden" name="workspaceID" value={params.id} />
|
||||
<button data-color="ghost">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function KeyCreateForm() {
|
||||
const params = useParams()
|
||||
const submission = useSubmission(createKey)
|
||||
const [store, setStore] = createStore({ show: false })
|
||||
|
||||
let input: HTMLInputElement
|
||||
|
||||
createEffect(() => {
|
||||
if (!submission.pending && submission.result && !submission.result.error) {
|
||||
hide()
|
||||
}
|
||||
})
|
||||
|
||||
function show() {
|
||||
// submission.clear() does not clear the result in some cases, ie.
|
||||
// 1. Create key with empty name => error shows
|
||||
// 2. Put in a key name and creates the key => form hides
|
||||
// 3. Click add key button again => form shows with the same error if
|
||||
// submission.clear() is called only once
|
||||
while (true) {
|
||||
submission.clear()
|
||||
if (!submission.result) break
|
||||
}
|
||||
setStore("show", true)
|
||||
input.focus()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
setStore("show", false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={store.show}
|
||||
fallback={
|
||||
<button data-color="primary" onClick={() => show()}>
|
||||
Create API Key
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<form action={createKey} method="post" data-slot="create-form">
|
||||
<div data-slot="input-container">
|
||||
<input ref={(r) => (input = r)} data-component="input" name="name" type="text" placeholder="Enter key name" />
|
||||
<Show when={submission.result && submission.result.error}>
|
||||
{(err) => <div data-slot="form-error">{err()}</div>}
|
||||
</Show>
|
||||
</div>
|
||||
<input type="hidden" name="workspaceID" value={params.id} />
|
||||
<div data-slot="form-actions">
|
||||
<button type="reset" data-color="ghost" onClick={() => hide()}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" data-color="primary" disabled={submission.pending}>
|
||||
{submission.pending ? "Creating..." : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function BillingSection() {
|
||||
const params = useParams()
|
||||
const balanceInfo = createAsync(() => getBillingInfo(params.id))
|
||||
const createCheckoutUrlAction = useAction(createCheckoutUrl)
|
||||
const createCheckoutUrlSubmission = useSubmission(createCheckoutUrl)
|
||||
const createSessionUrlAction = useAction(createSessionUrl)
|
||||
const createSessionUrlSubmission = useSubmission(createSessionUrl)
|
||||
const disableReloadSubmission = useSubmission(disableReload)
|
||||
const reloadSubmission = useSubmission(reload)
|
||||
|
||||
const balanceAmount = createMemo(() => {
|
||||
return ((balanceInfo()?.balance ?? 0) / 100000000).toFixed(2)
|
||||
})
|
||||
|
||||
return (
|
||||
<section data-component="billing-section">
|
||||
<div data-slot="section-title">
|
||||
<h2>Billing</h2>
|
||||
<p>
|
||||
Manage payments methods. <a href="mailto:contact@anoma.ly">Contact us</a> if you have any questions.
|
||||
</p>
|
||||
</div>
|
||||
<div data-slot="section-content">
|
||||
<Show when={balanceInfo()?.reloadError}>
|
||||
<div data-slot="reload-error">
|
||||
<p>
|
||||
Reload failed at{" "}
|
||||
{balanceInfo()?.timeReloadError!.toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
})}
|
||||
. Reason: {balanceInfo()?.reloadError?.replace(/\.$/, "")}. Please update your payment method and try
|
||||
again.
|
||||
</p>
|
||||
<form action={reload} method="post" data-slot="create-form">
|
||||
<input type="hidden" name="workspaceID" value={params.id} />
|
||||
<button data-color="primary" type="submit" disabled={reloadSubmission.pending}>
|
||||
{reloadSubmission.pending ? "Reloading..." : "Reload"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</Show>
|
||||
<div data-slot="payment">
|
||||
<div data-slot="credit-card">
|
||||
<div data-slot="card-icon">
|
||||
<IconCreditCard style={{ width: "32px", height: "32px" }} />
|
||||
</div>
|
||||
<div data-slot="card-details">
|
||||
<Show when={balanceInfo()?.paymentMethodLast4} fallback={<span data-slot="number">----</span>}>
|
||||
<span data-slot="secret">••••</span>
|
||||
<span data-slot="number">{balanceInfo()?.paymentMethodLast4}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="button-row">
|
||||
<Show
|
||||
when={balanceInfo()?.reload}
|
||||
fallback={
|
||||
<button
|
||||
data-color="primary"
|
||||
disabled={createCheckoutUrlSubmission.pending}
|
||||
onClick={async () => {
|
||||
const baseUrl = window.location.href
|
||||
const checkoutUrl = await createCheckoutUrlAction(params.id, baseUrl, baseUrl)
|
||||
if (checkoutUrl) {
|
||||
window.location.href = checkoutUrl
|
||||
}
|
||||
}}
|
||||
>
|
||||
{createCheckoutUrlSubmission.pending ? "Loading..." : "Enable Billing"}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<button
|
||||
data-color="primary"
|
||||
disabled={createSessionUrlSubmission.pending}
|
||||
onClick={async () => {
|
||||
const baseUrl = window.location.href
|
||||
const sessionUrl = await createSessionUrlAction(params.id, baseUrl)
|
||||
if (sessionUrl) {
|
||||
window.location.href = sessionUrl
|
||||
}
|
||||
}}
|
||||
>
|
||||
{createSessionUrlSubmission.pending ? "Loading..." : "Manage Payment Methods"}
|
||||
</button>
|
||||
<form action={disableReload} method="post" data-slot="create-form">
|
||||
<input type="hidden" name="workspaceID" value={params.id} />
|
||||
<button data-color="ghost" type="submit" disabled={disableReloadSubmission.pending}>
|
||||
{disableReloadSubmission.pending ? "Disabling..." : "Disable"}
|
||||
</button>
|
||||
</form>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="usage">
|
||||
<Show when={!balanceInfo()?.reload && !(balanceAmount() === "0.00" || balanceAmount() === "-0.00")}>
|
||||
<p>
|
||||
You have <b data-slot="value">${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}</b> remaining in
|
||||
your account. You can continue using the API with your remaining balance.
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={balanceInfo()?.reload && !balanceInfo()?.reloadError}>
|
||||
<p>
|
||||
Your current balance is <b data-slot="value">${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}</b>
|
||||
. We'll automatically reload <b>$20</b> (+$1.23 processing fee) when it reaches <b>$5</b>.
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function MonthlyLimitSection() {
|
||||
const params = useParams()
|
||||
const submission = useSubmission(setMonthlyLimit)
|
||||
const [store, setStore] = createStore({ show: false })
|
||||
const balanceInfo = createAsync(() => getBillingInfo(params.id))
|
||||
|
||||
let input: HTMLInputElement
|
||||
|
||||
createEffect(() => {
|
||||
if (!submission.pending && submission.result && !submission.result.error) {
|
||||
hide()
|
||||
}
|
||||
})
|
||||
|
||||
function show() {
|
||||
// submission.clear() does not clear the result in some cases, ie.
|
||||
// 1. Create key with empty name => error shows
|
||||
// 2. Put in a key name and creates the key => form hides
|
||||
// 3. Click add key button again => form shows with the same error if
|
||||
// submission.clear() is called only once
|
||||
while (true) {
|
||||
submission.clear()
|
||||
if (!submission.result) break
|
||||
}
|
||||
setStore("show", true)
|
||||
input.focus()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
setStore("show", false)
|
||||
}
|
||||
|
||||
return (
|
||||
<section data-component="monthly-limit-section">
|
||||
<div data-slot="section-title">
|
||||
<h2>Monthly Limit</h2>
|
||||
<p>Set a monthly spending limit for your account.</p>
|
||||
</div>
|
||||
<div data-slot="section-content">
|
||||
<div data-slot="balance">
|
||||
<div data-slot="amount">
|
||||
{balanceInfo()?.monthlyLimit ? <span data-slot="currency">$</span> : null}
|
||||
<span data-slot="value">{balanceInfo()?.monthlyLimit ?? "-"}</span>
|
||||
</div>
|
||||
<Show
|
||||
when={!store.show}
|
||||
fallback={
|
||||
<form action={setMonthlyLimit} method="post" data-slot="create-form">
|
||||
<div data-slot="input-container">
|
||||
<input ref={(r) => (input = r)} data-component="input" name="limit" type="number" placeholder="50" />
|
||||
<Show when={submission.result && submission.result.error}>
|
||||
{(err) => <div data-slot="form-error">{err()}</div>}
|
||||
</Show>
|
||||
</div>
|
||||
<input type="hidden" name="workspaceID" value={params.id} />
|
||||
<div data-slot="form-actions">
|
||||
<button type="reset" data-color="ghost" onClick={() => hide()}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" data-color="primary" disabled={submission.pending}>
|
||||
{submission.pending ? "Setting..." : "Set"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
>
|
||||
<button data-color="primary" onClick={() => show()}>
|
||||
{balanceInfo()?.monthlyLimit ? "Edit Limit" : "Set Limit"}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={balanceInfo()?.monthlyLimit} fallback={<p data-slot="usage-status">No spending limit set.</p>}>
|
||||
<p data-slot="usage-status">
|
||||
Current usage for {new Date().toLocaleDateString("en-US", { month: "long", timeZone: "UTC" })} is $
|
||||
{(() => {
|
||||
const dateLastUsed = balanceInfo()?.timeMonthlyUsageUpdated
|
||||
if (!dateLastUsed) return "0"
|
||||
|
||||
const current = new Date().toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
timeZone: "UTC",
|
||||
})
|
||||
const lastUsed = dateLastUsed.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
timeZone: "UTC",
|
||||
})
|
||||
if (current !== lastUsed) return "0"
|
||||
return ((balanceInfo()?.monthlyUsage ?? 0) / 100000000).toFixed(2)
|
||||
})()}
|
||||
.
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function UsageSection() {
|
||||
const params = useParams()
|
||||
const usage = createAsync(() => getUsageInfo(params.id))
|
||||
|
||||
return (
|
||||
<section data-component="usage-section">
|
||||
<div data-slot="section-title">
|
||||
<h2>Usage History</h2>
|
||||
<p>Recent API usage and costs.</p>
|
||||
</div>
|
||||
<div data-slot="usage-table">
|
||||
<Show
|
||||
when={usage() && usage()!.length > 0}
|
||||
fallback={
|
||||
<div data-component="empty-state">
|
||||
<p>Make your first API call to get started.</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<table data-slot="usage-table-element">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Model</th>
|
||||
<th>Input</th>
|
||||
<th>Output</th>
|
||||
<th>Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={usage()!}>
|
||||
{(usage) => {
|
||||
const date = createMemo(() => new Date(usage.timeCreated))
|
||||
return (
|
||||
<tr>
|
||||
<td data-slot="usage-date" title={formatDateUTC(date())}>
|
||||
{formatDateForTable(date())}
|
||||
</td>
|
||||
<td data-slot="usage-model">{usage.model}</td>
|
||||
<td data-slot="usage-tokens">{usage.inputTokens}</td>
|
||||
<td data-slot="usage-tokens">{usage.outputTokens}</td>
|
||||
<td data-slot="usage-cost">${((usage.cost ?? 0) / 100000000).toFixed(4)}</td>
|
||||
</tr>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function PaymentSection() {
|
||||
const params = useParams()
|
||||
const payments = createAsync(() => getPaymentsInfo(params.id))
|
||||
|
||||
return (
|
||||
payments() &&
|
||||
payments()!.length > 0 && (
|
||||
<section data-component="payments-section">
|
||||
<div data-slot="section-title">
|
||||
<h2>Payments History</h2>
|
||||
<p>Recent payment transactions.</p>
|
||||
</div>
|
||||
<div data-slot="payments-table">
|
||||
<table data-slot="payments-table-element">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Payment ID</th>
|
||||
<th>Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={payments()!}>
|
||||
{(payment) => {
|
||||
const date = new Date(payment.timeCreated)
|
||||
return (
|
||||
<tr>
|
||||
<td data-slot="payment-date" title={formatDateUTC(date)}>
|
||||
{formatDateForTable(date)}
|
||||
</td>
|
||||
<td data-slot="payment-id">{payment.id}</td>
|
||||
<td data-slot="payment-amount">${((payment.amount ?? 0) / 100000000).toFixed(2)}</td>
|
||||
</tr>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function NewUserSection() {
|
||||
const params = useParams()
|
||||
const [copiedKey, setCopiedKey] = createSignal(false)
|
||||
const keys = createAsync(() => listKeys(params.id))
|
||||
const usage = createAsync(() => getUsageInfo(params.id))
|
||||
const isNew = createMemo(() => {
|
||||
const keysList = keys()
|
||||
const usageList = usage()
|
||||
return keysList?.length === 1 && (!usageList || usageList.length === 0)
|
||||
})
|
||||
const defaultKey = createMemo(() => keys()?.at(-1)?.key)
|
||||
|
||||
return (
|
||||
<Show when={isNew()}>
|
||||
<div data-slot="new-user-sections">
|
||||
<div data-component="feature-grid">
|
||||
<div data-slot="feature">
|
||||
<h3>Tested & Verified Models</h3>
|
||||
<p>We've benchmarked and tested models specifically for coding agents to ensure the best performance.</p>
|
||||
</div>
|
||||
<div data-slot="feature">
|
||||
<h3>Highest Quality</h3>
|
||||
<p>Access models configured for optimal performance - no downgrades or routing to cheaper providers.</p>
|
||||
</div>
|
||||
<div data-slot="feature">
|
||||
<h3>No Lock-in</h3>
|
||||
<p>Use Zen with any coding agent, and continue using other providers with opencode whenever you want.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-component="api-key-highlight">
|
||||
<Show when={defaultKey()}>
|
||||
<div data-slot="key-display">
|
||||
<div data-slot="key-container">
|
||||
<code data-slot="key-value">{defaultKey()}</code>
|
||||
<button
|
||||
data-color="primary"
|
||||
disabled={copiedKey()}
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(defaultKey() ?? "")
|
||||
setCopiedKey(true)
|
||||
setTimeout(() => setCopiedKey(false), 2000)
|
||||
}}
|
||||
title="Copy API key"
|
||||
>
|
||||
<Show
|
||||
when={copiedKey()}
|
||||
fallback={
|
||||
<>
|
||||
<IconCopy style={{ width: "16px", height: "16px" }} /> Copy Key
|
||||
</>
|
||||
}
|
||||
>
|
||||
<IconCheck style={{ width: "16px", height: "16px" }} /> Copied!
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div data-component="next-steps">
|
||||
<ol>
|
||||
<li>Enable billing</li>
|
||||
<li>
|
||||
Run <code>opencode auth login</code> and select opencode
|
||||
</li>
|
||||
<li>Paste your API key</li>
|
||||
<li>
|
||||
Start opencode and run <code>/models</code> to select a model
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export default function () {
|
||||
const params = useParams()
|
||||
const balanceInfo = createAsync(() => getBillingInfo(params.id))
|
||||
|
||||
return (
|
||||
<div data-page="workspace-[id]">
|
||||
<section data-component="title-section">
|
||||
<h1>Zen</h1>
|
||||
<p>
|
||||
Curated list of models provided by opencode.{" "}
|
||||
<a target="_blank" href="/docs/zen">
|
||||
Learn more
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div data-slot="sections">
|
||||
<NewUserSection />
|
||||
<KeySection />
|
||||
<BillingSection />
|
||||
<Show when={balanceInfo()?.reload}>
|
||||
<MonthlyLimitSection />
|
||||
</Show>
|
||||
<UsageSection />
|
||||
<PaymentSection />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,595 +0,0 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import path from "node:path"
|
||||
import { and, Database, eq, isNull, lt, or, sql } from "@opencode/cloud-core/drizzle/index.js"
|
||||
import { KeyTable } from "@opencode/cloud-core/schema/key.sql.js"
|
||||
import { BillingTable, PaymentTable, UsageTable } from "@opencode/cloud-core/schema/billing.sql.js"
|
||||
import { centsToMicroCents } from "@opencode/cloud-core/util/price.js"
|
||||
import { Identifier } from "@opencode/cloud-core/identifier.js"
|
||||
import { Resource } from "@opencode/cloud-resource"
|
||||
import { Billing } from "../../../../core/src/billing"
|
||||
import { Actor } from "@opencode/cloud-core/actor.js"
|
||||
|
||||
type ModelCost = {
|
||||
input: number
|
||||
output: number
|
||||
cacheRead?: number
|
||||
cacheWrite5m?: number
|
||||
cacheWrite1h?: number
|
||||
}
|
||||
|
||||
type Model = {
|
||||
id: string
|
||||
auth: boolean
|
||||
cost: ModelCost | ((usage: any) => ModelCost)
|
||||
headerMappings: Record<string, string>
|
||||
providers: Record<
|
||||
string,
|
||||
{
|
||||
api: string
|
||||
apiKey: string
|
||||
model: string
|
||||
weight?: number
|
||||
}
|
||||
>
|
||||
}
|
||||
|
||||
export async function handler(
|
||||
input: APIEvent,
|
||||
opts: {
|
||||
modifyBody?: (body: any) => any
|
||||
setAuthHeader: (headers: Headers, apiKey: string) => void
|
||||
parseApiKey: (headers: Headers) => string | undefined
|
||||
onStreamPart: (chunk: string) => void
|
||||
getStreamUsage: () => any
|
||||
normalizeUsage: (body: any) => {
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
reasoningTokens?: number
|
||||
cacheReadTokens?: number
|
||||
cacheWrite5mTokens?: number
|
||||
cacheWrite1hTokens?: number
|
||||
}
|
||||
},
|
||||
) {
|
||||
class AuthError extends Error {}
|
||||
class CreditsError extends Error {}
|
||||
class MonthlyLimitError extends Error {}
|
||||
class ModelError extends Error {}
|
||||
|
||||
const MODELS: Record<string, Model> = {
|
||||
"claude-opus-4-1": {
|
||||
id: "claude-opus-4-1" as const,
|
||||
auth: true,
|
||||
cost: {
|
||||
input: 0.000015,
|
||||
output: 0.000075,
|
||||
cacheRead: 0.0000015,
|
||||
cacheWrite5m: 0.00001875,
|
||||
cacheWrite1h: 0.00003,
|
||||
},
|
||||
headerMappings: {},
|
||||
providers: {
|
||||
anthropic: {
|
||||
api: "https://api.anthropic.com",
|
||||
apiKey: Resource.ANTHROPIC_API_KEY.value,
|
||||
model: "claude-opus-4-1-20250805",
|
||||
},
|
||||
},
|
||||
},
|
||||
"claude-sonnet-4": {
|
||||
id: "claude-sonnet-4" as const,
|
||||
auth: true,
|
||||
cost: (usage: any) => {
|
||||
const totalInputTokens =
|
||||
usage.inputTokens + usage.cacheReadTokens + usage.cacheWrite5mTokens + usage.cacheWrite1hTokens
|
||||
return totalInputTokens <= 200_000
|
||||
? {
|
||||
input: 0.000003,
|
||||
output: 0.000015,
|
||||
cacheRead: 0.0000003,
|
||||
cacheWrite5m: 0.00000375,
|
||||
cacheWrite1h: 0.000006,
|
||||
}
|
||||
: {
|
||||
input: 0.000006,
|
||||
output: 0.0000225,
|
||||
cacheRead: 0.0000006,
|
||||
cacheWrite5m: 0.0000075,
|
||||
cacheWrite1h: 0.000012,
|
||||
}
|
||||
},
|
||||
headerMappings: {},
|
||||
providers: {
|
||||
anthropic: {
|
||||
api: "https://api.anthropic.com",
|
||||
apiKey: Resource.ANTHROPIC_API_KEY.value,
|
||||
model: "claude-sonnet-4-20250514",
|
||||
},
|
||||
},
|
||||
},
|
||||
"claude-3-5-haiku": {
|
||||
id: "claude-3-5-haiku" as const,
|
||||
auth: true,
|
||||
cost: {
|
||||
input: 0.0000008,
|
||||
output: 0.000004,
|
||||
cacheRead: 0.00000008,
|
||||
cacheWrite5m: 0.000001,
|
||||
cacheWrite1h: 0.0000016,
|
||||
},
|
||||
headerMappings: {},
|
||||
providers: {
|
||||
anthropic: {
|
||||
api: "https://api.anthropic.com",
|
||||
apiKey: Resource.ANTHROPIC_API_KEY.value,
|
||||
model: "claude-3-5-haiku-20241022",
|
||||
},
|
||||
},
|
||||
},
|
||||
"gpt-5": {
|
||||
id: "gpt-5" as const,
|
||||
auth: true,
|
||||
cost: {
|
||||
input: 0.00000125,
|
||||
output: 0.00001,
|
||||
cacheRead: 0.000000125,
|
||||
},
|
||||
headerMappings: {},
|
||||
providers: {
|
||||
openai: {
|
||||
api: "https://api.openai.com",
|
||||
apiKey: Resource.OPENAI_API_KEY.value,
|
||||
model: "gpt-5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"qwen3-coder": {
|
||||
id: "qwen3-coder" as const,
|
||||
auth: true,
|
||||
cost: {
|
||||
input: 0.00000045,
|
||||
output: 0.0000018,
|
||||
},
|
||||
headerMappings: {},
|
||||
providers: {
|
||||
baseten: {
|
||||
api: "https://inference.baseten.co",
|
||||
apiKey: Resource.BASETEN_API_KEY.value,
|
||||
model: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
|
||||
weight: 4,
|
||||
},
|
||||
fireworks: {
|
||||
api: "https://api.fireworks.ai/inference",
|
||||
apiKey: Resource.FIREWORKS_API_KEY.value,
|
||||
model: "accounts/fireworks/models/qwen3-coder-480b-a35b-instruct",
|
||||
weight: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
"kimi-k2": {
|
||||
id: "kimi-k2" as const,
|
||||
auth: true,
|
||||
cost: {
|
||||
input: 0.0000006,
|
||||
output: 0.0000025,
|
||||
},
|
||||
headerMappings: {},
|
||||
providers: {
|
||||
baseten: {
|
||||
api: "https://inference.baseten.co",
|
||||
apiKey: Resource.BASETEN_API_KEY.value,
|
||||
model: "moonshotai/Kimi-K2-Instruct-0905",
|
||||
weight: 4,
|
||||
},
|
||||
fireworks: {
|
||||
api: "https://api.fireworks.ai/inference",
|
||||
apiKey: Resource.FIREWORKS_API_KEY.value,
|
||||
model: "accounts/fireworks/models/kimi-k2-instruct-0905",
|
||||
weight: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
"grok-code": {
|
||||
id: "grok-code" as const,
|
||||
auth: false,
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
},
|
||||
headerMappings: {
|
||||
"x-grok-conv-id": "x-opencode-session",
|
||||
"x-grok-req-id": "x-opencode-request",
|
||||
},
|
||||
providers: {
|
||||
xai: {
|
||||
api: "https://api.x.ai",
|
||||
apiKey: Resource.XAI_API_KEY.value,
|
||||
model: "grok-code",
|
||||
},
|
||||
},
|
||||
},
|
||||
// deprecated
|
||||
"qwen/qwen3-coder": {
|
||||
id: "qwen/qwen3-coder" as const,
|
||||
auth: true,
|
||||
cost: {
|
||||
input: 0.00000038,
|
||||
output: 0.00000153,
|
||||
},
|
||||
headerMappings: {},
|
||||
providers: {
|
||||
baseten: {
|
||||
api: "https://inference.baseten.co",
|
||||
apiKey: Resource.BASETEN_API_KEY.value,
|
||||
model: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
|
||||
weight: 5,
|
||||
},
|
||||
fireworks: {
|
||||
api: "https://api.fireworks.ai/inference",
|
||||
apiKey: Resource.FIREWORKS_API_KEY.value,
|
||||
model: "accounts/fireworks/models/qwen3-coder-480b-a35b-instruct",
|
||||
weight: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const FREE_WORKSPACES = [
|
||||
"wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank
|
||||
]
|
||||
|
||||
const logger = {
|
||||
metric: (values: Record<string, any>) => {
|
||||
console.log(`_metric:${JSON.stringify(values)}`)
|
||||
},
|
||||
log: console.log,
|
||||
debug: (message: string) => {
|
||||
if (Resource.App.stage === "production") return
|
||||
console.debug(message)
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(input.request.url)
|
||||
const body = await input.request.json()
|
||||
logger.debug(JSON.stringify(body))
|
||||
logger.metric({
|
||||
is_tream: !!body.stream,
|
||||
session: input.request.headers.get("x-opencode-session"),
|
||||
request: input.request.headers.get("x-opencode-request"),
|
||||
})
|
||||
const MODEL = validateModel()
|
||||
const apiKey = await authenticate()
|
||||
const isFree = FREE_WORKSPACES.includes(apiKey?.workspaceID ?? "")
|
||||
await checkCreditsAndLimit()
|
||||
const providerName = selectProvider()
|
||||
const providerData = MODEL.providers[providerName]
|
||||
logger.metric({ provider: providerName })
|
||||
|
||||
// Request to model provider
|
||||
const startTimestamp = Date.now()
|
||||
const res = await fetch(path.posix.join(providerData.api, url.pathname.replace(/^\/zen/, "") + url.search), {
|
||||
method: "POST",
|
||||
headers: (() => {
|
||||
const headers = input.request.headers
|
||||
headers.delete("host")
|
||||
headers.delete("content-length")
|
||||
opts.setAuthHeader(headers, providerData.apiKey)
|
||||
Object.entries(MODEL.headerMappings ?? {}).forEach(([k, v]) => {
|
||||
headers.set(k, headers.get(v)!)
|
||||
})
|
||||
return headers
|
||||
})(),
|
||||
body: JSON.stringify({
|
||||
...(opts.modifyBody?.(body) ?? body),
|
||||
model: providerData.model,
|
||||
}),
|
||||
})
|
||||
|
||||
// Scrub response headers
|
||||
const resHeaders = new Headers()
|
||||
const keepHeaders = ["content-type", "cache-control"]
|
||||
for (const [k, v] of res.headers.entries()) {
|
||||
if (keepHeaders.includes(k.toLowerCase())) {
|
||||
resHeaders.set(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle non-streaming response
|
||||
if (!body.stream) {
|
||||
const json = await res.json()
|
||||
const body = JSON.stringify(json)
|
||||
logger.metric({ response_length: body.length })
|
||||
logger.debug(body)
|
||||
await trackUsage(json.usage)
|
||||
await reload()
|
||||
return new Response(body, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers: resHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
// Handle streaming response
|
||||
const stream = new ReadableStream({
|
||||
start(c) {
|
||||
const reader = res.body?.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ""
|
||||
let responseLength = 0
|
||||
|
||||
function pump(): Promise<void> {
|
||||
return (
|
||||
reader?.read().then(async ({ done, value }) => {
|
||||
if (done) {
|
||||
logger.metric({ response_length: responseLength })
|
||||
const usage = opts.getStreamUsage()
|
||||
if (usage) {
|
||||
await trackUsage(usage)
|
||||
await reload()
|
||||
}
|
||||
c.close()
|
||||
return
|
||||
}
|
||||
|
||||
if (responseLength === 0) {
|
||||
logger.metric({ time_to_first_byte: Date.now() - startTimestamp })
|
||||
}
|
||||
responseLength += value.length
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
const parts = buffer.split("\n\n")
|
||||
buffer = parts.pop() ?? ""
|
||||
|
||||
for (const part of parts) {
|
||||
logger.debug(part)
|
||||
opts.onStreamPart(part.trim())
|
||||
}
|
||||
|
||||
c.enqueue(value)
|
||||
|
||||
return pump()
|
||||
}) || Promise.resolve()
|
||||
)
|
||||
}
|
||||
|
||||
return pump()
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers: resHeaders,
|
||||
})
|
||||
|
||||
function validateModel() {
|
||||
if (!(body.model in MODELS)) {
|
||||
throw new ModelError(`Model ${body.model} not supported`)
|
||||
}
|
||||
const model = MODELS[body.model as keyof typeof MODELS]
|
||||
logger.metric({ model: model.id })
|
||||
return model
|
||||
}
|
||||
|
||||
async function authenticate() {
|
||||
try {
|
||||
const apiKey = opts.parseApiKey(input.request.headers)
|
||||
if (!apiKey) throw new AuthError("Missing API key.")
|
||||
|
||||
const key = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
id: KeyTable.id,
|
||||
workspaceID: KeyTable.workspaceID,
|
||||
})
|
||||
.from(KeyTable)
|
||||
.where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted)))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
|
||||
if (!key) throw new AuthError("Invalid API key.")
|
||||
logger.metric({
|
||||
api_key: key.id,
|
||||
workspace: key.workspaceID,
|
||||
})
|
||||
return key
|
||||
} catch (e) {
|
||||
// ignore error if model does not require authentication
|
||||
if (!MODEL.auth) return
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async function checkCreditsAndLimit() {
|
||||
if (!apiKey || !MODEL.auth || isFree) return
|
||||
|
||||
const billing = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
balance: BillingTable.balance,
|
||||
paymentMethodID: BillingTable.paymentMethodID,
|
||||
monthlyLimit: BillingTable.monthlyLimit,
|
||||
monthlyUsage: BillingTable.monthlyUsage,
|
||||
timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated,
|
||||
})
|
||||
.from(BillingTable)
|
||||
.where(eq(BillingTable.workspaceID, apiKey.workspaceID))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
|
||||
if (!billing.paymentMethodID) throw new CreditsError("No payment method")
|
||||
if (billing.balance <= 0) throw new CreditsError("Insufficient balance")
|
||||
if (
|
||||
billing.monthlyLimit &&
|
||||
billing.monthlyUsage &&
|
||||
billing.timeMonthlyUsageUpdated &&
|
||||
billing.monthlyUsage >= centsToMicroCents(billing.monthlyLimit * 100)
|
||||
) {
|
||||
const now = new Date()
|
||||
const currentYear = now.getUTCFullYear()
|
||||
const currentMonth = now.getUTCMonth()
|
||||
const dateYear = billing.timeMonthlyUsageUpdated.getUTCFullYear()
|
||||
const dateMonth = billing.timeMonthlyUsageUpdated.getUTCMonth()
|
||||
if (currentYear === dateYear && currentMonth === dateMonth)
|
||||
throw new MonthlyLimitError(`You have reached your monthly spending limit of $${billing.monthlyLimit}.`)
|
||||
}
|
||||
}
|
||||
|
||||
function selectProvider() {
|
||||
const picks = Object.entries(MODEL.providers).flatMap(([name, provider]) =>
|
||||
Array<string>(provider.weight ?? 1).fill(name),
|
||||
)
|
||||
return picks[Math.floor(Math.random() * picks.length)]
|
||||
}
|
||||
|
||||
async function trackUsage(usage: any) {
|
||||
const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } =
|
||||
opts.normalizeUsage(usage)
|
||||
|
||||
const modelCost = typeof MODEL.cost === "function" ? MODEL.cost(usage) : MODEL.cost
|
||||
|
||||
const inputCost = modelCost.input * inputTokens * 100
|
||||
const outputCost = modelCost.output * outputTokens * 100
|
||||
const reasoningCost = (() => {
|
||||
if (!reasoningTokens) return undefined
|
||||
return modelCost.output * reasoningTokens * 100
|
||||
})()
|
||||
const cacheReadCost = (() => {
|
||||
if (!cacheReadTokens) return undefined
|
||||
if (!modelCost.cacheRead) return undefined
|
||||
return modelCost.cacheRead * cacheReadTokens * 100
|
||||
})()
|
||||
const cacheWrite5mCost = (() => {
|
||||
if (!cacheWrite5mTokens) return undefined
|
||||
if (!modelCost.cacheWrite5m) return undefined
|
||||
return modelCost.cacheWrite5m * cacheWrite5mTokens * 100
|
||||
})()
|
||||
const cacheWrite1hCost = (() => {
|
||||
if (!cacheWrite1hTokens) return undefined
|
||||
if (!modelCost.cacheWrite1h) return undefined
|
||||
return modelCost.cacheWrite1h * cacheWrite1hTokens * 100
|
||||
})()
|
||||
const totalCostInCent =
|
||||
inputCost +
|
||||
outputCost +
|
||||
(reasoningCost ?? 0) +
|
||||
(cacheReadCost ?? 0) +
|
||||
(cacheWrite5mCost ?? 0) +
|
||||
(cacheWrite1hCost ?? 0)
|
||||
|
||||
logger.metric({
|
||||
"tokens.input": inputTokens,
|
||||
"tokens.output": outputTokens,
|
||||
"tokens.reasoning": reasoningTokens,
|
||||
"tokens.cache_read": cacheReadTokens,
|
||||
"tokens.cache_write_5m": cacheWrite5mTokens,
|
||||
"tokens.cache_write_1h": cacheWrite1hTokens,
|
||||
"cost.input": Math.round(inputCost),
|
||||
"cost.output": Math.round(outputCost),
|
||||
"cost.reasoning": reasoningCost ? Math.round(reasoningCost) : undefined,
|
||||
"cost.cache_read": cacheReadCost ? Math.round(cacheReadCost) : undefined,
|
||||
"cost.cache_write_5m": cacheWrite5mCost ? Math.round(cacheWrite5mCost) : undefined,
|
||||
"cost.cache_write_1h": cacheWrite1hCost ? Math.round(cacheWrite1hCost) : undefined,
|
||||
"cost.total": Math.round(totalCostInCent),
|
||||
})
|
||||
|
||||
if (!apiKey) return
|
||||
|
||||
const cost = isFree ? 0 : centsToMicroCents(totalCostInCent)
|
||||
await Database.transaction(async (tx) => {
|
||||
await tx.insert(UsageTable).values({
|
||||
workspaceID: apiKey.workspaceID,
|
||||
id: Identifier.create("usage"),
|
||||
model: MODEL.id,
|
||||
provider: providerName,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
reasoningTokens,
|
||||
cacheReadTokens,
|
||||
cacheWrite5mTokens,
|
||||
cacheWrite1hTokens,
|
||||
cost,
|
||||
})
|
||||
await tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
balance: sql`${BillingTable.balance} - ${cost}`,
|
||||
monthlyUsage: sql`
|
||||
CASE
|
||||
WHEN MONTH(${BillingTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${BillingTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${BillingTable.monthlyUsage} + ${cost}
|
||||
ELSE ${cost}
|
||||
END
|
||||
`,
|
||||
timeMonthlyUsageUpdated: sql`now()`,
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, apiKey.workspaceID))
|
||||
})
|
||||
|
||||
await Database.use((tx) =>
|
||||
tx
|
||||
.update(KeyTable)
|
||||
.set({ timeUsed: sql`now()` })
|
||||
.where(eq(KeyTable.id, apiKey.id)),
|
||||
)
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
if (!apiKey) return
|
||||
|
||||
// acquire reload lock
|
||||
const lock = await Database.use((tx) =>
|
||||
tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
timeReloadLockedTill: sql`now() + interval 1 minute`,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(BillingTable.workspaceID, apiKey.workspaceID),
|
||||
eq(BillingTable.reload, true),
|
||||
lt(BillingTable.balance, centsToMicroCents(Billing.CHARGE_THRESHOLD)),
|
||||
or(isNull(BillingTable.timeReloadLockedTill), lt(BillingTable.timeReloadLockedTill, sql`now()`)),
|
||||
),
|
||||
),
|
||||
)
|
||||
if (lock.rowsAffected === 0) return
|
||||
|
||||
await Actor.provide("system", { workspaceID: apiKey.workspaceID }, async () => {
|
||||
await Billing.reload()
|
||||
})
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.metric({
|
||||
"error.type": error.constructor.name,
|
||||
"error.message": error.message,
|
||||
})
|
||||
|
||||
// Note: both top level "type" and "error.type" fields are used by the @ai-sdk/anthropic client to render the error message.
|
||||
if (
|
||||
error instanceof AuthError ||
|
||||
error instanceof CreditsError ||
|
||||
error instanceof MonthlyLimitError ||
|
||||
error instanceof ModelError
|
||||
)
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
error: { type: error.constructor.name, message: error.message },
|
||||
}),
|
||||
{ status: 401 },
|
||||
)
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
error: {
|
||||
type: "error",
|
||||
message: error.message,
|
||||
},
|
||||
}),
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
html {
|
||||
line-height: 1;
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { text, mysqlTable, uniqueIndex, varchar, int } from "drizzle-orm/mysql-core"
|
||||
import { timestamps, utc, workspaceColumns } from "../drizzle/types"
|
||||
import { workspaceIndexes } from "./workspace.sql"
|
||||
|
||||
export const UserTable = mysqlTable(
|
||||
"user",
|
||||
{
|
||||
...workspaceColumns,
|
||||
...timestamps,
|
||||
email: varchar("email", { length: 255 }).notNull(),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
timeSeen: utc("time_seen"),
|
||||
color: int("color"),
|
||||
},
|
||||
(table) => [...workspaceIndexes(table), uniqueIndex("user_email").on(table.workspaceID, table.email)],
|
||||
)
|
||||
@@ -1,18 +0,0 @@
|
||||
import { z } from "zod"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { fn } from "./util/fn"
|
||||
import { Database } from "./drizzle"
|
||||
import { UserTable } from "./schema/user.sql"
|
||||
|
||||
export namespace User {
|
||||
export const fromID = fn(z.string(), async (id) =>
|
||||
Database.transaction(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(UserTable)
|
||||
.where(eq(UserTable.id, id))
|
||||
.execute()
|
||||
.then((rows) => rows[0])
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
/* This file is auto-generated by SST. Do not edit. */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
|
||||
import "sst"
|
||||
declare module "sst" {
|
||||
export interface Resource {
|
||||
ANTHROPIC_API_KEY: {
|
||||
type: "sst.sst.Secret"
|
||||
value: string
|
||||
}
|
||||
AUTH_API_URL: {
|
||||
type: "sst.sst.Linkable"
|
||||
value: string
|
||||
}
|
||||
BASETEN_API_KEY: {
|
||||
type: "sst.sst.Secret"
|
||||
value: string
|
||||
}
|
||||
Console: {
|
||||
type: "sst.cloudflare.SolidStart"
|
||||
url: string
|
||||
}
|
||||
Database: {
|
||||
database: string
|
||||
host: string
|
||||
password: string
|
||||
port: number
|
||||
type: "sst.sst.Linkable"
|
||||
username: string
|
||||
}
|
||||
FIREWORKS_API_KEY: {
|
||||
type: "sst.sst.Secret"
|
||||
value: string
|
||||
}
|
||||
GITHUB_APP_ID: {
|
||||
type: "sst.sst.Secret"
|
||||
value: string
|
||||
}
|
||||
GITHUB_APP_PRIVATE_KEY: {
|
||||
type: "sst.sst.Secret"
|
||||
value: string
|
||||
}
|
||||
GITHUB_CLIENT_ID_CONSOLE: {
|
||||
type: "sst.sst.Secret"
|
||||
value: string
|
||||
}
|
||||
GITHUB_CLIENT_SECRET_CONSOLE: {
|
||||
type: "sst.sst.Secret"
|
||||
value: string
|
||||
}
|
||||
GOOGLE_CLIENT_ID: {
|
||||
type: "sst.sst.Secret"
|
||||
value: string
|
||||
}
|
||||
HONEYCOMB_API_KEY: {
|
||||
type: "sst.sst.Secret"
|
||||
value: string
|
||||
}
|
||||
OPENAI_API_KEY: {
|
||||
type: "sst.sst.Secret"
|
||||
value: string
|
||||
}
|
||||
STRIPE_SECRET_KEY: {
|
||||
type: "sst.sst.Secret"
|
||||
value: string
|
||||
}
|
||||
STRIPE_WEBHOOK_SECRET: {
|
||||
type: "sst.sst.Linkable"
|
||||
value: string
|
||||
}
|
||||
Web: {
|
||||
type: "sst.cloudflare.Astro"
|
||||
url: string
|
||||
}
|
||||
XAI_API_KEY: {
|
||||
type: "sst.sst.Secret"
|
||||
value: string
|
||||
}
|
||||
}
|
||||
}
|
||||
// cloudflare
|
||||
import * as cloudflare from "@cloudflare/workers-types"
|
||||
declare module "sst" {
|
||||
export interface Resource {
|
||||
Api: cloudflare.Service
|
||||
AuthApi: cloudflare.Service
|
||||
AuthStorage: cloudflare.KVNamespace
|
||||
Bucket: cloudflare.R2Bucket
|
||||
LogProcessor: cloudflare.Service
|
||||
}
|
||||
}
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
@@ -1,96 +0,0 @@
|
||||
/* This file is auto-generated by SST. Do not edit. */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
|
||||
import "sst"
|
||||
declare module "sst" {
|
||||
export interface Resource {
|
||||
ANTHROPIC_API_KEY: {
|
||||
type: "sst.sst.Secret"
|
||||
value: string
|
||||
}
|
||||
AUTH_API_URL: {
|
||||
type: "sst.sst.Linkable"
|
||||
value: string
|
||||
}
|
||||
BASETEN_API_KEY: {
|
||||
type: "sst.sst.Secret"
|
||||
value: string
|
||||
}
|
||||
Console: {
|
||||
type: "sst.cloudflare.SolidStart"
|
||||
url: string
|
||||
}
|
||||
Database: {
|
||||
database: string
|
||||
host: string
|
||||
password: string
|
||||
port: number
|
||||
type: "sst.sst.Linkable"
|
||||
username: string
|
||||
}
|
||||
FIREWORKS_API_KEY: {
|
||||
type: "sst.sst.Secret"
|
||||
value: string
|
||||
}
|
||||
GITHUB_APP_ID: {
|
||||
type: "sst.sst.Secret"
|
||||
value: string
|
||||
}
|
||||
GITHUB_APP_PRIVATE_KEY: {
|
||||
type: "sst.sst.Secret"
|
||||
value: string
|
||||
}
|
||||
GITHUB_CLIENT_ID_CONSOLE: {
|
||||
type: "sst.sst.Secret"
|
||||
value: string
|
||||
}
|
||||
GITHUB_CLIENT_SECRET_CONSOLE: {
|
||||
type: "sst.sst.Secret"
|
||||
value: string
|
||||
}
|
||||
GOOGLE_CLIENT_ID: {
|
||||
type: "sst.sst.Secret"
|
||||
value: string
|
||||
}
|
||||
HONEYCOMB_API_KEY: {
|
||||
type: "sst.sst.Secret"
|
||||
value: string
|
||||
}
|
||||
OPENAI_API_KEY: {
|
||||
type: "sst.sst.Secret"
|
||||
value: string
|
||||
}
|
||||
STRIPE_SECRET_KEY: {
|
||||
type: "sst.sst.Secret"
|
||||
value: string
|
||||
}
|
||||
STRIPE_WEBHOOK_SECRET: {
|
||||
type: "sst.sst.Linkable"
|
||||
value: string
|
||||
}
|
||||
Web: {
|
||||
type: "sst.cloudflare.Astro"
|
||||
url: string
|
||||
}
|
||||
XAI_API_KEY: {
|
||||
type: "sst.sst.Secret"
|
||||
value: string
|
||||
}
|
||||
}
|
||||
}
|
||||
// cloudflare
|
||||
import * as cloudflare from "@cloudflare/workers-types"
|
||||
declare module "sst" {
|
||||
export interface Resource {
|
||||
Api: cloudflare.Service
|
||||
AuthApi: cloudflare.Service
|
||||
AuthStorage: cloudflare.KVNamespace
|
||||
Bucket: cloudflare.R2Bucket
|
||||
LogProcessor: cloudflare.Service
|
||||
}
|
||||
}
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
@@ -6,4 +6,4 @@
|
||||
/// <reference path="../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
export {}
|
||||
@@ -2,6 +2,7 @@ import { domain } from "./stage"
|
||||
|
||||
const GITHUB_APP_ID = new sst.Secret("GITHUB_APP_ID")
|
||||
const GITHUB_APP_PRIVATE_KEY = new sst.Secret("GITHUB_APP_PRIVATE_KEY")
|
||||
export const EMAILOCTOPUS_API_KEY = new sst.Secret("EMAILOCTOPUS_API_KEY")
|
||||
const bucket = new sst.cloudflare.Bucket("Bucket")
|
||||
|
||||
export const api = new sst.cloudflare.Worker("Api", {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { WebhookEndpoint } from "pulumi-stripe"
|
||||
import { domain } from "./stage"
|
||||
import { EMAILOCTOPUS_API_KEY } from "./app"
|
||||
|
||||
////////////////
|
||||
// DATABASE
|
||||
@@ -44,7 +44,7 @@ new sst.x.DevCommand("Studio", {
|
||||
link: [database],
|
||||
dev: {
|
||||
command: "bun db studio",
|
||||
directory: "cloud/core",
|
||||
directory: "packages/console/core",
|
||||
autostart: true,
|
||||
},
|
||||
})
|
||||
@@ -59,7 +59,7 @@ const GOOGLE_CLIENT_ID = new sst.Secret("GOOGLE_CLIENT_ID")
|
||||
const authStorage = new sst.cloudflare.Kv("AuthStorage")
|
||||
export const auth = new sst.cloudflare.Worker("AuthApi", {
|
||||
domain: `auth.${domain}`,
|
||||
handler: "cloud/function/src/auth.ts",
|
||||
handler: "packages/console/function/src/auth.ts",
|
||||
url: true,
|
||||
link: [database, authStorage, GITHUB_CLIENT_ID_CONSOLE, GITHUB_CLIENT_SECRET_CONSOLE, GOOGLE_CLIENT_ID],
|
||||
})
|
||||
@@ -68,13 +68,14 @@ export const auth = new sst.cloudflare.Worker("AuthApi", {
|
||||
// GATEWAY
|
||||
////////////////
|
||||
|
||||
export const stripeWebhook = new WebhookEndpoint("StripeWebhookEndpoint", {
|
||||
export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint", {
|
||||
url: $interpolate`https://${domain}/stripe/webhook`,
|
||||
enabledEvents: [
|
||||
"checkout.session.async_payment_failed",
|
||||
"checkout.session.async_payment_succeeded",
|
||||
"checkout.session.completed",
|
||||
"checkout.session.expired",
|
||||
"charge.refunded",
|
||||
"customer.created",
|
||||
"customer.deleted",
|
||||
"customer.updated",
|
||||
@@ -93,17 +94,10 @@ export const stripeWebhook = new WebhookEndpoint("StripeWebhookEndpoint", {
|
||||
"customer.subscription.resumed",
|
||||
"customer.subscription.trial_will_end",
|
||||
"customer.subscription.updated",
|
||||
"customer.tax_id.created",
|
||||
"customer.tax_id.deleted",
|
||||
"customer.tax_id.updated",
|
||||
],
|
||||
})
|
||||
|
||||
const ANTHROPIC_API_KEY = new sst.Secret("ANTHROPIC_API_KEY")
|
||||
const OPENAI_API_KEY = new sst.Secret("OPENAI_API_KEY")
|
||||
const XAI_API_KEY = new sst.Secret("XAI_API_KEY")
|
||||
const BASETEN_API_KEY = new sst.Secret("BASETEN_API_KEY")
|
||||
const FIREWORKS_API_KEY = new sst.Secret("FIREWORKS_API_KEY")
|
||||
const ZEN_MODELS = new sst.Secret("ZEN_MODELS")
|
||||
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
|
||||
const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
|
||||
properties: { value: auth.url.apply((url) => url!) },
|
||||
@@ -116,28 +110,30 @@ const STRIPE_WEBHOOK_SECRET = new sst.Linkable("STRIPE_WEBHOOK_SECRET", {
|
||||
// CONSOLE
|
||||
////////////////
|
||||
|
||||
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
|
||||
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
|
||||
|
||||
let logProcessor
|
||||
if ($app.stage === "production" || $app.stage === "frank") {
|
||||
const HONEYCOMB_API_KEY = new sst.Secret("HONEYCOMB_API_KEY")
|
||||
logProcessor = new sst.cloudflare.Worker("LogProcessor", {
|
||||
handler: "cloud/function/src/log-processor.ts",
|
||||
handler: "packages/console/function/src/log-processor.ts",
|
||||
link: [HONEYCOMB_API_KEY],
|
||||
})
|
||||
}
|
||||
|
||||
new sst.cloudflare.x.SolidStart("Console", {
|
||||
domain,
|
||||
path: "cloud/app",
|
||||
path: "packages/console/app",
|
||||
link: [
|
||||
database,
|
||||
AUTH_API_URL,
|
||||
STRIPE_WEBHOOK_SECRET,
|
||||
STRIPE_SECRET_KEY,
|
||||
ANTHROPIC_API_KEY,
|
||||
OPENAI_API_KEY,
|
||||
XAI_API_KEY,
|
||||
BASETEN_API_KEY,
|
||||
FIREWORKS_API_KEY,
|
||||
ZEN_MODELS,
|
||||
EMAILOCTOPUS_API_KEY,
|
||||
AWS_SES_ACCESS_KEY_ID,
|
||||
AWS_SES_SECRET_ACCESS_KEY,
|
||||
],
|
||||
environment: {
|
||||
//VITE_DOCS_URL: web.url.apply((url) => url!),
|
||||
@@ -4,7 +4,7 @@ new sst.cloudflare.StaticSite("Desktop", {
|
||||
domain: "desktop." + domain,
|
||||
path: "packages/app",
|
||||
build: {
|
||||
command: "bun run build",
|
||||
command: "bun turbo build",
|
||||
output: "./dist",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -3,3 +3,11 @@ export const domain = (() => {
|
||||
if ($app.stage === "dev") return "dev.opencode.ai"
|
||||
return `${$app.stage}.dev.opencode.ai`
|
||||
})()
|
||||
|
||||
export const zoneID = "430ba34c138cfb5360826c4909f99be8"
|
||||
|
||||
// new cloudflare.RegionalHostname("RegionalHostname", {
|
||||
// hostname: domain,
|
||||
// regionKey: "us",
|
||||
// zoneId: zoneID,
|
||||
// })
|
||||
|
||||
@@ -46,7 +46,7 @@ mkdir -p "$INSTALL_DIR"
|
||||
|
||||
if [ -z "$requested_version" ]; then
|
||||
url="https://github.com/sst/opencode/releases/latest/download/$filename"
|
||||
specific_version=$(curl -s https://api.github.com/repos/sst/opencode/releases/latest | awk -F'"' '/"tag_name": "/ {gsub(/^v/, "", $4); print $4}')
|
||||
specific_version=$(curl -s https://api.github.com/repos/sst/opencode/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')
|
||||
|
||||
if [[ $? -ne 0 || -z "$specific_version" ]]; then
|
||||
echo -e "${RED}Failed to fetch version information${NC}"
|
||||
|
||||
@@ -5,36 +5,37 @@
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.2.21",
|
||||
"scripts": {
|
||||
"dev": "bun run --conditions=development packages/opencode/src/index.ts",
|
||||
"typecheck": "bun run --filter='*' typecheck",
|
||||
"generate": "(cd packages/sdk && ./js/script/generate.ts) && (cd packages/sdk/stainless && ./generate.ts)",
|
||||
"postinstall": "./script/hooks"
|
||||
"dev": "bun run packages/opencode/src/index.ts",
|
||||
"typecheck": "bun turbo typecheck",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
"cloud/*",
|
||||
"packages/*",
|
||||
"packages/console/*",
|
||||
"packages/sdk/js"
|
||||
],
|
||||
"catalog": {
|
||||
"@types/bun": "1.2.21",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@types/node": "22.13.9",
|
||||
"@tsconfig/node22": "22.0.2",
|
||||
"ai": "5.0.8",
|
||||
"hono": "4.7.10",
|
||||
"fuzzysort": "3.1.0",
|
||||
"luxon": "3.6.1",
|
||||
"typescript": "5.8.2",
|
||||
"zod": "4.1.8",
|
||||
"remeda": "2.26.0",
|
||||
"solid-js": "1.9.9"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"pulumi-stripe": "0.0.24"
|
||||
},
|
||||
"devDependencies": {
|
||||
"husky": "9.1.7",
|
||||
"prettier": "3.6.2",
|
||||
"sst": "3.17.13"
|
||||
"sst": "3.17.13",
|
||||
"turbo": "2.5.6"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
src/assets/theme.css
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<link rel="shortcut icon" type="image/ico" href="/src/assets/favicon.ico" />
|
||||
<link rel="shortcut icon" type="image/ico" href="/src/assets/favicon.svg" />
|
||||
<link rel="stylesheet" href="/src/assets/theme.css" />
|
||||
<title>opencode</title>
|
||||
</head>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "0.9.6",
|
||||
"version": "0.14.1",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -21,15 +21,17 @@
|
||||
"vite-plugin-solid": "^2.11.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@shikijs/transformers": "3.9.2",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"@solid-primitives/resize-observer": "2.1.3",
|
||||
"@solid-primitives/scroll": "2.1.3",
|
||||
"@solidjs/router": "0.15.3",
|
||||
"@thisbeyond/solid-dnd": "0.7.5",
|
||||
"diff": "8.0.2",
|
||||
"luxon": "3.7.1",
|
||||
"fuzzysort": "catalog:",
|
||||
"luxon": "catalog:",
|
||||
"marked": "16.2.0",
|
||||
"marked-shiki": "1.2.1",
|
||||
"remeda": "catalog:",
|
||||
|
||||
@@ -35,18 +35,19 @@ class ColorResolver {
|
||||
|
||||
try {
|
||||
if (typeof value === "string") {
|
||||
if (value.startsWith("#") || value === "none") {
|
||||
return { dark: value, light: value }
|
||||
if (value === "none") return { dark: value, light: value }
|
||||
if (value.startsWith("#")) {
|
||||
return { dark: value.toLowerCase(), light: value.toLowerCase() }
|
||||
}
|
||||
const resolved = this.resolveReference(value)
|
||||
return { dark: resolved, light: resolved }
|
||||
}
|
||||
if (typeof value === "object" && value !== null) {
|
||||
const dark = this.resolveColorValue(value.dark || value.light || "#000000")
|
||||
const light = this.resolveColorValue(value.light || value.dark || "#ffffff")
|
||||
const light = this.resolveColorValue(value.light || value.dark || "#FFFFFF")
|
||||
return { dark, light }
|
||||
}
|
||||
return { dark: "#000000", light: "#ffffff" }
|
||||
return { dark: "#000000", light: "#FFFFFF" }
|
||||
} finally {
|
||||
this.visited.delete(key)
|
||||
}
|
||||
@@ -54,8 +55,9 @@ class ColorResolver {
|
||||
|
||||
private resolveColorValue(value: any): string {
|
||||
if (typeof value === "string") {
|
||||
if (value.startsWith("#") || value === "none") {
|
||||
return value
|
||||
if (value === "none") return value
|
||||
if (value.startsWith("#")) {
|
||||
return value.toLowerCase()
|
||||
}
|
||||
return this.resolveReference(value)
|
||||
}
|
||||
@@ -68,8 +70,9 @@ class ColorResolver {
|
||||
throw new Error(`Color reference '${ref}' not found`)
|
||||
}
|
||||
if (typeof colorValue === "string") {
|
||||
if (colorValue.startsWith("#") || colorValue === "none") {
|
||||
return colorValue
|
||||
if (colorValue === "none") return colorValue
|
||||
if (colorValue.startsWith("#")) {
|
||||
return colorValue.toLowerCase()
|
||||
}
|
||||
return this.resolveReference(colorValue)
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 377 B After Width: | Height: | Size: 377 B |
@@ -0,0 +1,381 @@
|
||||
import { For, Match, Show, Switch, createSignal, splitProps } from "solid-js"
|
||||
import { Tabs } from "@/ui/tabs"
|
||||
import { FileIcon, Icon, IconButton, Logo, Tooltip } from "@/ui"
|
||||
import {
|
||||
DragDropProvider,
|
||||
DragDropSensors,
|
||||
DragOverlay,
|
||||
SortableProvider,
|
||||
closestCenter,
|
||||
createSortable,
|
||||
useDragDropContext,
|
||||
} from "@thisbeyond/solid-dnd"
|
||||
import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
|
||||
import type { LocalFile } from "@/context/local"
|
||||
import { Code } from "@/components/code"
|
||||
import PromptForm from "@/components/prompt-form"
|
||||
import { useLocal, useSDK, useSync } from "@/context"
|
||||
import { getFilename } from "@/utils"
|
||||
import type { JSX } from "solid-js"
|
||||
|
||||
interface EditorPaneProps {
|
||||
layoutKey: string
|
||||
timelinePane: string
|
||||
onFileClick: (file: LocalFile) => void
|
||||
onOpenModelSelect: () => void
|
||||
onInputRefChange: (element: HTMLTextAreaElement | null) => void
|
||||
}
|
||||
|
||||
export default function EditorPane(props: EditorPaneProps): JSX.Element {
|
||||
const [localProps] = splitProps(props, [
|
||||
"layoutKey",
|
||||
"timelinePane",
|
||||
"onFileClick",
|
||||
"onOpenModelSelect",
|
||||
"onInputRefChange",
|
||||
])
|
||||
const local = useLocal()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const [activeItem, setActiveItem] = createSignal<string | undefined>(undefined)
|
||||
|
||||
const navigateChange = (dir: 1 | -1) => {
|
||||
const active = local.file.active()
|
||||
if (!active) return
|
||||
const current = local.file.changeIndex(active.path)
|
||||
const next = current === undefined ? (dir === 1 ? 0 : -1) : current + dir
|
||||
local.file.setChangeIndex(active.path, next)
|
||||
}
|
||||
|
||||
const handleTabChange = (path: string) => {
|
||||
local.file.open(path)
|
||||
}
|
||||
|
||||
const handleTabClose = (file: LocalFile) => {
|
||||
local.file.close(file.path)
|
||||
}
|
||||
|
||||
const handlePromptSubmit = async (prompt: string) => {
|
||||
const existingSession = local.layout.visible(localProps.layoutKey, localProps.timelinePane)
|
||||
? local.session.active()
|
||||
: undefined
|
||||
let session = existingSession
|
||||
if (!session) {
|
||||
const created = await sdk.session.create()
|
||||
session = created.data ?? undefined
|
||||
}
|
||||
if (!session) return
|
||||
local.session.setActive(session.id)
|
||||
local.layout.show(localProps.layoutKey, localProps.timelinePane)
|
||||
|
||||
await sdk.session.prompt({
|
||||
path: { id: session.id },
|
||||
body: {
|
||||
agent: local.agent.current()!.name,
|
||||
model: {
|
||||
modelID: local.model.current()!.id,
|
||||
providerID: local.model.current()!.provider.id,
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: prompt,
|
||||
},
|
||||
...(local.context.active()
|
||||
? [
|
||||
{
|
||||
type: "file" as const,
|
||||
mime: "text/plain",
|
||||
url: `file://${local.context.active()!.absolute}`,
|
||||
filename: local.context.active()!.name,
|
||||
source: {
|
||||
type: "file" as const,
|
||||
text: {
|
||||
value: "@" + local.context.active()!.name,
|
||||
start: 0,
|
||||
end: 0,
|
||||
},
|
||||
path: local.context.active()!.absolute,
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...local.context.all().flatMap((file) => [
|
||||
{
|
||||
type: "file" as const,
|
||||
mime: "text/plain",
|
||||
url: `file://${sync.absolute(file.path)}${file.selection ? `?start=${file.selection.startLine}&end=${file.selection.endLine}` : ""}`,
|
||||
filename: getFilename(file.path),
|
||||
source: {
|
||||
type: "file" as const,
|
||||
text: {
|
||||
value: "@" + getFilename(file.path),
|
||||
start: 0,
|
||||
end: 0,
|
||||
},
|
||||
path: sync.absolute(file.path),
|
||||
},
|
||||
},
|
||||
]),
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleDragStart = (event: unknown) => {
|
||||
const id = getDraggableId(event)
|
||||
if (!id) return
|
||||
setActiveItem(id)
|
||||
}
|
||||
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
const { draggable, droppable } = event
|
||||
if (draggable && droppable) {
|
||||
const currentFiles = local.file.opened().map((file) => file.path)
|
||||
const fromIndex = currentFiles.indexOf(draggable.id.toString())
|
||||
const toIndex = currentFiles.indexOf(droppable.id.toString())
|
||||
if (fromIndex !== toIndex) {
|
||||
local.file.move(draggable.id.toString(), toIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setActiveItem(undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="relative flex h-full flex-col">
|
||||
<Logo size={64} variant="ornate" class="absolute top-2/5 left-1/2 transform -translate-x-1/2 -translate-y-1/2" />
|
||||
<DragDropProvider
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
collisionDetector={closestCenter}
|
||||
>
|
||||
<DragDropSensors />
|
||||
<ConstrainDragYAxis />
|
||||
<Tabs
|
||||
class="relative grow w-full flex flex-col h-full"
|
||||
value={local.file.active()?.path}
|
||||
onChange={handleTabChange}
|
||||
>
|
||||
<div class="sticky top-0 shrink-0 flex">
|
||||
<Tabs.List class="grow">
|
||||
<SortableProvider ids={local.file.opened().map((file) => file.path)}>
|
||||
<For each={local.file.opened()}>
|
||||
{(file) => (
|
||||
<SortableTab file={file} onTabClick={localProps.onFileClick} onTabClose={handleTabClose} />
|
||||
)}
|
||||
</For>
|
||||
</SortableProvider>
|
||||
</Tabs.List>
|
||||
<div class="shrink-0 h-full flex items-center gap-1 px-2 border-b border-border-subtle/40">
|
||||
<Show when={local.file.active() && local.file.active()!.content?.diff}>
|
||||
{(() => {
|
||||
const activeFile = local.file.active()!
|
||||
const view = local.file.view(activeFile.path)
|
||||
return (
|
||||
<div class="flex items-center gap-1">
|
||||
<Show when={view !== "raw"}>
|
||||
<div class="mr-1 flex items-center gap-1">
|
||||
<Tooltip value="Previous change" placement="bottom">
|
||||
<IconButton size="xs" variant="ghost" onClick={() => navigateChange(-1)}>
|
||||
<Icon name="arrow-up" size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip value="Next change" placement="bottom">
|
||||
<IconButton size="xs" variant="ghost" onClick={() => navigateChange(1)}>
|
||||
<Icon name="arrow-down" size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Show>
|
||||
<Tooltip value="Raw" placement="bottom">
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
classList={{
|
||||
"text-text": view === "raw",
|
||||
"text-text-muted/70": view !== "raw",
|
||||
"bg-background-element": view === "raw",
|
||||
}}
|
||||
onClick={() => local.file.setView(activeFile.path, "raw")}
|
||||
>
|
||||
<Icon name="file-text" size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip value="Unified diff" placement="bottom">
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
classList={{
|
||||
"text-text": view === "diff-unified",
|
||||
"text-text-muted/70": view !== "diff-unified",
|
||||
"bg-background-element": view === "diff-unified",
|
||||
}}
|
||||
onClick={() => local.file.setView(activeFile.path, "diff-unified")}
|
||||
>
|
||||
<Icon name="checklist" size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip value="Split diff" placement="bottom">
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
classList={{
|
||||
"text-text": view === "diff-split",
|
||||
"text-text-muted/70": view !== "diff-split",
|
||||
"bg-background-element": view === "diff-split",
|
||||
}}
|
||||
onClick={() => local.file.setView(activeFile.path, "diff-split")}
|
||||
>
|
||||
<Icon name="columns" size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</Show>
|
||||
<Tooltip
|
||||
value={local.layout.visible(localProps.layoutKey, localProps.timelinePane) ? "Close pane" : "Open pane"}
|
||||
placement="bottom"
|
||||
>
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={() => local.layout.toggle(localProps.layoutKey, localProps.timelinePane)}
|
||||
>
|
||||
<Icon
|
||||
name={
|
||||
local.layout.visible(localProps.layoutKey, localProps.timelinePane) ? "close-pane" : "open-pane"
|
||||
}
|
||||
size={14}
|
||||
/>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<For each={local.file.opened()}>
|
||||
{(file) => (
|
||||
<Tabs.Content value={file.path} class="grow h-full pt-1 select-text">
|
||||
{(() => {
|
||||
const view = local.file.view(file.path)
|
||||
const showRaw = view === "raw" || !file.content?.diff
|
||||
const code = showRaw ? (file.content?.content ?? "") : (file.content?.diff ?? "")
|
||||
return <Code path={file.path} code={code} class="[&_code]:pb-60" />
|
||||
})()}
|
||||
</Tabs.Content>
|
||||
)}
|
||||
</For>
|
||||
</Tabs>
|
||||
<DragOverlay>
|
||||
{(() => {
|
||||
const id = activeItem()
|
||||
if (!id) return null
|
||||
const draggedFile = local.file.node(id)
|
||||
if (!draggedFile) return null
|
||||
return (
|
||||
<div class="relative px-3 h-8 flex items-center text-sm font-medium text-text whitespace-nowrap shrink-0 bg-background-panel border-x border-border-subtle/40 border-b border-b-transparent">
|
||||
<TabVisual file={draggedFile} />
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
<PromptForm
|
||||
class="peer/editor absolute inset-x-4 z-50 flex items-center justify-center"
|
||||
classList={{
|
||||
"bottom-8": !!local.file.active(),
|
||||
"bottom-3/8": local.file.active() === undefined,
|
||||
}}
|
||||
onSubmit={handlePromptSubmit}
|
||||
onOpenModelSelect={localProps.onOpenModelSelect}
|
||||
onInputRefChange={(element) => localProps.onInputRefChange(element ?? null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TabVisual(props: { file: LocalFile }): JSX.Element {
|
||||
return (
|
||||
<div class="flex items-center gap-x-1.5">
|
||||
<FileIcon node={props.file} class="" />
|
||||
<span classList={{ "text-xs": true, "text-primary": !!props.file.status?.status, italic: !props.file.pinned }}>
|
||||
{props.file.name}
|
||||
</span>
|
||||
<span class="text-xs opacity-70">
|
||||
<Switch>
|
||||
<Match when={props.file.status?.status === "modified"}>
|
||||
<span class="text-primary">M</span>
|
||||
</Match>
|
||||
<Match when={props.file.status?.status === "added"}>
|
||||
<span class="text-success">A</span>
|
||||
</Match>
|
||||
<Match when={props.file.status?.status === "deleted"}>
|
||||
<span class="text-error">D</span>
|
||||
</Match>
|
||||
</Switch>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SortableTab(props: {
|
||||
file: LocalFile
|
||||
onTabClick: (file: LocalFile) => void
|
||||
onTabClose: (file: LocalFile) => void
|
||||
}): JSX.Element {
|
||||
const sortable = createSortable(props.file.path)
|
||||
|
||||
return (
|
||||
// @ts-ignore
|
||||
<div use:sortable classList={{ "opacity-0": sortable.isActiveDraggable }}>
|
||||
<Tooltip value={props.file.path} placement="bottom">
|
||||
<div class="relative">
|
||||
<Tabs.Trigger value={props.file.path} class="peer/tab pr-7" onClick={() => props.onTabClick(props.file)}>
|
||||
<TabVisual file={props.file} />
|
||||
</Tabs.Trigger>
|
||||
<IconButton
|
||||
class="absolute right-1 top-1.5 opacity-0 text-text-muted/60 peer-data-[selected]/tab:opacity-100 peer-data-[selected]/tab:text-text peer-data-[selected]/tab:hover:bg-border-subtle hover:opacity-100 peer-hover/tab:opacity-100"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={() => props.onTabClose(props.file)}
|
||||
>
|
||||
<Icon name="close" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConstrainDragYAxis(): JSX.Element {
|
||||
const context = useDragDropContext()
|
||||
if (!context) return <></>
|
||||
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
|
||||
const transformer: Transformer = {
|
||||
id: "constrain-y-axis",
|
||||
order: 100,
|
||||
callback: (transform) => ({ ...transform, y: 0 }),
|
||||
}
|
||||
onDragStart((event) => {
|
||||
const id = getDraggableId(event)
|
||||
if (!id) return
|
||||
addTransformer("draggables", id, transformer)
|
||||
})
|
||||
onDragEnd((event) => {
|
||||
const id = getDraggableId(event)
|
||||
if (!id) return
|
||||
removeTransformer("draggables", id, transformer.id)
|
||||
})
|
||||
return <></>
|
||||
}
|
||||
|
||||
const getDraggableId = (event: unknown): string | undefined => {
|
||||
if (typeof event !== "object" || event === null) return undefined
|
||||
if (!("draggable" in event)) return undefined
|
||||
const draggable = (event as { draggable?: { id?: unknown } }).draggable
|
||||
if (!draggable) return undefined
|
||||
return typeof draggable.id === "string" ? draggable.id : undefined
|
||||
}
|
||||
@@ -18,11 +18,35 @@ export default function FileTree(props: {
|
||||
<Dynamic
|
||||
component={p.as ?? "div"}
|
||||
classList={{
|
||||
"p-0.5 w-full flex items-center gap-x-2 hover:bg-background-panel cursor-pointer": true,
|
||||
"p-0.5 w-full flex items-center gap-x-2 hover:bg-background-element cursor-pointer": true,
|
||||
"bg-background-element": local.file.active()?.path === p.node.path,
|
||||
[props.nodeClass ?? ""]: !!props.nodeClass,
|
||||
}}
|
||||
style={`padding-left: ${level * 10}px`}
|
||||
draggable={true}
|
||||
onDragStart={(e: any) => {
|
||||
const evt = e as globalThis.DragEvent
|
||||
evt.dataTransfer!.effectAllowed = "copy"
|
||||
evt.dataTransfer!.setData("text/plain", `file:${p.node.path}`)
|
||||
|
||||
// Create custom drag image without margins
|
||||
const dragImage = document.createElement("div")
|
||||
dragImage.className =
|
||||
"flex items-center gap-x-2 px-2 py-1 bg-background-element rounded-md border border-border-1"
|
||||
dragImage.style.position = "absolute"
|
||||
dragImage.style.top = "-1000px"
|
||||
|
||||
// Copy only the icon and text content without padding
|
||||
const icon = e.currentTarget.querySelector("svg")
|
||||
const text = e.currentTarget.querySelector("span")
|
||||
if (icon && text) {
|
||||
dragImage.innerHTML = icon.outerHTML + text.outerHTML
|
||||
}
|
||||
|
||||
document.body.appendChild(dragImage)
|
||||
evt.dataTransfer!.setDragImage(dragImage, 0, 12)
|
||||
setTimeout(() => document.body.removeChild(dragImage), 0)
|
||||
}}
|
||||
{...p}
|
||||
>
|
||||
{p.children}
|
||||
@@ -51,6 +75,7 @@ export default function FileTree(props: {
|
||||
<Switch>
|
||||
<Match when={node.type === "directory"}>
|
||||
<Collapsible
|
||||
class="w-full"
|
||||
forceMount={false}
|
||||
open={local.file.node(node.path)?.expanded}
|
||||
onOpenChange={(open) => (open ? local.file.expand(node.path) : local.file.collapse(node.path))}
|
||||
|
||||
@@ -1,595 +1,17 @@
|
||||
import { transformerNotationDiff } from "@shikijs/transformers"
|
||||
import { marked } from "marked"
|
||||
import markedShiki from "marked-shiki"
|
||||
import { codeToHtml } from "shiki"
|
||||
import { useMarked } from "@/context"
|
||||
import { createResource } from "solid-js"
|
||||
|
||||
const markedWithShiki = marked.use(
|
||||
markedShiki({
|
||||
highlight(code, lang) {
|
||||
return codeToHtml(code, {
|
||||
// structure: "inline",
|
||||
lang: lang || "text",
|
||||
tabindex: false,
|
||||
theme: {
|
||||
colors: {
|
||||
"actionBar.toggledBackground": "var(--theme-background-element)",
|
||||
"activityBarBadge.background": "var(--theme-accent)",
|
||||
"checkbox.border": "var(--theme-border)",
|
||||
"editor.background": "transparent",
|
||||
"editor.foreground": "var(--theme-text)",
|
||||
"editor.inactiveSelectionBackground": "var(--theme-background-element)",
|
||||
"editor.selectionHighlightBackground": "var(--theme-border-active)",
|
||||
"editorIndentGuide.activeBackground1": "var(--theme-border-subtle)",
|
||||
"editorIndentGuide.background1": "var(--theme-border-subtle)",
|
||||
"input.placeholderForeground": "var(--theme-text-muted)",
|
||||
"list.activeSelectionIconForeground": "var(--theme-text)",
|
||||
"list.dropBackground": "var(--theme-background-element)",
|
||||
"menu.background": "var(--theme-background-panel)",
|
||||
"menu.border": "var(--theme-border)",
|
||||
"menu.foreground": "var(--theme-text)",
|
||||
"menu.selectionBackground": "var(--theme-primary)",
|
||||
"menu.separatorBackground": "var(--theme-border)",
|
||||
"ports.iconRunningProcessForeground": "var(--theme-success)",
|
||||
"sideBarSectionHeader.background": "transparent",
|
||||
"sideBarSectionHeader.border": "var(--theme-border-subtle)",
|
||||
"sideBarTitle.foreground": "var(--theme-text-muted)",
|
||||
"statusBarItem.remoteBackground": "var(--theme-success)",
|
||||
"statusBarItem.remoteForeground": "var(--theme-text)",
|
||||
"tab.lastPinnedBorder": "var(--theme-border-subtle)",
|
||||
"tab.selectedBackground": "var(--theme-background-element)",
|
||||
"tab.selectedForeground": "var(--theme-text-muted)",
|
||||
"terminal.inactiveSelectionBackground": "var(--theme-background-element)",
|
||||
"widget.border": "var(--theme-border)",
|
||||
},
|
||||
displayName: "opencode",
|
||||
name: "opencode",
|
||||
semanticHighlighting: true,
|
||||
semanticTokenColors: {
|
||||
customLiteral: "var(--theme-syntax-function)",
|
||||
newOperator: "var(--theme-syntax-operator)",
|
||||
numberLiteral: "var(--theme-syntax-number)",
|
||||
stringLiteral: "var(--theme-syntax-string)",
|
||||
},
|
||||
tokenColors: [
|
||||
{
|
||||
scope: [
|
||||
"meta.embedded",
|
||||
"source.groovy.embedded",
|
||||
"string meta.image.inline.markdown",
|
||||
"variable.legacy.builtin.python",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-text)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "emphasis",
|
||||
settings: {
|
||||
fontStyle: "italic",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "strong",
|
||||
settings: {
|
||||
fontStyle: "bold",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "header",
|
||||
settings: {
|
||||
foreground: "var(--theme-markdown-heading)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "comment",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-comment)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "constant.language",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"constant.numeric",
|
||||
"variable.other.enummember",
|
||||
"keyword.operator.plus.exponent",
|
||||
"keyword.operator.minus.exponent",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-number)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "constant.regexp",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "entity.name.tag",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["entity.name.tag.css", "entity.name.tag.less"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "entity.other.attribute-name",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-variable)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"entity.other.attribute-name.class.css",
|
||||
"source.css entity.other.attribute-name.class",
|
||||
"entity.other.attribute-name.id.css",
|
||||
"entity.other.attribute-name.parent-selector.css",
|
||||
"entity.other.attribute-name.parent.less",
|
||||
"source.css entity.other.attribute-name.pseudo-class",
|
||||
"entity.other.attribute-name.pseudo-element.css",
|
||||
"source.css.less entity.other.attribute-name.id",
|
||||
"entity.other.attribute-name.scss",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "invalid",
|
||||
settings: {
|
||||
foreground: "var(--theme-error)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.underline",
|
||||
settings: {
|
||||
fontStyle: "underline",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.bold",
|
||||
settings: {
|
||||
fontStyle: "bold",
|
||||
foreground: "var(--theme-markdown-strong)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.heading",
|
||||
settings: {
|
||||
fontStyle: "bold",
|
||||
foreground: "var(--theme-markdown-heading)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.italic",
|
||||
settings: {
|
||||
fontStyle: "italic",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.strikethrough",
|
||||
settings: {
|
||||
fontStyle: "strikethrough",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.inserted",
|
||||
settings: {
|
||||
foreground: "var(--theme-diff-added)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.deleted",
|
||||
settings: {
|
||||
foreground: "var(--theme-diff-removed)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.changed",
|
||||
settings: {
|
||||
foreground: "var(--theme-diff-context)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "punctuation.definition.quote.begin.markdown",
|
||||
settings: {
|
||||
foreground: "var(--theme-markdown-block-quote)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "punctuation.definition.list.begin.markdown",
|
||||
settings: {
|
||||
foreground: "var(--theme-markdown-list-enumeration)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.inline.raw",
|
||||
settings: {
|
||||
foreground: "var(--theme-markdown-code)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "punctuation.definition.tag",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-punctuation)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["meta.preprocessor", "entity.name.function.preprocessor"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "meta.preprocessor.string",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-string)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "meta.preprocessor.numeric",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-number)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "meta.structure.dictionary.key.python",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-variable)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "meta.diff.header",
|
||||
settings: {
|
||||
foreground: "var(--theme-diff-hunk-header)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "storage",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "storage.type",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["storage.modifier", "keyword.operator.noexcept"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["string", "meta.embedded.assembly"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-string)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "string.tag",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-string)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "string.value",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-string)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "string.regexp",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"punctuation.definition.template-expression.begin",
|
||||
"punctuation.definition.template-expression.end",
|
||||
"punctuation.section.embedded",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["meta.template.expression"],
|
||||
settings: {
|
||||
foreground: "var(--theme-text)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"support.type.vendored.property-name",
|
||||
"support.type.property-name",
|
||||
"source.css variable",
|
||||
"source.coffee.embedded",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-variable)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "keyword",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "keyword.control",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "keyword.operator",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"keyword.operator.new",
|
||||
"keyword.operator.expression",
|
||||
"keyword.operator.cast",
|
||||
"keyword.operator.sizeof",
|
||||
"keyword.operator.alignof",
|
||||
"keyword.operator.typeid",
|
||||
"keyword.operator.alignas",
|
||||
"keyword.operator.instanceof",
|
||||
"keyword.operator.logical.python",
|
||||
"keyword.operator.wordlike",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "keyword.other.unit",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-number)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["punctuation.section.embedded.begin.php", "punctuation.section.embedded.end.php"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "support.function.git-rebase",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-variable)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "constant.sha.git-rebase",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-number)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"storage.modifier.import.java",
|
||||
"variable.language.wildcard.java",
|
||||
"storage.modifier.package.java",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-text)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "variable.language",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"entity.name.function",
|
||||
"support.function",
|
||||
"support.constant.handlebars",
|
||||
"source.powershell variable.other.member",
|
||||
"entity.name.operator.custom-literal",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-function)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"support.class",
|
||||
"support.type",
|
||||
"entity.name.type",
|
||||
"entity.name.namespace",
|
||||
"entity.other.attribute",
|
||||
"entity.name.scope-resolution",
|
||||
"entity.name.class",
|
||||
"storage.type.numeric.go",
|
||||
"storage.type.byte.go",
|
||||
"storage.type.boolean.go",
|
||||
"storage.type.string.go",
|
||||
"storage.type.uintptr.go",
|
||||
"storage.type.error.go",
|
||||
"storage.type.rune.go",
|
||||
"storage.type.cs",
|
||||
"storage.type.generic.cs",
|
||||
"storage.type.modifier.cs",
|
||||
"storage.type.variable.cs",
|
||||
"storage.type.annotation.java",
|
||||
"storage.type.generic.java",
|
||||
"storage.type.java",
|
||||
"storage.type.object.array.java",
|
||||
"storage.type.primitive.array.java",
|
||||
"storage.type.primitive.java",
|
||||
"storage.type.token.java",
|
||||
"storage.type.groovy",
|
||||
"storage.type.annotation.groovy",
|
||||
"storage.type.parameters.groovy",
|
||||
"storage.type.generic.groovy",
|
||||
"storage.type.object.array.groovy",
|
||||
"storage.type.primitive.array.groovy",
|
||||
"storage.type.primitive.groovy",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-type)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"meta.type.cast.expr",
|
||||
"meta.type.new.expr",
|
||||
"support.constant.math",
|
||||
"support.constant.dom",
|
||||
"support.constant.json",
|
||||
"entity.other.inherited-class",
|
||||
"punctuation.separator.namespace.ruby",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-type)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"keyword.control",
|
||||
"source.cpp keyword.operator.new",
|
||||
"keyword.operator.delete",
|
||||
"keyword.other.using",
|
||||
"keyword.other.directive.using",
|
||||
"keyword.other.operator",
|
||||
"entity.name.operator",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"variable",
|
||||
"meta.definition.variable.name",
|
||||
"support.variable",
|
||||
"entity.name.variable",
|
||||
"constant.other.placeholder",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-variable)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["variable.other.constant", "variable.other.enummember"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-variable)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["meta.object-literal.key"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-variable)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"support.constant.property-value",
|
||||
"support.constant.font-name",
|
||||
"support.constant.media-type",
|
||||
"support.constant.media",
|
||||
"constant.other.color.rgb-value",
|
||||
"constant.other.rgb-value",
|
||||
"support.constant.color",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-string)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"punctuation.definition.group.regexp",
|
||||
"punctuation.definition.group.assertion.regexp",
|
||||
"punctuation.definition.character-class.regexp",
|
||||
"punctuation.character.set.begin.regexp",
|
||||
"punctuation.character.set.end.regexp",
|
||||
"keyword.operator.negation.regexp",
|
||||
"support.other.parenthesis.regexp",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-string)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"constant.character.character-class.regexp",
|
||||
"constant.other.character-class.set.regexp",
|
||||
"constant.other.character-class.regexp",
|
||||
"constant.character.set.regexp",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["keyword.operator.or.regexp", "keyword.control.anchor.regexp"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "keyword.operator.quantifier.regexp",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["constant.character", "constant.other.option"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "constant.character.escape",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "entity.name.label",
|
||||
settings: {
|
||||
foreground: "var(--theme-text-muted)",
|
||||
},
|
||||
},
|
||||
],
|
||||
type: "dark",
|
||||
},
|
||||
transformers: [transformerNotationDiff()],
|
||||
})
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
function strip(text: string): string {
|
||||
const wrappedRe = /^\s*<([A-Za-z]\w*)>\s*([\s\S]*?)\s*<\/\1>\s*$/
|
||||
const match = text.match(wrappedRe)
|
||||
return match ? match[2] : text
|
||||
}
|
||||
|
||||
export default function Markdown(props: { text: string; class?: string }) {
|
||||
export function Markdown(props: { text: string; class?: string }) {
|
||||
const marked = useMarked()
|
||||
const [html] = createResource(
|
||||
() => strip(props.text),
|
||||
async (markdown) => {
|
||||
return markedWithShiki.parse(markdown)
|
||||
return marked.parse(markdown)
|
||||
},
|
||||
)
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { Button, FileIcon, Icon, IconButton, Tooltip } from "@/ui"
|
||||
import { Select } from "@/components/select"
|
||||
import { useLocal } from "@/context"
|
||||
import type { FileContext, LocalFile } from "@/context/local"
|
||||
import { getFilename } from "@/utils"
|
||||
import { createSpeechRecognition } from "@/utils/speech"
|
||||
|
||||
interface PromptFormProps {
|
||||
class?: string
|
||||
classList?: Record<string, boolean>
|
||||
onSubmit: (prompt: string) => Promise<void> | void
|
||||
onOpenModelSelect: () => void
|
||||
onInputRefChange?: (element: HTMLTextAreaElement | undefined) => void
|
||||
}
|
||||
|
||||
export default function PromptForm(props: PromptFormProps) {
|
||||
const local = useLocal()
|
||||
|
||||
const [prompt, setPrompt] = createSignal("")
|
||||
const [isDragOver, setIsDragOver] = createSignal(false)
|
||||
|
||||
const placeholderText = "Start typing or speaking..."
|
||||
|
||||
const {
|
||||
isSupported,
|
||||
isRecording,
|
||||
interim: interimTranscript,
|
||||
start: startSpeech,
|
||||
stop: stopSpeech,
|
||||
} = createSpeechRecognition({
|
||||
onFinal: (text) => setPrompt((prev) => (prev && !prev.endsWith(" ") ? prev + " " : prev) + text),
|
||||
})
|
||||
|
||||
let inputRef: HTMLTextAreaElement | undefined = undefined
|
||||
let overlayContainerRef: HTMLDivElement | undefined = undefined
|
||||
let shouldAutoScroll = true
|
||||
|
||||
const promptContent = createMemo(() => {
|
||||
const base = prompt() || ""
|
||||
const interim = isRecording() ? interimTranscript() : ""
|
||||
if (!base && !interim) {
|
||||
return <span class="text-text-muted/70">{placeholderText}</span>
|
||||
}
|
||||
const needsSpace = base && interim && !base.endsWith(" ") && !interim.startsWith(" ")
|
||||
return (
|
||||
<>
|
||||
<span class="text-text">{base}</span>
|
||||
{interim && (
|
||||
<span class="text-text-muted/60 italic">
|
||||
{needsSpace ? " " : ""}
|
||||
{interim}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
prompt()
|
||||
interimTranscript()
|
||||
queueMicrotask(() => {
|
||||
if (!inputRef) return
|
||||
if (!overlayContainerRef) return
|
||||
if (!shouldAutoScroll) {
|
||||
overlayContainerRef.scrollTop = inputRef.scrollTop
|
||||
return
|
||||
}
|
||||
scrollPromptToEnd()
|
||||
})
|
||||
})
|
||||
|
||||
const handlePromptKeyDown = (event: KeyboardEvent & { currentTarget: HTMLTextAreaElement }) => {
|
||||
if (event.isComposing) return
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
inputRef?.form?.requestSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
const handlePromptScroll = (event: Event & { currentTarget: HTMLTextAreaElement }) => {
|
||||
const target = event.currentTarget
|
||||
shouldAutoScroll = target.scrollTop + target.clientHeight >= target.scrollHeight - 4
|
||||
if (overlayContainerRef) overlayContainerRef.scrollTop = target.scrollTop
|
||||
}
|
||||
|
||||
const scrollPromptToEnd = () => {
|
||||
if (!inputRef) return
|
||||
const maxInputScroll = inputRef.scrollHeight - inputRef.clientHeight
|
||||
const next = maxInputScroll > 0 ? maxInputScroll : 0
|
||||
inputRef.scrollTop = next
|
||||
if (overlayContainerRef) overlayContainerRef.scrollTop = next
|
||||
shouldAutoScroll = true
|
||||
}
|
||||
|
||||
const handleSubmit = async (event: SubmitEvent) => {
|
||||
event.preventDefault()
|
||||
const currentPrompt = prompt()
|
||||
setPrompt("")
|
||||
shouldAutoScroll = true
|
||||
if (overlayContainerRef) overlayContainerRef.scrollTop = 0
|
||||
if (inputRef) {
|
||||
inputRef.scrollTop = 0
|
||||
inputRef.blur()
|
||||
}
|
||||
|
||||
await props.onSubmit(currentPrompt)
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
props.onInputRefChange?.(undefined)
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} class={props.class} classList={props.classList}>
|
||||
<div
|
||||
class="w-full max-w-xl min-w-0 p-2 mx-auto rounded-lg isolate backdrop-blur-xs
|
||||
flex flex-col gap-1
|
||||
bg-gradient-to-b from-background-panel/90 to-background/90
|
||||
ring-1 ring-border-active/50 border border-transparent
|
||||
focus-within:ring-2 focus-within:ring-primary/40 focus-within:border-primary
|
||||
transition-all duration-200"
|
||||
classList={{
|
||||
"shadow-[0_0_33px_rgba(0,0,0,0.8)]": !!local.file.active(),
|
||||
"ring-2 ring-primary/60 bg-primary/5": isDragOver(),
|
||||
}}
|
||||
onDragEnter={(event) => {
|
||||
const evt = event as unknown as globalThis.DragEvent
|
||||
if (evt.dataTransfer?.types.includes("text/plain")) {
|
||||
evt.preventDefault()
|
||||
setIsDragOver(true)
|
||||
}
|
||||
}}
|
||||
onDragLeave={(event) => {
|
||||
if (event.currentTarget === event.target) {
|
||||
setIsDragOver(false)
|
||||
}
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
const evt = event as unknown as globalThis.DragEvent
|
||||
if (evt.dataTransfer?.types.includes("text/plain")) {
|
||||
evt.preventDefault()
|
||||
evt.dataTransfer.dropEffect = "copy"
|
||||
}
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
const evt = event as unknown as globalThis.DragEvent
|
||||
evt.preventDefault()
|
||||
setIsDragOver(false)
|
||||
|
||||
const data = evt.dataTransfer?.getData("text/plain")
|
||||
if (data && data.startsWith("file:")) {
|
||||
const filePath = data.slice(5)
|
||||
const fileNode = local.file.node(filePath)
|
||||
if (fileNode) {
|
||||
local.context.add({
|
||||
type: "file",
|
||||
path: filePath,
|
||||
})
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Show when={local.context.all().length > 0 || local.context.active()}>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Show when={local.context.active()}>
|
||||
<ActiveTabContextTag file={local.context.active()!} onClose={() => local.context.removeActive()} />
|
||||
</Show>
|
||||
<For each={local.context.all()}>
|
||||
{(file) => <FileTag file={file} onClose={() => local.context.remove(file.key)} />}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="relative">
|
||||
<textarea
|
||||
ref={(element) => {
|
||||
inputRef = element ?? undefined
|
||||
props.onInputRefChange?.(inputRef)
|
||||
}}
|
||||
value={prompt()}
|
||||
onInput={(event) => setPrompt(event.currentTarget.value)}
|
||||
onKeyDown={handlePromptKeyDown}
|
||||
onScroll={handlePromptScroll}
|
||||
placeholder={placeholderText}
|
||||
autocapitalize="off"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
spellcheck={false}
|
||||
class="relative w-full h-20 rounded-md px-0.5 resize-none overflow-y-auto
|
||||
bg-transparent text-transparent caret-text font-light text-base
|
||||
leading-relaxed focus:outline-none selection:bg-primary/20"
|
||||
></textarea>
|
||||
<div
|
||||
ref={(element) => {
|
||||
overlayContainerRef = element ?? undefined
|
||||
}}
|
||||
class="pointer-events-none absolute inset-0 overflow-hidden"
|
||||
>
|
||||
<div class="px-0.5 text-base font-light leading-relaxed whitespace-pre-wrap text-left text-text">
|
||||
{promptContent()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between items-center text-xs text-text-muted">
|
||||
<div class="flex gap-2 items-center">
|
||||
<Select
|
||||
options={local.agent.list().map((agent) => agent.name)}
|
||||
current={local.agent.current().name}
|
||||
onSelect={local.agent.set}
|
||||
class="uppercase"
|
||||
/>
|
||||
<Button onClick={() => props.onOpenModelSelect()}>
|
||||
{local.model.current()?.name ?? "Select model"}
|
||||
<Icon name="chevron-down" size={24} class="text-text-muted" />
|
||||
</Button>
|
||||
<span class="text-text-muted/70 whitespace-nowrap">{local.model.current()?.provider.name}</span>
|
||||
</div>
|
||||
<div class="flex gap-1 items-center">
|
||||
<Show when={isSupported()}>
|
||||
<Tooltip value={isRecording() ? "Stop voice input" : "Start voice input"} placement="top">
|
||||
<IconButton
|
||||
onClick={async (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
if (isRecording()) {
|
||||
stopSpeech()
|
||||
} else {
|
||||
startSpeech()
|
||||
}
|
||||
inputRef?.focus()
|
||||
}}
|
||||
classList={{
|
||||
"text-text-muted": !isRecording(),
|
||||
"text-error! animate-pulse": isRecording(),
|
||||
}}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Icon name="mic" size={16} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<IconButton class="text-text-muted" size="xs" variant="ghost">
|
||||
<Icon name="photo" size={16} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
class="text-background-panel! bg-primary rounded-full! hover:bg-primary/90 ml-0.5"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
type="submit"
|
||||
>
|
||||
<Icon name="arrow-up" size={14} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
const ActiveTabContextTag = (props: { file: LocalFile; onClose: () => void }) => (
|
||||
<div
|
||||
class="flex items-center bg-background group/tag
|
||||
border border-border-subtle/60 border-dashed
|
||||
rounded-md text-xs text-text-muted"
|
||||
>
|
||||
<IconButton class="text-text-muted" size="xs" variant="ghost" onClick={props.onClose}>
|
||||
<Icon name="file" class="group-hover/tag:hidden" size={12} />
|
||||
<Icon name="close" class="hidden group-hover/tag:block" size={12} />
|
||||
</IconButton>
|
||||
<div class="pr-1 flex gap-1 items-center">
|
||||
<span>{getFilename(props.file.path)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const FileTag = (props: { file: FileContext; onClose: () => void }) => (
|
||||
<div
|
||||
class="flex items-center bg-background group/tag
|
||||
border border-border-subtle/60
|
||||
rounded-md text-xs text-text-muted"
|
||||
>
|
||||
<IconButton class="text-text-muted" size="xs" variant="ghost" onClick={props.onClose}>
|
||||
<FileIcon node={props.file} class="group-hover/tag:hidden size-3!" />
|
||||
<Icon name="close" class="hidden group-hover/tag:block" size={12} />
|
||||
</IconButton>
|
||||
<div class="pr-1 flex gap-1 items-center">
|
||||
<span>{getFilename(props.file.path)}</span>
|
||||
<Show when={props.file.selection}>
|
||||
<span>
|
||||
({props.file.selection!.startLine}-{props.file.selection!.endLine})
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,217 @@
|
||||
import { batch, createContext, createMemo, createSignal, onCleanup, Show, useContext } from "solid-js"
|
||||
import type { ComponentProps, JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLocal } from "@/context"
|
||||
|
||||
type PaneDefault = number | { size: number; visible?: boolean }
|
||||
|
||||
type LayoutContextValue = {
|
||||
id: string
|
||||
register: (pane: string, options: { min?: number | string; max?: number | string }) => void
|
||||
size: (pane: string) => number
|
||||
visible: (pane: string) => boolean
|
||||
percent: (pane: string) => number
|
||||
next: (pane: string) => string | undefined
|
||||
startDrag: (left: string, right: string | undefined, event: MouseEvent) => void
|
||||
dragging: () => string | undefined
|
||||
}
|
||||
|
||||
const LayoutContext = createContext<LayoutContextValue | undefined>(undefined)
|
||||
|
||||
export interface ResizeableLayoutProps {
|
||||
id: string
|
||||
defaults: Record<string, PaneDefault>
|
||||
class?: ComponentProps<"div">["class"]
|
||||
classList?: ComponentProps<"div">["classList"]
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
export interface ResizeablePaneProps {
|
||||
id: string
|
||||
minSize?: number | string
|
||||
maxSize?: number | string
|
||||
class?: ComponentProps<"div">["class"]
|
||||
classList?: ComponentProps<"div">["classList"]
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
export function ResizeableLayout(props: ResizeableLayoutProps) {
|
||||
const local = useLocal()
|
||||
const [meta, setMeta] = createStore<Record<string, { min: number; max: number; minPx?: number; maxPx?: number }>>({})
|
||||
const [dragging, setDragging] = createSignal<string>()
|
||||
let container: HTMLDivElement | undefined
|
||||
|
||||
local.layout.ensure(props.id, props.defaults)
|
||||
|
||||
const order = createMemo(() => local.layout.order(props.id))
|
||||
const visibleOrder = createMemo(() => order().filter((pane) => local.layout.visible(props.id, pane)))
|
||||
const totalVisible = createMemo(() => {
|
||||
const panes = visibleOrder()
|
||||
if (!panes.length) return 0
|
||||
return panes.reduce((total, pane) => total + local.layout.size(props.id, pane), 0)
|
||||
})
|
||||
|
||||
const percent = (pane: string) => {
|
||||
const panes = visibleOrder()
|
||||
if (!panes.length) return 0
|
||||
const total = totalVisible()
|
||||
if (!total) return 100 / panes.length
|
||||
return (local.layout.size(props.id, pane) / total) * 100
|
||||
}
|
||||
|
||||
const nextPane = (pane: string) => {
|
||||
const panes = visibleOrder()
|
||||
const index = panes.indexOf(pane)
|
||||
if (index === -1) return undefined
|
||||
return panes[index + 1]
|
||||
}
|
||||
|
||||
const minMax = (pane: string) => meta[pane] ?? { min: 5, max: 95 }
|
||||
|
||||
const pxToPercent = (px: number, total: number) => (px / total) * 100
|
||||
|
||||
const boundsForPair = (left: string, right: string, total: number) => {
|
||||
const leftMeta = minMax(left)
|
||||
const rightMeta = minMax(right)
|
||||
const containerWidth = container?.getBoundingClientRect().width ?? 0
|
||||
|
||||
let minLeft = leftMeta.min
|
||||
let maxLeft = leftMeta.max
|
||||
let minRight = rightMeta.min
|
||||
let maxRight = rightMeta.max
|
||||
|
||||
if (containerWidth && leftMeta.minPx !== undefined) minLeft = pxToPercent(leftMeta.minPx, containerWidth)
|
||||
if (containerWidth && leftMeta.maxPx !== undefined) maxLeft = pxToPercent(leftMeta.maxPx, containerWidth)
|
||||
if (containerWidth && rightMeta.minPx !== undefined) minRight = pxToPercent(rightMeta.minPx, containerWidth)
|
||||
if (containerWidth && rightMeta.maxPx !== undefined) maxRight = pxToPercent(rightMeta.maxPx, containerWidth)
|
||||
|
||||
const finalMinLeft = Math.max(minLeft, total - maxRight)
|
||||
const finalMaxLeft = Math.min(maxLeft, total - minRight)
|
||||
return {
|
||||
min: Math.min(finalMinLeft, finalMaxLeft),
|
||||
max: Math.max(finalMinLeft, finalMaxLeft),
|
||||
}
|
||||
}
|
||||
|
||||
const setPair = (left: string, right: string, leftSize: number, rightSize: number) => {
|
||||
batch(() => {
|
||||
local.layout.setSize(props.id, left, leftSize)
|
||||
local.layout.setSize(props.id, right, rightSize)
|
||||
})
|
||||
}
|
||||
|
||||
const startDrag = (left: string, right: string | undefined, event: MouseEvent) => {
|
||||
if (!right) return
|
||||
if (!container) return
|
||||
const rect = container.getBoundingClientRect()
|
||||
if (!rect.width) return
|
||||
event.preventDefault()
|
||||
const startX = event.clientX
|
||||
const startLeft = local.layout.size(props.id, left)
|
||||
const startRight = local.layout.size(props.id, right)
|
||||
const total = startLeft + startRight
|
||||
const bounds = boundsForPair(left, right, total)
|
||||
const move = (moveEvent: MouseEvent) => {
|
||||
const delta = ((moveEvent.clientX - startX) / rect.width) * 100
|
||||
const nextLeft = Math.max(bounds.min, Math.min(bounds.max, startLeft + delta))
|
||||
const nextRight = total - nextLeft
|
||||
setPair(left, right, nextLeft, nextRight)
|
||||
}
|
||||
const stop = () => {
|
||||
setDragging()
|
||||
document.removeEventListener("mousemove", move)
|
||||
document.removeEventListener("mouseup", stop)
|
||||
}
|
||||
setDragging(left)
|
||||
document.addEventListener("mousemove", move)
|
||||
document.addEventListener("mouseup", stop)
|
||||
onCleanup(() => stop())
|
||||
}
|
||||
|
||||
const register = (pane: string, options: { min?: number | string; max?: number | string }) => {
|
||||
let min = 5
|
||||
let max = 95
|
||||
let minPx: number | undefined
|
||||
let maxPx: number | undefined
|
||||
|
||||
if (typeof options.min === "string" && options.min.endsWith("px")) {
|
||||
minPx = parseInt(options.min)
|
||||
min = 0
|
||||
} else if (typeof options.min === "number") {
|
||||
min = options.min
|
||||
}
|
||||
|
||||
if (typeof options.max === "string" && options.max.endsWith("px")) {
|
||||
maxPx = parseInt(options.max)
|
||||
max = 100
|
||||
} else if (typeof options.max === "number") {
|
||||
max = options.max
|
||||
}
|
||||
|
||||
setMeta(pane, () => ({ min, max, minPx, maxPx }))
|
||||
const fallback = props.defaults[pane]
|
||||
local.layout.ensurePane(props.id, pane, fallback ?? { size: min, visible: true })
|
||||
}
|
||||
|
||||
const contextValue: LayoutContextValue = {
|
||||
id: props.id,
|
||||
register,
|
||||
size: (pane) => local.layout.size(props.id, pane),
|
||||
visible: (pane) => local.layout.visible(props.id, pane),
|
||||
percent,
|
||||
next: nextPane,
|
||||
startDrag,
|
||||
dragging,
|
||||
}
|
||||
|
||||
return (
|
||||
<LayoutContext.Provider value={contextValue}>
|
||||
<div
|
||||
ref={(node) => {
|
||||
container = node ?? undefined
|
||||
}}
|
||||
class={props.class ? `relative flex h-full w-full ${props.class}` : "relative flex h-full w-full"}
|
||||
classList={props.classList}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</LayoutContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function ResizeablePane(props: ResizeablePaneProps) {
|
||||
const context = useContext(LayoutContext)!
|
||||
context.register(props.id, { min: props.minSize, max: props.maxSize })
|
||||
const visible = () => context.visible(props.id)
|
||||
const width = () => context.percent(props.id)
|
||||
const next = () => context.next(props.id)
|
||||
const dragging = () => context.dragging() === props.id
|
||||
|
||||
return (
|
||||
<Show when={visible()}>
|
||||
<div
|
||||
class={props.class ? `relative flex h-full flex-col ${props.class}` : "relative flex h-full flex-col"}
|
||||
classList={props.classList}
|
||||
style={{
|
||||
width: `${width()}%`,
|
||||
flex: `0 0 ${width()}%`,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
<Show when={next()}>
|
||||
<div
|
||||
class="absolute top-0 -right-1 h-full w-1.5 cursor-col-resize z-50 group"
|
||||
onMouseDown={(event) => context.startDrag(props.id, next(), event)}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"w-0.5 h-full bg-transparent transition-colors group-hover:bg-border-active": true,
|
||||
"bg-border-active!": dragging(),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { createEffect, Show, For, createMemo, type JSX, createResource } from "solid-js"
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import { Icon, IconButton } from "@/ui"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { entries, flatMap, groupBy, map, pipe } from "remeda"
|
||||
import { createList } from "solid-list"
|
||||
import fuzzysort from "fuzzysort"
|
||||
|
||||
interface SelectDialogProps<T> {
|
||||
items: T[] | ((filter: string) => Promise<T[]>)
|
||||
key: (item: T) => string
|
||||
render: (item: T) => JSX.Element
|
||||
filter?: string[]
|
||||
current?: T
|
||||
placeholder?: string
|
||||
groupBy?: (x: T) => string
|
||||
onSelect?: (value: T | undefined) => void
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
export function SelectDialog<T>(props: SelectDialogProps<T>) {
|
||||
let scrollRef: HTMLDivElement | undefined
|
||||
const [store, setStore] = createStore({
|
||||
filter: "",
|
||||
mouseActive: false,
|
||||
})
|
||||
|
||||
const [grouped] = createResource(
|
||||
() => store.filter,
|
||||
async (filter) => {
|
||||
const needle = filter.toLowerCase()
|
||||
const all = (typeof props.items === "function" ? await props.items(needle) : props.items) || []
|
||||
const result = pipe(
|
||||
all,
|
||||
(x) => {
|
||||
if (!needle) return x
|
||||
if (!props.filter && Array.isArray(x) && x.every((e) => typeof e === "string")) {
|
||||
return fuzzysort.go(needle, x).map((x) => x.target) as T[]
|
||||
}
|
||||
return fuzzysort.go(needle, x, { keys: props.filter! }).map((x) => x.obj)
|
||||
},
|
||||
groupBy((x) => (props.groupBy ? props.groupBy(x) : "")),
|
||||
// mapValues((x) => x.sort((a, b) => props.key(a).localeCompare(props.key(b)))),
|
||||
entries(),
|
||||
map(([k, v]) => ({ category: k, items: v })),
|
||||
)
|
||||
return result
|
||||
},
|
||||
)
|
||||
const flat = createMemo(() => {
|
||||
return pipe(
|
||||
grouped() || [],
|
||||
flatMap((x) => x.items),
|
||||
)
|
||||
})
|
||||
const list = createList({
|
||||
items: () => flat().map(props.key),
|
||||
initialActive: props.current ? props.key(props.current) : undefined,
|
||||
loop: true,
|
||||
})
|
||||
const resetSelection = () => {
|
||||
const all = flat()
|
||||
if (all.length === 0) return
|
||||
list.setActive(props.key(all[0]))
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
store.filter
|
||||
scrollRef?.scrollTo(0, 0)
|
||||
resetSelection()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const all = flat()
|
||||
if (store.mouseActive || all.length === 0) return
|
||||
if (list.active() === props.key(all[0])) {
|
||||
scrollRef?.scrollTo(0, 0)
|
||||
return
|
||||
}
|
||||
const element = scrollRef?.querySelector(`[data-key="${list.active()}"]`)
|
||||
element?.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
||||
})
|
||||
|
||||
const handleInput = (value: string) => {
|
||||
setStore("filter", value)
|
||||
resetSelection()
|
||||
}
|
||||
|
||||
const handleSelect = (item: T) => {
|
||||
props.onSelect?.(item)
|
||||
props.onClose?.()
|
||||
}
|
||||
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
setStore("mouseActive", false)
|
||||
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
const selected = flat().find((x) => props.key(x) === list.active())
|
||||
if (selected) handleSelect(selected)
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
props.onClose?.()
|
||||
} else {
|
||||
list.onKeyDown(e)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog defaultOpen modal onOpenChange={(open) => open || props.onClose?.()}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="fixed inset-0 bg-black/50 backdrop-blur-sm z-[100]" />
|
||||
<Dialog.Content
|
||||
class="fixed top-[20%] left-1/2 -translate-x-1/2 w-[90vw] max-w-2xl
|
||||
shadow-[0_0_33px_rgba(0,0,0,0.8)]
|
||||
bg-background border border-border-subtle/30 rounded-lg z-[101]
|
||||
max-h-[60vh] flex flex-col"
|
||||
>
|
||||
<div class="border-b border-border-subtle/30">
|
||||
<div class="relative">
|
||||
<Icon name="command" size={16} class="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted/80" />
|
||||
<input
|
||||
type="text"
|
||||
value={store.filter}
|
||||
onInput={(e) => handleInput(e.currentTarget.value)}
|
||||
onKeyDown={handleKey}
|
||||
placeholder={props.placeholder}
|
||||
class="w-full pl-10 pr-4 py-2 rounded-t-md
|
||||
text-sm text-text placeholder-text-muted/70
|
||||
focus:outline-none"
|
||||
autofocus
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
/>
|
||||
<div class="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-2">
|
||||
{/* <Show when={fileResults.loading && mode() === "files"}>
|
||||
<div class="text-text-muted">
|
||||
<Icon name="refresh" size={14} class="animate-spin" />
|
||||
</div>
|
||||
</Show> */}
|
||||
<Show when={store.filter}>
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
class="text-text-muted hover:text-text"
|
||||
onClick={() => {
|
||||
setStore("filter", "")
|
||||
resetSelection()
|
||||
}}
|
||||
>
|
||||
<Icon name="close" size={14} />
|
||||
</IconButton>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={(el) => (scrollRef = el)} class="relative flex-1 overflow-y-auto">
|
||||
<Show
|
||||
when={flat().length > 0}
|
||||
fallback={<div class="text-center py-8 text-text-muted text-sm">No results</div>}
|
||||
>
|
||||
<For each={grouped()}>
|
||||
{(group) => (
|
||||
<>
|
||||
<Show when={group.category}>
|
||||
<div class="top-0 sticky z-10 bg-background-panel p-2 text-xs text-text-muted/60 tracking-wider uppercase">
|
||||
{group.category}
|
||||
</div>
|
||||
</Show>
|
||||
<div class="p-2">
|
||||
<For each={group.items}>
|
||||
{(item) => (
|
||||
<button
|
||||
data-key={props.key(item)}
|
||||
onClick={() => handleSelect(item)}
|
||||
onMouseMove={() => {
|
||||
setStore("mouseActive", true)
|
||||
list.setActive(props.key(item))
|
||||
}}
|
||||
classList={{
|
||||
"w-full px-3 py-2 flex items-center gap-3": true,
|
||||
"rounded-md text-left transition-colors group": true,
|
||||
"bg-background-element": props.key(item) === list.active(),
|
||||
}}
|
||||
>
|
||||
{props.render(item)}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="p-3 border-t border-border-subtle/30 flex items-center justify-between text-xs text-text-muted">
|
||||
<div class="flex items-center gap-5">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<kbd class="px-1.5 py-0.5 bg-background-element border border-border-subtle/30 rounded text-[10px]">
|
||||
↑↓
|
||||
</kbd>
|
||||
Navigate
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<kbd class="px-1.5 py-0.5 bg-background-element border border-border-subtle/30 rounded text-[10px]">
|
||||
↵
|
||||
</kbd>
|
||||
Select
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<kbd class="px-1.5 py-0.5 bg-background-element border border-border-subtle/30 rounded text-[10px]">
|
||||
ESC
|
||||
</kbd>
|
||||
Close
|
||||
</span>
|
||||
</div>
|
||||
<span>{`${flat().length} results`}</span>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Select as KobalteSelect } from "@kobalte/core/select"
|
||||
import { createMemo } from "solid-js"
|
||||
import type { ComponentProps } from "solid-js"
|
||||
import { Icon } from "@/ui/icon"
|
||||
import { pipe, groupBy, entries, map } from "remeda"
|
||||
import { Button, type ButtonProps } from "@/ui"
|
||||
|
||||
export interface SelectProps<T> {
|
||||
placeholder?: string
|
||||
options: T[]
|
||||
current?: T
|
||||
value?: (x: T) => string
|
||||
label?: (x: T) => string
|
||||
groupBy?: (x: T) => string
|
||||
onSelect?: (value: T | undefined) => void
|
||||
class?: ComponentProps<"div">["class"]
|
||||
classList?: ComponentProps<"div">["classList"]
|
||||
}
|
||||
|
||||
export function Select<T>(props: SelectProps<T> & ButtonProps) {
|
||||
const grouped = createMemo(() => {
|
||||
const result = pipe(
|
||||
props.options,
|
||||
groupBy((x) => (props.groupBy ? props.groupBy(x) : "")),
|
||||
// mapValues((x) => x.sort((a, b) => a.title.localeCompare(b.title))),
|
||||
entries(),
|
||||
map(([k, v]) => ({ category: k, options: v })),
|
||||
)
|
||||
return result
|
||||
})
|
||||
|
||||
return (
|
||||
<KobalteSelect<T, { category: string; options: T[] }>
|
||||
value={props.current}
|
||||
options={grouped()}
|
||||
optionValue={(x) => (props.value ? props.value(x) : (x as string))}
|
||||
optionTextValue={(x) => (props.label ? props.label(x) : (x as string))}
|
||||
optionGroupChildren="options"
|
||||
placeholder={props.placeholder}
|
||||
sectionComponent={(props) => (
|
||||
<KobalteSelect.Section class="text-xs uppercase text-text-muted/60 font-light mt-3 first:mt-0 ml-2">
|
||||
{props.section.rawValue.category}
|
||||
</KobalteSelect.Section>
|
||||
)}
|
||||
itemComponent={(itemProps) => (
|
||||
<KobalteSelect.Item
|
||||
classList={{
|
||||
"relative flex cursor-pointer select-none items-center": true,
|
||||
"rounded-sm px-2 py-0.5 text-xs outline-none text-text": true,
|
||||
"transition-colors data-[disabled]:pointer-events-none": true,
|
||||
"data-[highlighted]:bg-background-element data-[disabled]:opacity-50": true,
|
||||
[props.class ?? ""]: !!props.class,
|
||||
}}
|
||||
{...itemProps}
|
||||
>
|
||||
<KobalteSelect.ItemLabel>
|
||||
{props.label ? props.label(itemProps.item.rawValue) : (itemProps.item.rawValue as string)}
|
||||
</KobalteSelect.ItemLabel>
|
||||
<KobalteSelect.ItemIndicator class="ml-auto">
|
||||
<Icon name="checkmark" size={16} />
|
||||
</KobalteSelect.ItemIndicator>
|
||||
</KobalteSelect.Item>
|
||||
)}
|
||||
onChange={(v) => {
|
||||
props.onSelect?.(v ?? undefined)
|
||||
}}
|
||||
>
|
||||
<KobalteSelect.Trigger
|
||||
as={Button}
|
||||
size={props.size || "sm"}
|
||||
variant={props.variant || "secondary"}
|
||||
classList={{
|
||||
...(props.classList ?? {}),
|
||||
[props.class ?? ""]: !!props.class,
|
||||
}}
|
||||
>
|
||||
<KobalteSelect.Value<T>>
|
||||
{(state) => {
|
||||
const selected = state.selectedOption() ?? props.current
|
||||
if (!selected) return props.placeholder || ""
|
||||
if (props.label) return props.label(selected)
|
||||
return selected as string
|
||||
}}
|
||||
</KobalteSelect.Value>
|
||||
<KobalteSelect.Icon
|
||||
classList={{
|
||||
"size-fit shrink-0 text-text-muted transition-transform duration-100 data-[expanded]:rotate-180": true,
|
||||
}}
|
||||
>
|
||||
<Icon name="chevron-down" size={24} />
|
||||
</KobalteSelect.Icon>
|
||||
</KobalteSelect.Trigger>
|
||||
<KobalteSelect.Portal>
|
||||
<KobalteSelect.Content
|
||||
classList={{
|
||||
"min-w-32 overflow-hidden rounded-md border border-border-subtle/40": true,
|
||||
"bg-background-panel p-1 shadow-md z-50": true,
|
||||
"data-[closed]:animate-out data-[closed]:fade-out-0 data-[closed]:zoom-out-95": true,
|
||||
"data-[expanded]:animate-in data-[expanded]:fade-in-0 data-[expanded]:zoom-in-95": true,
|
||||
}}
|
||||
>
|
||||
<KobalteSelect.Listbox class="overflow-y-auto max-h-48" />
|
||||
</KobalteSelect.Content>
|
||||
</KobalteSelect.Portal>
|
||||
</KobalteSelect>
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,7 @@ export default function SessionList() {
|
||||
const local = useLocal()
|
||||
|
||||
return (
|
||||
<VList data={sync.data.session} class="p-2 no-scrollbar">
|
||||
<VList data={sync.data.session} class="p-2">
|
||||
{(session) => (
|
||||
<Tooltip placement="right" value={session.title} class="w-full min-w-0">
|
||||
<Button
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useLocal, useSync } from "@/context"
|
||||
import { Collapsible, Icon, type IconProps } from "@/ui"
|
||||
import { Collapsible, Icon } from "@/ui"
|
||||
import type { Part, ToolPart } from "@opencode-ai/sdk"
|
||||
import { DateTime } from "luxon"
|
||||
import {
|
||||
@@ -13,58 +13,14 @@ import {
|
||||
type ParentProps,
|
||||
createEffect,
|
||||
createMemo,
|
||||
Show,
|
||||
} from "solid-js"
|
||||
import { getFilename } from "@/utils"
|
||||
import Markdown from "./markdown"
|
||||
import { Markdown } from "./markdown"
|
||||
import { Code } from "./code"
|
||||
import { createElementSize } from "@solid-primitives/resize-observer"
|
||||
import { createScrollPosition } from "@solid-primitives/scroll"
|
||||
|
||||
function TimelineIcon(props: { name: IconProps["name"]; class?: string }) {
|
||||
return (
|
||||
<div
|
||||
classList={{
|
||||
"relative flex flex-none self-start items-center justify-center bg-background h-6 w-6": true,
|
||||
[props.class ?? ""]: !!props.class,
|
||||
}}
|
||||
>
|
||||
<Icon name={props.name} class="text-text/40" size={18} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleTimelineIcon(props: { name: IconProps["name"]; class?: string }) {
|
||||
return (
|
||||
<>
|
||||
<TimelineIcon
|
||||
name={props.name}
|
||||
class={`group-hover/li:hidden group-has-[[data-expanded]]/li:hidden ${props.class ?? ""}`}
|
||||
/>
|
||||
<TimelineIcon
|
||||
name="chevron-right"
|
||||
class={`hidden group-hover/li:flex group-has-[[data-expanded]]/li:hidden ${props.class ?? ""}`}
|
||||
/>
|
||||
<TimelineIcon name="chevron-down" class={`hidden group-has-[[data-expanded]]/li:flex ${props.class ?? ""}`} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolIcon(props: { part: ToolPart }) {
|
||||
return (
|
||||
<Switch fallback={<TimelineIcon name="hammer" />}>
|
||||
<Match when={props.part.tool === "read"}>
|
||||
<TimelineIcon name="file" />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "edit"}>
|
||||
<CollapsibleTimelineIcon name="pencil" />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "write"}>
|
||||
<CollapsibleTimelineIcon name="file-plus" />
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
function Part(props: ParentProps & ComponentProps<"div">) {
|
||||
const [local, others] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
@@ -97,9 +53,13 @@ function CollapsiblePart(props: { title: ParentProps["children"] } & ParentProps
|
||||
}
|
||||
|
||||
function ReadToolPart(props: { part: ToolPart }) {
|
||||
const sync = useSync()
|
||||
const local = useLocal()
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.part.state.status === "pending"}>
|
||||
<Part>Reading file...</Part>
|
||||
</Match>
|
||||
<Match when={props.part.state.status === "completed" && props.part.state}>
|
||||
{(state) => {
|
||||
const path = state().input["filePath"] as string
|
||||
@@ -110,13 +70,27 @@ function ReadToolPart(props: { part: ToolPart }) {
|
||||
)
|
||||
}}
|
||||
</Match>
|
||||
<Match when={props.part.state.status === "error" && props.part.state}>
|
||||
{(state) => (
|
||||
<div>
|
||||
<Part>
|
||||
<span class="text-text-muted">Read</span> {getFilename(state().input["filePath"] as string)}
|
||||
</Part>
|
||||
<div class="text-error">{sync.sanitize(state().error)}</div>
|
||||
</div>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
function EditToolPart(props: { part: ToolPart }) {
|
||||
const sync = useSync()
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.part.state.status === "pending"}>
|
||||
<Part>Preparing edit...</Part>
|
||||
</Match>
|
||||
<Match when={props.part.state.status === "completed" && props.part.state}>
|
||||
{(state) => (
|
||||
<CollapsiblePart
|
||||
@@ -127,11 +101,20 @@ function EditToolPart(props: { part: ToolPart }) {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Code
|
||||
path={state().input["filePath"] as string}
|
||||
code={state().metadata["diff"] as string}
|
||||
class="[&_code]:pb-0!"
|
||||
/>
|
||||
<Code path={state().input["filePath"] as string} code={state().metadata["diff"] as string} />
|
||||
</CollapsiblePart>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={props.part.state.status === "error" && props.part.state}>
|
||||
{(state) => (
|
||||
<CollapsiblePart
|
||||
title={
|
||||
<>
|
||||
<span class="text-text-muted">Edit</span> {getFilename(state().input["filePath"] as string)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div class="text-error">{sync.sanitize(state().error)}</div>
|
||||
</CollapsiblePart>
|
||||
)}
|
||||
</Match>
|
||||
@@ -140,8 +123,12 @@ function EditToolPart(props: { part: ToolPart }) {
|
||||
}
|
||||
|
||||
function WriteToolPart(props: { part: ToolPart }) {
|
||||
const sync = useSync()
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.part.state.status === "pending"}>
|
||||
<Part>Preparing write...</Part>
|
||||
</Match>
|
||||
<Match when={props.part.state.status === "completed" && props.part.state}>
|
||||
{(state) => (
|
||||
<CollapsiblePart
|
||||
@@ -155,35 +142,95 @@ function WriteToolPart(props: { part: ToolPart }) {
|
||||
</CollapsiblePart>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={props.part.state.status === "error" && props.part.state}>
|
||||
{(state) => (
|
||||
<div>
|
||||
<Part>
|
||||
<span class="text-text-muted">Write</span> {getFilename(state().input["filePath"] as string)}
|
||||
</Part>
|
||||
<div class="text-error">{sync.sanitize(state().error)}</div>
|
||||
</div>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
function BashToolPart(props: { part: ToolPart }) {
|
||||
const sync = useSync()
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.part.state.status === "pending"}>
|
||||
<Part>Writing shell command...</Part>
|
||||
</Match>
|
||||
<Match when={props.part.state.status === "completed" && props.part.state}>
|
||||
{(state) => (
|
||||
<CollapsiblePart
|
||||
defaultOpen
|
||||
title={
|
||||
<>
|
||||
<span class="text-text-muted">Run command:</span> {state().input["command"]}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Markdown text={`\`\`\`command\n${state().input["command"]}\n${state().output}\`\`\``} />
|
||||
</CollapsiblePart>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={props.part.state.status === "error" && props.part.state}>
|
||||
{(state) => (
|
||||
<CollapsiblePart
|
||||
title={
|
||||
<>
|
||||
<span class="text-text-muted">Shell</span> {state().input["command"]}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div class="text-error">{sync.sanitize(state().error)}</div>
|
||||
</CollapsiblePart>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolPart(props: { part: ToolPart }) {
|
||||
// read
|
||||
// edit
|
||||
// write
|
||||
// bash
|
||||
// ls
|
||||
// glob
|
||||
// grep
|
||||
// todowrite
|
||||
// todoread
|
||||
// webfetch
|
||||
// websearch
|
||||
// patch
|
||||
// task
|
||||
return (
|
||||
<Switch
|
||||
fallback={
|
||||
<div class="flex-auto min-w-0 text-xs">
|
||||
{props.part.type}:{props.part.tool}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Match when={props.part.tool === "read"}>
|
||||
<div class="min-w-0 flex-auto">
|
||||
<div class="min-w-0 flex-auto text-xs">
|
||||
<Switch
|
||||
fallback={
|
||||
<span>
|
||||
{props.part.type}:{props.part.tool}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Match when={props.part.tool === "read"}>
|
||||
<ReadToolPart part={props.part} />
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={props.part.tool === "edit"}>
|
||||
<div class="min-w-0 flex-auto">
|
||||
</Match>
|
||||
<Match when={props.part.tool === "edit"}>
|
||||
<EditToolPart part={props.part} />
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={props.part.tool === "write"}>
|
||||
<div class="min-w-0 flex-auto">
|
||||
</Match>
|
||||
<Match when={props.part.tool === "write"}>
|
||||
<WriteToolPart part={props.part} />
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Match>
|
||||
<Match when={props.part.tool === "bash"}>
|
||||
<BashToolPart part={props.part} />
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -196,6 +243,7 @@ export default function SessionTimeline(props: { session: string; class?: string
|
||||
const scroll = createScrollPosition(scrollElement)
|
||||
|
||||
onMount(() => sync.session.sync(props.session))
|
||||
const session = createMemo(() => sync.session.get(props.session))
|
||||
const messages = createMemo(() => sync.data.message[props.session] ?? [])
|
||||
const working = createMemo(() => {
|
||||
const last = messages()[messages().length - 1]
|
||||
@@ -285,60 +333,33 @@ export default function SessionTimeline(props: { session: string; class?: string
|
||||
<div
|
||||
ref={setRoot}
|
||||
classList={{
|
||||
"p-4 select-text flex flex-col gap-y-8": true,
|
||||
"p-4 select-text flex flex-col gap-y-1": true,
|
||||
[props.class ?? ""]: !!props.class,
|
||||
}}
|
||||
>
|
||||
<For each={messages()}>
|
||||
{(message) => (
|
||||
<ul role="list" class="space-y-2">
|
||||
<ul role="list" class="flex flex-col gap-1">
|
||||
<For each={messages()}>
|
||||
{(message) => (
|
||||
<For each={sync.data.part[message.id]?.filter(valid)}>
|
||||
{(part) => (
|
||||
<li classList={{ "relative group/li flex gap-x-4 min-w-0 w-full": true }}>
|
||||
<div
|
||||
classList={{
|
||||
"absolute top-0 left-0 flex w-6 justify-center": true,
|
||||
"last:h-10 not-last:-bottom-10": true,
|
||||
}}
|
||||
>
|
||||
<div class="w-px bg-border-subtle" />
|
||||
</div>
|
||||
<Switch
|
||||
fallback={
|
||||
<div class="m-0.5 relative flex size-5 flex-none items-center justify-center bg-background">
|
||||
<div class="size-1 rounded-full bg-text/10 ring ring-text/20" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Match when={part.type === "text"}>
|
||||
<Switch>
|
||||
<Match when={message.role === "user"}>
|
||||
<TimelineIcon name="avatar-square" />
|
||||
</Match>
|
||||
<Match when={message.role === "assistant"}>
|
||||
<TimelineIcon name="sparkles" />
|
||||
</Match>
|
||||
</Switch>
|
||||
</Match>
|
||||
<Match when={part.type === "reasoning"}>
|
||||
<CollapsibleTimelineIcon name="brain" />
|
||||
</Match>
|
||||
<Match when={part.type === "tool" && part}>{(part) => <ToolIcon part={part()} />}</Match>
|
||||
</Switch>
|
||||
<li class="group/li">
|
||||
<Switch fallback={<div class="flex-auto min-w-0 text-xs mt-1 text-left">{part.type}</div>}>
|
||||
<Match when={part.type === "text" && part}>
|
||||
{(part) => (
|
||||
<Switch>
|
||||
<Match when={message.role === "user"}>
|
||||
<div class="w-full flex flex-col items-end justify-stretch gap-y-1.5 min-w-0">
|
||||
<div class="w-full flex flex-col items-end justify-stretch gap-y-1.5 min-w-0 mt-5 group-first/li:mt-0">
|
||||
<p class="w-full rounded-md p-3 ring-1 ring-text/15 ring-inset text-xs bg-background-panel">
|
||||
<span class="font-medium text-text whitespace-pre-wrap break-words">{part().text}</span>
|
||||
</p>
|
||||
<p class="text-xs text-text-muted">12:07pm · adam</p>
|
||||
<p class="text-xs text-text-muted">
|
||||
{DateTime.fromMillis(message.time.created).toRelative()} ·{" "}
|
||||
{sync.data.config.username ?? "user"}
|
||||
</p>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={message.role === "assistant"}>
|
||||
<Markdown text={part().text} class="text-text" />
|
||||
<Markdown text={sync.sanitize(part().text)} class="text-text mt-1" />
|
||||
</Match>
|
||||
</Switch>
|
||||
)}
|
||||
@@ -347,9 +368,11 @@ export default function SessionTimeline(props: { session: string; class?: string
|
||||
{(part) => (
|
||||
<CollapsiblePart
|
||||
title={
|
||||
<>
|
||||
<span class="text-text-muted">Thought</span> for {duration(part())}s
|
||||
</>
|
||||
<Switch fallback={<span class="text-text-muted">Thinking</span>}>
|
||||
<Match when={part().time.end}>
|
||||
<span class="text-text-muted">Thought</span> for {duration(part())}s
|
||||
</Match>
|
||||
</Switch>
|
||||
}
|
||||
>
|
||||
<Markdown text={part().text} />
|
||||
@@ -361,9 +384,76 @@ export default function SessionTimeline(props: { session: string; class?: string
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
)}
|
||||
</For>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
<Show when={false}>
|
||||
<Collapsible defaultOpen={false}>
|
||||
<Collapsible.Trigger>
|
||||
<div class="mt-12 ml-1 flex items-center gap-x-2 text-xs text-text-muted">
|
||||
<Icon name="file-code" size={16} />
|
||||
<span>Raw Session Data</span>
|
||||
<Collapsible.Arrow size={18} class="text-text-muted" />
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content class="mt-5">
|
||||
<ul role="list" class="space-y-2">
|
||||
<li>
|
||||
<Collapsible>
|
||||
<Collapsible.Trigger>
|
||||
<div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
|
||||
<Icon name="file-code" size={16} />
|
||||
<span>session</span>
|
||||
<Collapsible.Arrow size={18} class="text-text-muted" />
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<Code path="session.json" code={JSON.stringify(session(), null, 2)} />
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</li>
|
||||
<For each={messages()}>
|
||||
{(message) => (
|
||||
<>
|
||||
<li>
|
||||
<Collapsible>
|
||||
<Collapsible.Trigger>
|
||||
<div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
|
||||
<Icon name="file-code" size={16} />
|
||||
<span>{message.role === "user" ? "user" : "assistant"}</span>
|
||||
<Collapsible.Arrow size={18} class="text-text-muted" />
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<Code path={message.id + ".json"} code={JSON.stringify(message, null, 2)} />
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</li>
|
||||
<For each={sync.data.part[message.id]}>
|
||||
{(part) => (
|
||||
<li>
|
||||
<Collapsible>
|
||||
<Collapsible.Trigger>
|
||||
<div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
|
||||
<Icon name="file-code" size={16} />
|
||||
<span>{part.type}</span>
|
||||
<Collapsible.Arrow size={18} class="text-text-muted" />
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<Code path={message.id + "." + part.id + ".json"} code={JSON.stringify(part, null, 2)} />
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { createContext, useContext, type ParentProps } from "solid-js"
|
||||
import { createEventBus } from "@solid-primitives/event-bus"
|
||||
import type { Event as SDKEvent } from "@opencode-ai/sdk"
|
||||
import { useSDK } from "@/context"
|
||||
|
||||
export type Event = SDKEvent // can extend with custom events later
|
||||
|
||||
function init() {
|
||||
const sdk = useSDK()
|
||||
const bus = createEventBus<Event>()
|
||||
sdk.event.subscribe().then(async (events) => {
|
||||
for await (const event of events.stream) {
|
||||
bus.emit(event)
|
||||
}
|
||||
})
|
||||
return bus
|
||||
}
|
||||
|
||||
type EventContext = ReturnType<typeof init>
|
||||
|
||||
const ctx = createContext<EventContext>()
|
||||
|
||||
export function EventProvider(props: ParentProps) {
|
||||
const value = init()
|
||||
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
|
||||
}
|
||||
|
||||
export function useEvent() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) {
|
||||
throw new Error("useEvent must be used within a EventProvider")
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
export { EventProvider, useEvent } from "./event"
|
||||
export { LocalProvider, useLocal } from "./local"
|
||||
export { MarkedProvider, useMarked } from "./marked"
|
||||
export { SDKProvider, useSDK } from "./sdk"
|
||||
export { ShikiProvider, useShiki } from "./shiki"
|
||||
export { SyncProvider, useSync } from "./sync"
|
||||
export { ThemeProvider, useTheme } from "./theme"
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { batch, createContext, createEffect, createMemo, useContext, type ParentProps } from "solid-js"
|
||||
import { useSync } from "./sync"
|
||||
import { uniqueBy } from "remeda"
|
||||
import type { FileContent, FileNode } from "@opencode-ai/sdk"
|
||||
import { useSDK } from "./sdk"
|
||||
import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk"
|
||||
import { useSDK, useEvent, useSync } from "@/context"
|
||||
|
||||
export type LocalFile = FileNode &
|
||||
Partial<{
|
||||
@@ -16,30 +15,43 @@ export type LocalFile = FileNode &
|
||||
view: "raw" | "diff-unified" | "diff-split"
|
||||
folded: string[]
|
||||
selectedChange: number
|
||||
status: FileStatus
|
||||
}>
|
||||
export type TextSelection = LocalFile["selection"]
|
||||
export type View = LocalFile["view"]
|
||||
|
||||
export type LocalModel = Omit<Model, "provider"> & {
|
||||
provider: Provider
|
||||
}
|
||||
export type ModelKey = { providerID: string; modelID: string }
|
||||
|
||||
export type FileContext = { type: "file"; path: string; selection?: TextSelection }
|
||||
export type ContextItem = FileContext
|
||||
|
||||
function init() {
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
|
||||
const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent"))
|
||||
const agent = (() => {
|
||||
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent"))
|
||||
const [store, setStore] = createStore<{
|
||||
current: string
|
||||
}>({
|
||||
current: agents()[0].name,
|
||||
current: list()[0].name,
|
||||
})
|
||||
return {
|
||||
list,
|
||||
current() {
|
||||
return agents().find((x) => x.name === store.current)!
|
||||
return list().find((x) => x.name === store.current)!
|
||||
},
|
||||
set(name: string | undefined) {
|
||||
setStore("current", name ?? list()[0].name)
|
||||
},
|
||||
move(direction: 1 | -1) {
|
||||
let next = agents().findIndex((x) => x.name === store.current) + direction
|
||||
if (next < 0) next = agents().length - 1
|
||||
if (next >= agents().length) next = 0
|
||||
const value = agents()[next]
|
||||
let next = list().findIndex((x) => x.name === store.current) + direction
|
||||
if (next < 0) next = list().length - 1
|
||||
if (next >= list().length) next = 0
|
||||
const value = list()[next]
|
||||
setStore("current", value.name)
|
||||
if (value.model)
|
||||
model.set({
|
||||
@@ -51,18 +63,14 @@ function init() {
|
||||
})()
|
||||
|
||||
const model = (() => {
|
||||
const list = createMemo(() =>
|
||||
sync.data.provider.flatMap((p) => Object.values(p.models).map((m) => ({ ...m, provider: p }) as LocalModel)),
|
||||
)
|
||||
const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID)
|
||||
|
||||
const [store, setStore] = createStore<{
|
||||
model: Record<
|
||||
string,
|
||||
{
|
||||
providerID: string
|
||||
modelID: string
|
||||
}
|
||||
>
|
||||
recent: {
|
||||
providerID: string
|
||||
modelID: string
|
||||
}[]
|
||||
model: Record<string, ModelKey>
|
||||
recent: ModelKey[]
|
||||
}>({
|
||||
model: {},
|
||||
recent: [],
|
||||
@@ -78,35 +86,24 @@ function init() {
|
||||
if (store.recent.length) return store.recent[0]
|
||||
const provider = sync.data.provider[0]
|
||||
const model = Object.values(provider.models)[0]
|
||||
return {
|
||||
providerID: provider.id,
|
||||
modelID: model.id,
|
||||
}
|
||||
return { modelID: model.id, providerID: provider.id }
|
||||
})
|
||||
|
||||
const current = createMemo(() => {
|
||||
const a = agent.current()
|
||||
return store.model[agent.current().name] ?? (a.model ? a.model : fallback())
|
||||
return find(store.model[agent.current().name]) ?? find(a.model ?? fallback())
|
||||
})
|
||||
|
||||
const recent = createMemo(() => store.recent.map(find).filter(Boolean))
|
||||
|
||||
return {
|
||||
list,
|
||||
current,
|
||||
recent() {
|
||||
return store.recent
|
||||
},
|
||||
parsed: createMemo(() => {
|
||||
const value = current()
|
||||
const provider = sync.data.provider.find((x) => x.id === value.providerID)!
|
||||
const model = provider.models[value.modelID]
|
||||
return {
|
||||
provider: provider.name ?? value.providerID,
|
||||
model: model.name ?? value.modelID,
|
||||
}
|
||||
}),
|
||||
set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
|
||||
recent,
|
||||
set(model: ModelKey | undefined, options?: { recent?: boolean }) {
|
||||
batch(() => {
|
||||
setStore("model", agent.current().name, model)
|
||||
if (options?.recent) {
|
||||
setStore("model", agent.current().name, model ?? fallback())
|
||||
if (options?.recent && model) {
|
||||
const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
|
||||
if (uniq.length > 5) uniq.pop()
|
||||
setStore("recent", uniq)
|
||||
@@ -131,11 +128,36 @@ function init() {
|
||||
return store.node[store.active]
|
||||
})
|
||||
const opened = createMemo(() => store.opened.map((x) => store.node[x]))
|
||||
const changes = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
|
||||
const status = (path: string) => sync.data.changes.find((f) => f.path === path)
|
||||
const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
|
||||
const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b)))
|
||||
|
||||
createEffect((prev: FileStatus[]) => {
|
||||
const removed = prev.filter((p) => !sync.data.changes.find((c) => c.path === p.path))
|
||||
for (const p of removed) {
|
||||
setStore(
|
||||
"node",
|
||||
p.path,
|
||||
produce((draft) => {
|
||||
draft.status = undefined
|
||||
draft.view = "raw"
|
||||
}),
|
||||
)
|
||||
load(p.path)
|
||||
}
|
||||
for (const p of sync.data.changes) {
|
||||
if (store.node[p.path] === undefined) {
|
||||
fetch(p.path).then(() => setStore("node", p.path, "status", p))
|
||||
} else {
|
||||
setStore("node", p.path, "status", p)
|
||||
}
|
||||
}
|
||||
return sync.data.changes
|
||||
}, sync.data.changes)
|
||||
|
||||
const changed = (path: string) => {
|
||||
const set = changes()
|
||||
const node = store.node[path]
|
||||
if (node?.status) return true
|
||||
const set = changeset()
|
||||
if (set.has(path)) return true
|
||||
for (const p of set) {
|
||||
if (p.startsWith(path ? path + "/" : "")) return true
|
||||
@@ -156,38 +178,48 @@ function init() {
|
||||
})
|
||||
}
|
||||
|
||||
const load = async (path: string) =>
|
||||
sdk.file.read({ query: { path } }).then((x) => {
|
||||
const relative = (path: string) => path.replace(sync.data.path.directory + "/", "")
|
||||
|
||||
const load = async (path: string) => {
|
||||
const relativePath = relative(path)
|
||||
sdk.file.read({ query: { path: relativePath } }).then((x) => {
|
||||
setStore(
|
||||
"node",
|
||||
path,
|
||||
relativePath,
|
||||
produce((draft) => {
|
||||
draft.loaded = true
|
||||
draft.content = x.data
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const open = async (path: string) => {
|
||||
const relative = path.replace(sync.data.path.directory + "/", "")
|
||||
if (!store.node[relative]) {
|
||||
const parent = relative.split("/").slice(0, -1).join("/")
|
||||
if (parent) {
|
||||
await list(parent)
|
||||
}
|
||||
const fetch = async (path: string) => {
|
||||
const relativePath = relative(path)
|
||||
const parent = relativePath.split("/").slice(0, -1).join("/")
|
||||
if (parent) {
|
||||
await list(parent)
|
||||
}
|
||||
}
|
||||
|
||||
const open = async (path: string, options?: { pinned?: boolean; view?: LocalFile["view"] }) => {
|
||||
const relativePath = relative(path)
|
||||
if (!store.node[relativePath]) await fetch(path)
|
||||
setStore("opened", (x) => {
|
||||
if (x.includes(relative)) return x
|
||||
if (x.includes(relativePath)) return x
|
||||
return [
|
||||
...opened()
|
||||
.filter((x) => x.pinned)
|
||||
.map((x) => x.path),
|
||||
relative,
|
||||
relativePath,
|
||||
]
|
||||
})
|
||||
setStore("active", relative)
|
||||
if (store.node[relative].loaded) return
|
||||
return load(relative)
|
||||
setStore("active", relativePath)
|
||||
context.addActive()
|
||||
if (options?.pinned) setStore("node", path, "pinned", true)
|
||||
if (options?.view && store.node[relativePath].view === undefined) setStore("node", path, "view", options.view)
|
||||
if (store.node[relativePath].loaded) return
|
||||
return load(relativePath)
|
||||
}
|
||||
|
||||
const list = async (path: string) => {
|
||||
@@ -204,27 +236,31 @@ function init() {
|
||||
})
|
||||
}
|
||||
|
||||
sdk.event.subscribe().then(async (events) => {
|
||||
for await (const event of events.stream) {
|
||||
switch (event.type) {
|
||||
case "message.part.updated":
|
||||
const part = event.properties.part
|
||||
if (part.type === "tool" && part.state.status === "completed") {
|
||||
switch (part.tool) {
|
||||
case "read":
|
||||
console.log("read", part.state.input)
|
||||
break
|
||||
case "edit":
|
||||
const absolute = part.state.input["filePath"] as string
|
||||
const path = absolute.replace(sync.data.path.directory + "/", "")
|
||||
load(path)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
const search = (query: string) => sdk.find.files({ query: { query } }).then((x) => x.data!)
|
||||
|
||||
const bus = useEvent()
|
||||
bus.listen((event) => {
|
||||
switch (event.type) {
|
||||
case "message.part.updated":
|
||||
const part = event.properties.part
|
||||
if (part.type === "tool" && part.state.status === "completed") {
|
||||
switch (part.tool) {
|
||||
case "read":
|
||||
break
|
||||
case "edit":
|
||||
// load(part.state.input["filePath"] as string)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
case "file.watcher.updated":
|
||||
setTimeout(sync.load.changes, 1000)
|
||||
const relativePath = relative(event.properties.file)
|
||||
if (relativePath.startsWith(".git/")) return
|
||||
load(relativePath)
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
@@ -297,8 +333,8 @@ function init() {
|
||||
setChangeIndex(path: string, index: number | undefined) {
|
||||
setStore("node", path, "selectedChange", index)
|
||||
},
|
||||
changes,
|
||||
changed,
|
||||
status,
|
||||
children(path: string) {
|
||||
return Object.values(store.node).filter(
|
||||
(x) =>
|
||||
@@ -307,56 +343,109 @@ function init() {
|
||||
!x.path.replace(new RegExp(`^${path + "/"}`), "").includes("/"),
|
||||
)
|
||||
},
|
||||
search,
|
||||
relative,
|
||||
}
|
||||
})()
|
||||
|
||||
const layout = (() => {
|
||||
const [store, setStore] = createStore<{
|
||||
rightPane: boolean
|
||||
leftWidth: number
|
||||
rightWidth: number
|
||||
}>({
|
||||
rightPane: false,
|
||||
leftWidth: 200, // Default 50 * 4px (w-50 = 12.5rem = 200px)
|
||||
rightWidth: 320, // Default 80 * 4px (w-80 = 20rem = 320px)
|
||||
})
|
||||
type PaneState = { size: number; visible: boolean }
|
||||
type LayoutState = { panes: Record<string, PaneState>; order: string[] }
|
||||
type PaneDefault = number | { size: number; visible?: boolean }
|
||||
|
||||
const value = localStorage.getItem("layout")
|
||||
if (value) {
|
||||
const v = JSON.parse(value)
|
||||
if (typeof v?.rightPane === "boolean") setStore("rightPane", v.rightPane)
|
||||
if (typeof v?.leftWidth === "number") setStore("leftWidth", Math.max(150, Math.min(400, v.leftWidth)))
|
||||
if (typeof v?.rightWidth === "number") setStore("rightWidth", Math.max(200, Math.min(500, v.rightWidth)))
|
||||
const [store, setStore] = createStore<Record<string, LayoutState>>({})
|
||||
|
||||
const raw = localStorage.getItem("layout")
|
||||
if (raw) {
|
||||
const data = JSON.parse(raw)
|
||||
if (data && typeof data === "object" && !Array.isArray(data)) {
|
||||
const first = Object.values(data)[0] as LayoutState
|
||||
if (first && typeof first === "object" && "panes" in first) {
|
||||
setStore(() => data as Record<string, LayoutState>)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
localStorage.setItem("layout", JSON.stringify(store))
|
||||
})
|
||||
|
||||
const normalize = (value: PaneDefault): PaneState => {
|
||||
if (typeof value === "number") return { size: value, visible: true }
|
||||
return { size: value.size, visible: value.visible ?? true }
|
||||
}
|
||||
|
||||
const ensure = (id: string, defaults: Record<string, PaneDefault>) => {
|
||||
const entries = Object.entries(defaults)
|
||||
if (!entries.length) return
|
||||
setStore(id, (current) => {
|
||||
if (current) return current
|
||||
return {
|
||||
panes: Object.fromEntries(entries.map(([pane, config]) => [pane, normalize(config)])),
|
||||
order: entries.map(([pane]) => pane),
|
||||
}
|
||||
})
|
||||
for (const [pane, config] of entries) {
|
||||
if (!store[id]?.panes[pane]) {
|
||||
setStore(id, "panes", pane, () => normalize(config))
|
||||
}
|
||||
if (!(store[id]?.order ?? []).includes(pane)) {
|
||||
setStore(id, "order", (list) => [...list, pane])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ensurePane = (id: string, pane: string, fallback?: PaneDefault) => {
|
||||
if (!store[id]) {
|
||||
const value = normalize(fallback ?? { size: 0, visible: true })
|
||||
setStore(id, () => ({
|
||||
panes: { [pane]: value },
|
||||
order: [pane],
|
||||
}))
|
||||
return
|
||||
}
|
||||
if (!store[id].panes[pane]) {
|
||||
const value = normalize(fallback ?? { size: 0, visible: true })
|
||||
setStore(id, "panes", pane, () => value)
|
||||
}
|
||||
if (!store[id].order.includes(pane)) {
|
||||
setStore(id, "order", (list) => [...list, pane])
|
||||
}
|
||||
}
|
||||
|
||||
const size = (id: string, pane: string) => store[id]?.panes[pane]?.size ?? 0
|
||||
const visible = (id: string, pane: string) => store[id]?.panes[pane]?.visible ?? false
|
||||
|
||||
const setSize = (id: string, pane: string, value: number) => {
|
||||
if (!store[id]?.panes[pane]) return
|
||||
const next = Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0
|
||||
setStore(id, "panes", pane, "size", next)
|
||||
}
|
||||
|
||||
const setVisible = (id: string, pane: string, value: boolean) => {
|
||||
if (!store[id]?.panes[pane]) return
|
||||
setStore(id, "panes", pane, "visible", value)
|
||||
}
|
||||
|
||||
const toggle = (id: string, pane: string) => {
|
||||
setVisible(id, pane, !visible(id, pane))
|
||||
}
|
||||
|
||||
const show = (id: string, pane: string) => setVisible(id, pane, true)
|
||||
const hide = (id: string, pane: string) => setVisible(id, pane, false)
|
||||
const order = (id: string) => store[id]?.order ?? []
|
||||
|
||||
return {
|
||||
rightPane() {
|
||||
return store.rightPane
|
||||
},
|
||||
leftWidth() {
|
||||
return store.leftWidth
|
||||
},
|
||||
rightWidth() {
|
||||
return store.rightWidth
|
||||
},
|
||||
toggleRightPane() {
|
||||
setStore("rightPane", (x) => !x)
|
||||
},
|
||||
openRightPane() {
|
||||
setStore("rightPane", true)
|
||||
},
|
||||
closeRightPane() {
|
||||
setStore("rightPane", false)
|
||||
},
|
||||
setLeftWidth(width: number) {
|
||||
setStore("leftWidth", Math.max(150, Math.min(400, width)))
|
||||
},
|
||||
setRightWidth(width: number) {
|
||||
setStore("rightWidth", Math.max(200, Math.min(500, width)))
|
||||
},
|
||||
ensure,
|
||||
ensurePane,
|
||||
size,
|
||||
visible,
|
||||
setSize,
|
||||
setVisible,
|
||||
toggle,
|
||||
show,
|
||||
hide,
|
||||
order,
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -381,12 +470,51 @@ function init() {
|
||||
}
|
||||
})()
|
||||
|
||||
const context = (() => {
|
||||
const [store, setStore] = createStore<{
|
||||
activeTab: boolean
|
||||
items: (ContextItem & { key: string })[]
|
||||
}>({
|
||||
activeTab: true,
|
||||
items: [],
|
||||
})
|
||||
|
||||
return {
|
||||
all() {
|
||||
return store.items
|
||||
},
|
||||
active() {
|
||||
return store.activeTab ? file.active() : undefined
|
||||
},
|
||||
addActive() {
|
||||
setStore("activeTab", true)
|
||||
},
|
||||
removeActive() {
|
||||
setStore("activeTab", false)
|
||||
},
|
||||
add(item: ContextItem) {
|
||||
let key = item.type
|
||||
switch (item.type) {
|
||||
case "file":
|
||||
key += `${item.path}:${item.selection?.startLine}:${item.selection?.endLine}`
|
||||
break
|
||||
}
|
||||
if (store.items.find((x) => x.key === key)) return
|
||||
setStore("items", (x) => [...x, { key, ...item }])
|
||||
},
|
||||
remove(key: string) {
|
||||
setStore("items", (x) => x.filter((x) => x.key !== key))
|
||||
},
|
||||
}
|
||||
})()
|
||||
|
||||
const result = {
|
||||
model,
|
||||
agent,
|
||||
file,
|
||||
layout,
|
||||
session,
|
||||
context,
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createContext, useContext, type ParentProps } from "solid-js"
|
||||
import { useShiki } from "@/context"
|
||||
import { marked } from "marked"
|
||||
import markedShiki from "marked-shiki"
|
||||
import { bundledLanguages, type BundledLanguage } from "shiki"
|
||||
|
||||
function init(highlighter: ReturnType<typeof useShiki>) {
|
||||
return marked.use(
|
||||
markedShiki({
|
||||
async highlight(code, lang) {
|
||||
if (!(lang in bundledLanguages)) {
|
||||
lang = "text"
|
||||
}
|
||||
if (!highlighter.getLoadedLanguages().includes(lang)) {
|
||||
await highlighter.loadLanguage(lang as BundledLanguage)
|
||||
}
|
||||
return highlighter.codeToHtml(code, {
|
||||
lang: lang || "text",
|
||||
theme: "opencode",
|
||||
tabindex: false,
|
||||
})
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
type MarkedContext = ReturnType<typeof init>
|
||||
|
||||
const ctx = createContext<MarkedContext>()
|
||||
|
||||
export function MarkedProvider(props: ParentProps) {
|
||||
const highlighter = useShiki()
|
||||
const value = init(highlighter)
|
||||
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
|
||||
}
|
||||
|
||||
export function useMarked() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) {
|
||||
throw new Error("useMarked must be used within a MarkedProvider")
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
import { createHighlighter, type ThemeInput } from "shiki"
|
||||
import { createContext, useContext, type ParentProps } from "solid-js"
|
||||
|
||||
const theme: ThemeInput = {
|
||||
colors: {
|
||||
"actionBar.toggledBackground": "var(--theme-background-element)",
|
||||
"activityBarBadge.background": "var(--theme-accent)",
|
||||
"checkbox.border": "var(--theme-border)",
|
||||
"editor.background": "transparent",
|
||||
"editor.foreground": "var(--theme-text)",
|
||||
"editor.inactiveSelectionBackground": "var(--theme-background-element)",
|
||||
"editor.selectionHighlightBackground": "var(--theme-border-active)",
|
||||
"editorIndentGuide.activeBackground1": "var(--theme-border-subtle)",
|
||||
"editorIndentGuide.background1": "var(--theme-border-subtle)",
|
||||
"input.placeholderForeground": "var(--theme-text-muted)",
|
||||
"list.activeSelectionIconForeground": "var(--theme-text)",
|
||||
"list.dropBackground": "var(--theme-background-element)",
|
||||
"menu.background": "var(--theme-background-panel)",
|
||||
"menu.border": "var(--theme-border)",
|
||||
"menu.foreground": "var(--theme-text)",
|
||||
"menu.selectionBackground": "var(--theme-primary)",
|
||||
"menu.separatorBackground": "var(--theme-border)",
|
||||
"ports.iconRunningProcessForeground": "var(--theme-success)",
|
||||
"sideBarSectionHeader.background": "transparent",
|
||||
"sideBarSectionHeader.border": "var(--theme-border-subtle)",
|
||||
"sideBarTitle.foreground": "var(--theme-text-muted)",
|
||||
"statusBarItem.remoteBackground": "var(--theme-success)",
|
||||
"statusBarItem.remoteForeground": "var(--theme-text)",
|
||||
"tab.lastPinnedBorder": "var(--theme-border-subtle)",
|
||||
"tab.selectedBackground": "var(--theme-background-element)",
|
||||
"tab.selectedForeground": "var(--theme-text-muted)",
|
||||
"terminal.inactiveSelectionBackground": "var(--theme-background-element)",
|
||||
"widget.border": "var(--theme-border)",
|
||||
},
|
||||
displayName: "opencode",
|
||||
name: "opencode",
|
||||
semanticHighlighting: true,
|
||||
semanticTokenColors: {
|
||||
customLiteral: "var(--theme-syntax-function)",
|
||||
newOperator: "var(--theme-syntax-operator)",
|
||||
numberLiteral: "var(--theme-syntax-number)",
|
||||
stringLiteral: "var(--theme-syntax-string)",
|
||||
},
|
||||
tokenColors: [
|
||||
{
|
||||
scope: [
|
||||
"meta.embedded",
|
||||
"source.groovy.embedded",
|
||||
"string meta.image.inline.markdown",
|
||||
"variable.legacy.builtin.python",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-text)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "emphasis",
|
||||
settings: {
|
||||
fontStyle: "italic",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "strong",
|
||||
settings: {
|
||||
fontStyle: "bold",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "header",
|
||||
settings: {
|
||||
foreground: "var(--theme-markdown-heading)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "comment",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-comment)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "constant.language",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"constant.numeric",
|
||||
"variable.other.enummember",
|
||||
"keyword.operator.plus.exponent",
|
||||
"keyword.operator.minus.exponent",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-number)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "constant.regexp",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "entity.name.tag",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["entity.name.tag.css", "entity.name.tag.less"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "entity.other.attribute-name",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-variable)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"entity.other.attribute-name.class.css",
|
||||
"source.css entity.other.attribute-name.class",
|
||||
"entity.other.attribute-name.id.css",
|
||||
"entity.other.attribute-name.parent-selector.css",
|
||||
"entity.other.attribute-name.parent.less",
|
||||
"source.css entity.other.attribute-name.pseudo-class",
|
||||
"entity.other.attribute-name.pseudo-element.css",
|
||||
"source.css.less entity.other.attribute-name.id",
|
||||
"entity.other.attribute-name.scss",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "invalid",
|
||||
settings: {
|
||||
foreground: "var(--theme-error)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.underline",
|
||||
settings: {
|
||||
fontStyle: "underline",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.bold",
|
||||
settings: {
|
||||
fontStyle: "bold",
|
||||
foreground: "var(--theme-markdown-strong)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.heading",
|
||||
settings: {
|
||||
fontStyle: "bold",
|
||||
foreground: "var(--theme-markdown-heading)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.italic",
|
||||
settings: {
|
||||
fontStyle: "italic",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.strikethrough",
|
||||
settings: {
|
||||
fontStyle: "strikethrough",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.inserted",
|
||||
settings: {
|
||||
foreground: "var(--theme-diff-added)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.deleted",
|
||||
settings: {
|
||||
foreground: "var(--theme-diff-removed)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.changed",
|
||||
settings: {
|
||||
foreground: "var(--theme-diff-context)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "punctuation.definition.quote.begin.markdown",
|
||||
settings: {
|
||||
foreground: "var(--theme-markdown-block-quote)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "punctuation.definition.list.begin.markdown",
|
||||
settings: {
|
||||
foreground: "var(--theme-markdown-list-enumeration)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.inline.raw",
|
||||
settings: {
|
||||
foreground: "var(--theme-markdown-code)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "punctuation.definition.tag",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-punctuation)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["meta.preprocessor", "entity.name.function.preprocessor"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "meta.preprocessor.string",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-string)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "meta.preprocessor.numeric",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-number)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "meta.structure.dictionary.key.python",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-variable)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "meta.diff.header",
|
||||
settings: {
|
||||
foreground: "var(--theme-diff-hunk-header)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "storage",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "storage.type",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["storage.modifier", "keyword.operator.noexcept"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["string", "meta.embedded.assembly"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-string)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "string.tag",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-string)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "string.value",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-string)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "string.regexp",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"punctuation.definition.template-expression.begin",
|
||||
"punctuation.definition.template-expression.end",
|
||||
"punctuation.section.embedded",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["meta.template.expression"],
|
||||
settings: {
|
||||
foreground: "var(--theme-text)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"support.type.vendored.property-name",
|
||||
"support.type.property-name",
|
||||
"source.css variable",
|
||||
"source.coffee.embedded",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-variable)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "keyword",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "keyword.control",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "keyword.operator",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"keyword.operator.new",
|
||||
"keyword.operator.expression",
|
||||
"keyword.operator.cast",
|
||||
"keyword.operator.sizeof",
|
||||
"keyword.operator.alignof",
|
||||
"keyword.operator.typeid",
|
||||
"keyword.operator.alignas",
|
||||
"keyword.operator.instanceof",
|
||||
"keyword.operator.logical.python",
|
||||
"keyword.operator.wordlike",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "keyword.other.unit",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-number)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["punctuation.section.embedded.begin.php", "punctuation.section.embedded.end.php"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "support.function.git-rebase",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-variable)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "constant.sha.git-rebase",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-number)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["storage.modifier.import.java", "variable.language.wildcard.java", "storage.modifier.package.java"],
|
||||
settings: {
|
||||
foreground: "var(--theme-text)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "variable.language",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"entity.name.function",
|
||||
"support.function",
|
||||
"support.constant.handlebars",
|
||||
"source.powershell variable.other.member",
|
||||
"entity.name.operator.custom-literal",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-function)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"support.class",
|
||||
"support.type",
|
||||
"entity.name.type",
|
||||
"entity.name.namespace",
|
||||
"entity.other.attribute",
|
||||
"entity.name.scope-resolution",
|
||||
"entity.name.class",
|
||||
"storage.type.numeric.go",
|
||||
"storage.type.byte.go",
|
||||
"storage.type.boolean.go",
|
||||
"storage.type.string.go",
|
||||
"storage.type.uintptr.go",
|
||||
"storage.type.error.go",
|
||||
"storage.type.rune.go",
|
||||
"storage.type.cs",
|
||||
"storage.type.generic.cs",
|
||||
"storage.type.modifier.cs",
|
||||
"storage.type.variable.cs",
|
||||
"storage.type.annotation.java",
|
||||
"storage.type.generic.java",
|
||||
"storage.type.java",
|
||||
"storage.type.object.array.java",
|
||||
"storage.type.primitive.array.java",
|
||||
"storage.type.primitive.java",
|
||||
"storage.type.token.java",
|
||||
"storage.type.groovy",
|
||||
"storage.type.annotation.groovy",
|
||||
"storage.type.parameters.groovy",
|
||||
"storage.type.generic.groovy",
|
||||
"storage.type.object.array.groovy",
|
||||
"storage.type.primitive.array.groovy",
|
||||
"storage.type.primitive.groovy",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-type)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"meta.type.cast.expr",
|
||||
"meta.type.new.expr",
|
||||
"support.constant.math",
|
||||
"support.constant.dom",
|
||||
"support.constant.json",
|
||||
"entity.other.inherited-class",
|
||||
"punctuation.separator.namespace.ruby",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-type)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"keyword.control",
|
||||
"source.cpp keyword.operator.new",
|
||||
"keyword.operator.delete",
|
||||
"keyword.other.using",
|
||||
"keyword.other.directive.using",
|
||||
"keyword.other.operator",
|
||||
"entity.name.operator",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"variable",
|
||||
"meta.definition.variable.name",
|
||||
"support.variable",
|
||||
"entity.name.variable",
|
||||
"constant.other.placeholder",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-variable)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["variable.other.constant", "variable.other.enummember"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-variable)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["meta.object-literal.key"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-variable)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"support.constant.property-value",
|
||||
"support.constant.font-name",
|
||||
"support.constant.media-type",
|
||||
"support.constant.media",
|
||||
"constant.other.color.rgb-value",
|
||||
"constant.other.rgb-value",
|
||||
"support.constant.color",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-string)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"punctuation.definition.group.regexp",
|
||||
"punctuation.definition.group.assertion.regexp",
|
||||
"punctuation.definition.character-class.regexp",
|
||||
"punctuation.character.set.begin.regexp",
|
||||
"punctuation.character.set.end.regexp",
|
||||
"keyword.operator.negation.regexp",
|
||||
"support.other.parenthesis.regexp",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-string)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"constant.character.character-class.regexp",
|
||||
"constant.other.character-class.set.regexp",
|
||||
"constant.other.character-class.regexp",
|
||||
"constant.character.set.regexp",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["keyword.operator.or.regexp", "keyword.control.anchor.regexp"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "keyword.operator.quantifier.regexp",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["constant.character", "constant.other.option"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "constant.character.escape",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "entity.name.label",
|
||||
settings: {
|
||||
foreground: "var(--theme-text-muted)",
|
||||
},
|
||||
},
|
||||
],
|
||||
type: "dark",
|
||||
}
|
||||
|
||||
const highlighter = await createHighlighter({
|
||||
themes: [theme],
|
||||
langs: [],
|
||||
})
|
||||
|
||||
type ShikiContext = typeof highlighter
|
||||
|
||||
const ctx = createContext<ShikiContext>()
|
||||
|
||||
export function ShikiProvider(props: ParentProps) {
|
||||
return <ctx.Provider value={highlighter}>{props.children}</ctx.Provider>
|
||||
}
|
||||
|
||||
export function useShiki() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) {
|
||||
throw new Error("useShiki must be used within a ShikiProvider")
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Message, Agent, Provider, Session, Part, Config, Path, File, FileNode } from "@opencode-ai/sdk"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { useSDK } from "./sdk"
|
||||
import { createContext, Show, useContext, type ParentProps } from "solid-js"
|
||||
import { createContext, createMemo, Show, useContext, type ParentProps } from "solid-js"
|
||||
import { useSDK, useEvent } from "@/context"
|
||||
import { Binary } from "@/utils/binary"
|
||||
|
||||
function init() {
|
||||
@@ -33,83 +33,89 @@ function init() {
|
||||
changes: [],
|
||||
})
|
||||
|
||||
const sdk = useSDK()
|
||||
|
||||
sdk.event.subscribe().then(async (events) => {
|
||||
for await (const event of events.stream) {
|
||||
switch (event.type) {
|
||||
case "session.updated": {
|
||||
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
|
||||
if (result.found) {
|
||||
setStore("session", result.index, reconcile(event.properties.info))
|
||||
break
|
||||
}
|
||||
setStore(
|
||||
"session",
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, event.properties.info)
|
||||
}),
|
||||
)
|
||||
const bus = useEvent()
|
||||
bus.listen((event) => {
|
||||
switch (event.type) {
|
||||
case "session.updated": {
|
||||
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
|
||||
if (result.found) {
|
||||
setStore("session", result.index, reconcile(event.properties.info))
|
||||
break
|
||||
}
|
||||
case "message.updated": {
|
||||
const messages = store.message[event.properties.info.sessionID]
|
||||
if (!messages) {
|
||||
setStore("message", event.properties.info.sessionID, [event.properties.info])
|
||||
break
|
||||
}
|
||||
const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
|
||||
if (result.found) {
|
||||
setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
|
||||
break
|
||||
}
|
||||
setStore(
|
||||
"message",
|
||||
event.properties.info.sessionID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, event.properties.info)
|
||||
}),
|
||||
)
|
||||
setStore(
|
||||
"session",
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, event.properties.info)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
case "message.updated": {
|
||||
const messages = store.message[event.properties.info.sessionID]
|
||||
if (!messages) {
|
||||
setStore("message", event.properties.info.sessionID, [event.properties.info])
|
||||
break
|
||||
}
|
||||
case "message.part.updated": {
|
||||
const parts = store.part[event.properties.part.messageID]
|
||||
if (!parts) {
|
||||
setStore("part", event.properties.part.messageID, [event.properties.part])
|
||||
break
|
||||
}
|
||||
const result = Binary.search(parts, event.properties.part.id, (p) => p.id)
|
||||
if (result.found) {
|
||||
setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part))
|
||||
break
|
||||
}
|
||||
setStore(
|
||||
"part",
|
||||
event.properties.part.messageID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, event.properties.part)
|
||||
}),
|
||||
)
|
||||
const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
|
||||
if (result.found) {
|
||||
setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
|
||||
break
|
||||
}
|
||||
setStore(
|
||||
"message",
|
||||
event.properties.info.sessionID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, event.properties.info)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
case "message.part.updated": {
|
||||
const parts = store.part[event.properties.part.messageID]
|
||||
if (!parts) {
|
||||
setStore("part", event.properties.part.messageID, [event.properties.part])
|
||||
break
|
||||
}
|
||||
const result = Binary.search(parts, event.properties.part.id, (p) => p.id)
|
||||
if (result.found) {
|
||||
setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part))
|
||||
break
|
||||
}
|
||||
setStore(
|
||||
"part",
|
||||
event.properties.part.messageID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, event.properties.part)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Promise.all([
|
||||
sdk.config.providers().then((x) => setStore("provider", x.data!.providers)),
|
||||
sdk.path.get().then((x) => setStore("path", x.data!)),
|
||||
sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
|
||||
sdk.session.list().then((x) =>
|
||||
setStore(
|
||||
"session",
|
||||
(x.data ?? []).slice().sort((a, b) => a.id.localeCompare(b.id)),
|
||||
const sdk = useSDK()
|
||||
|
||||
const load = {
|
||||
provider: () => sdk.config.providers().then((x) => setStore("provider", x.data!.providers)),
|
||||
path: () => sdk.path.get().then((x) => setStore("path", x.data!)),
|
||||
agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
|
||||
session: () =>
|
||||
sdk.session.list().then((x) =>
|
||||
setStore(
|
||||
"session",
|
||||
(x.data ?? []).slice().sort((a, b) => a.id.localeCompare(b.id)),
|
||||
),
|
||||
),
|
||||
),
|
||||
sdk.config.get().then((x) => setStore("config", x.data!)),
|
||||
sdk.file.status().then((x) => setStore("changes", x.data!)),
|
||||
sdk.file.list({ query: { path: "/" } }).then((x) => setStore("node", x.data!)),
|
||||
]).then(() => setStore("ready", true))
|
||||
config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
|
||||
changes: () => sdk.file.status().then((x) => setStore("changes", x.data!)),
|
||||
node: () => sdk.file.list({ query: { path: "/" } }).then((x) => setStore("node", x.data!)),
|
||||
}
|
||||
|
||||
Promise.all(Object.values(load).map((p) => p())).then(() => setStore("ready", true))
|
||||
|
||||
const sanitizer = createMemo(() => new RegExp(`${store.path.directory}/`, "g"))
|
||||
const sanitize = (text: string) => text.replace(sanitizer(), "")
|
||||
const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/")
|
||||
|
||||
return {
|
||||
data: store,
|
||||
@@ -140,6 +146,9 @@ function init() {
|
||||
)
|
||||
},
|
||||
},
|
||||
load,
|
||||
absolute,
|
||||
sanitize,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,19 @@
|
||||
/* color: var(--color-background); */
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--theme-background-panel);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: var(--theme-border-subtle);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-color: var(--theme-border-subtle) var(--theme-background-panel);
|
||||
}
|
||||
|
||||
.prose h1 {
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-sm);
|
||||
|
||||
@@ -4,7 +4,15 @@ import { Router, Route } from "@solidjs/router"
|
||||
import "@/index.css"
|
||||
import Layout from "@/pages/layout"
|
||||
import Home from "@/pages"
|
||||
import { SDKProvider, SyncProvider, LocalProvider, ThemeProvider } from "@/context"
|
||||
import {
|
||||
EventProvider,
|
||||
SDKProvider,
|
||||
SyncProvider,
|
||||
LocalProvider,
|
||||
ThemeProvider,
|
||||
ShikiProvider,
|
||||
MarkedProvider,
|
||||
} from "@/context"
|
||||
|
||||
const root = document.getElementById("root")
|
||||
|
||||
@@ -18,15 +26,21 @@ render(
|
||||
() => (
|
||||
<div class="h-full bg-background text-text-muted">
|
||||
<ThemeProvider defaultTheme="opencode" defaultDarkMode={true}>
|
||||
<SDKProvider>
|
||||
<SyncProvider>
|
||||
<LocalProvider>
|
||||
<Router root={Layout}>
|
||||
<Route path="/" component={Home} />
|
||||
</Router>
|
||||
</LocalProvider>
|
||||
</SyncProvider>
|
||||
</SDKProvider>
|
||||
<ShikiProvider>
|
||||
<MarkedProvider>
|
||||
<SDKProvider>
|
||||
<EventProvider>
|
||||
<SyncProvider>
|
||||
<LocalProvider>
|
||||
<Router root={Layout}>
|
||||
<Route path="/" component={Home} />
|
||||
</Router>
|
||||
</LocalProvider>
|
||||
</SyncProvider>
|
||||
</EventProvider>
|
||||
</SDKProvider>
|
||||
</MarkedProvider>
|
||||
</ShikiProvider>
|
||||
</ThemeProvider>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -1,36 +1,29 @@
|
||||
import { FileIcon, Icon, IconButton, Tooltip } from "@/ui"
|
||||
import { Tabs } from "@/ui/tabs"
|
||||
import FileTree from "@/components/file-tree"
|
||||
import { createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js"
|
||||
import { useLocal, useSDK } from "@/context"
|
||||
import { Code } from "@/components/code"
|
||||
import {
|
||||
DragDropProvider,
|
||||
DragDropSensors,
|
||||
DragOverlay,
|
||||
SortableProvider,
|
||||
createSortable,
|
||||
closestCenter,
|
||||
useDragDropContext,
|
||||
} from "@thisbeyond/solid-dnd"
|
||||
import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
|
||||
import EditorPane from "@/components/editor-pane"
|
||||
import { For, Match, onCleanup, onMount, Show, Switch } from "solid-js"
|
||||
import { SelectDialog } from "@/components/select-dialog"
|
||||
import { useLocal } from "@/context"
|
||||
import { ResizeableLayout, ResizeablePane } from "@/components/resizeable-pane"
|
||||
import type { LocalFile } from "@/context/local"
|
||||
import SessionList from "@/components/session-list"
|
||||
import SessionTimeline from "@/components/session-timeline"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { getDirectory, getFilename } from "@/utils"
|
||||
|
||||
export default function Page() {
|
||||
const sdk = useSDK()
|
||||
const local = useLocal()
|
||||
const [clickTimer, setClickTimer] = createSignal<number | undefined>()
|
||||
const [activeItem, setActiveItem] = createSignal<string | undefined>(undefined)
|
||||
const [inputValue, setInputValue] = createSignal("")
|
||||
const [isDragging, setIsDragging] = createSignal<"left" | "right" | undefined>(undefined)
|
||||
const [leftScrolled, setLeftScrolled] = createSignal(false)
|
||||
const [store, setStore] = createStore({
|
||||
clickTimer: undefined as number | undefined,
|
||||
modelSelectOpen: false,
|
||||
fileSelectOpen: false,
|
||||
})
|
||||
|
||||
// TODO: remove
|
||||
local.model.set({ providerID: "opencode", modelID: "grok-code" })
|
||||
const layoutKey = "workspace"
|
||||
const timelinePane = "timeline"
|
||||
|
||||
let inputRef: HTMLInputElement | undefined = undefined
|
||||
let inputRef: HTMLTextAreaElement | undefined = undefined
|
||||
|
||||
const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
|
||||
|
||||
@@ -42,54 +35,67 @@ export default function Page() {
|
||||
document.removeEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const inputFocused = document.activeElement === inputRef
|
||||
if (inputFocused) {
|
||||
if (e.key === "Escape") {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.getModifierState(MOD) && event.shiftKey && event.key.toLowerCase() === "p") {
|
||||
event.preventDefault()
|
||||
// TODO: command palette
|
||||
return
|
||||
}
|
||||
if (event.getModifierState(MOD) && event.key.toLowerCase() === "p") {
|
||||
event.preventDefault()
|
||||
setStore("fileSelectOpen", true)
|
||||
return
|
||||
}
|
||||
|
||||
const focused = document.activeElement === inputRef
|
||||
if (focused) {
|
||||
if (event.key === "Escape") {
|
||||
inputRef?.blur()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (local.file.active()) {
|
||||
if (e.getModifierState(MOD)) {
|
||||
if (e.key.toLowerCase() === "a") {
|
||||
const active = local.file.active()!
|
||||
if (event.key === "Enter" && active.selection) {
|
||||
local.context.add({
|
||||
type: "file",
|
||||
path: active.path,
|
||||
selection: { ...active.selection },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (event.getModifierState(MOD)) {
|
||||
if (event.key.toLowerCase() === "a") {
|
||||
return
|
||||
}
|
||||
if (e.key.toLowerCase() === "c") {
|
||||
if (event.key.toLowerCase() === "c") {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key.length === 1 && e.key !== "Unidentified") {
|
||||
if (event.key.length === 1 && event.key !== "Unidentified") {
|
||||
inputRef?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const navigateChange = (dir: 1 | -1) => {
|
||||
const active = local.file.active()
|
||||
if (!active) return
|
||||
const current = local.file.changeIndex(active.path)
|
||||
const next = current == undefined ? (dir === 1 ? 0 : -1) : current + dir
|
||||
local.file.setChangeIndex(active.path, next)
|
||||
}
|
||||
|
||||
const resetClickTimer = () => {
|
||||
if (!clickTimer()) return
|
||||
clearTimeout(clickTimer())
|
||||
setClickTimer(undefined)
|
||||
if (!store.clickTimer) return
|
||||
clearTimeout(store.clickTimer)
|
||||
setStore("clickTimer", undefined)
|
||||
}
|
||||
|
||||
const startClickTimer = () => {
|
||||
const newClickTimer = setTimeout(() => {
|
||||
setClickTimer(undefined)
|
||||
setStore("clickTimer", undefined)
|
||||
}, 300)
|
||||
setClickTimer(newClickTimer as unknown as number)
|
||||
setStore("clickTimer", newClickTimer as unknown as number)
|
||||
}
|
||||
|
||||
const handleFileClick = async (file: LocalFile) => {
|
||||
if (clickTimer()) {
|
||||
if (store.clickTimer) {
|
||||
resetClickTimer()
|
||||
local.file.update(file.path, { ...file, pinned: true })
|
||||
} else {
|
||||
@@ -98,191 +104,87 @@ export default function Page() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleTabChange = (path: string) => {
|
||||
local.file.open(path)
|
||||
}
|
||||
|
||||
const handleTabClose = (file: LocalFile) => {
|
||||
local.file.close(file.path)
|
||||
}
|
||||
|
||||
const onDragStart = (event: any) => {
|
||||
setActiveItem(event.draggable.id as string)
|
||||
}
|
||||
|
||||
const onDragOver = (event: DragEvent) => {
|
||||
const { draggable, droppable } = event
|
||||
if (draggable && droppable) {
|
||||
const currentFiles = local.file.opened().map((f) => f.path)
|
||||
const fromIndex = currentFiles.indexOf(draggable.id.toString())
|
||||
const toIndex = currentFiles.indexOf(droppable.id.toString())
|
||||
if (fromIndex !== toIndex) {
|
||||
local.file.move(draggable.id.toString(), toIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onDragEnd = () => {
|
||||
setActiveItem(undefined)
|
||||
}
|
||||
|
||||
const handleLeftDragStart = (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging("left")
|
||||
const startX = e.clientX
|
||||
const startWidth = local.layout.leftWidth()
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const deltaX = e.clientX - startX
|
||||
const newWidth = startWidth + deltaX
|
||||
local.layout.setLeftWidth(newWidth)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(undefined)
|
||||
document.removeEventListener("mousemove", handleMouseMove)
|
||||
document.removeEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove)
|
||||
document.addEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
|
||||
const handleRightDragStart = (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging("right")
|
||||
const startX = e.clientX
|
||||
const startWidth = local.layout.rightWidth()
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const deltaX = startX - e.clientX
|
||||
const newWidth = startWidth + deltaX
|
||||
local.layout.setRightWidth(newWidth)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(undefined)
|
||||
document.removeEventListener("mousemove", handleMouseMove)
|
||||
document.removeEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove)
|
||||
document.addEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: SubmitEvent) => {
|
||||
e.preventDefault()
|
||||
const prompt = inputValue()
|
||||
setInputValue("")
|
||||
inputRef?.blur()
|
||||
|
||||
const session =
|
||||
(local.layout.rightPane() ? local.session.active() : undefined) ??
|
||||
(await sdk.session.create().then((x) => x.data!))
|
||||
local.session.setActive(session!.id)
|
||||
local.layout.openRightPane()
|
||||
|
||||
const response = await sdk.session.prompt({
|
||||
path: { id: session!.id },
|
||||
body: {
|
||||
agent: local.agent.current()!.name,
|
||||
model: local.model.current(),
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: prompt,
|
||||
},
|
||||
...local.file
|
||||
.opened()
|
||||
.filter((f) => f.selection || local.file.active()?.path === f.path)
|
||||
.flatMap((f) => [
|
||||
{
|
||||
type: "file" as const,
|
||||
mime: "text/plain",
|
||||
url: `file://${f.absolute}${f.selection ? `?start=${f.selection.startLine}&end=${f.selection.endLine}` : ""}`,
|
||||
filename: f.name,
|
||||
source: {
|
||||
type: "file" as const,
|
||||
text: {
|
||||
value: "@" + f.name,
|
||||
start: 0, // f.start,
|
||||
end: 0, // f.end,
|
||||
},
|
||||
path: f.absolute,
|
||||
},
|
||||
},
|
||||
]),
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
console.log("response", response)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="relative">
|
||||
<div
|
||||
class="fixed top-0 left-0 h-full border-r border-border-subtle/30 flex flex-col overflow-hidden"
|
||||
style={`width: ${local.layout.leftWidth()}px`}
|
||||
<ResizeableLayout
|
||||
id={layoutKey}
|
||||
defaults={{
|
||||
explorer: { size: 24, visible: true },
|
||||
editor: { size: 56, visible: true },
|
||||
timeline: { size: 20, visible: false },
|
||||
}}
|
||||
class="h-screen"
|
||||
>
|
||||
<Tabs class="relative flex flex-col h-full" defaultValue="files">
|
||||
<div class="sticky top-0 shrink-0 flex">
|
||||
<Tabs.List class="grow w-full after:hidden">
|
||||
<Tabs.Trigger value="files" class="flex-1 justify-center">
|
||||
Files
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="changes" class="flex-1 justify-center">
|
||||
Changes
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</div>
|
||||
<Tabs.Content
|
||||
value="files"
|
||||
class="grow min-h-0 py-2 bg-background"
|
||||
onScroll={(e: Event & { currentTarget: HTMLDivElement }) => setLeftScrolled(e.currentTarget.scrollTop > 0)}
|
||||
>
|
||||
<FileTree path="" onFileClick={handleFileClick} />
|
||||
<Show when={leftScrolled()}>
|
||||
<div
|
||||
class="pointer-events-none sticky top-20 left-px h-4
|
||||
bg-gradient-to-t from-transparent to-background"
|
||||
style={`width: ${local.layout.leftWidth() - 2}px`}
|
||||
/>
|
||||
</Show>
|
||||
<div
|
||||
class="pointer-events-none fixed bottom-0 left-px h-4
|
||||
bg-gradient-to-b from-transparent to-background"
|
||||
style={`width: ${local.layout.leftWidth() - 2}px`}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="changes" class="grow min-h-0 py-2 bg-background">
|
||||
<div class="px-2 text-sm text-text-muted">No changes yet</div>
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div
|
||||
class="fixed top-0 h-full w-1.5 bg-transparent cursor-col-resize z-50 group"
|
||||
style={`left: ${local.layout.leftWidth()}px`}
|
||||
onMouseDown={(e) => handleLeftDragStart(e)}
|
||||
>
|
||||
<div
|
||||
class="w-0.5 h-full bg-transparent group-hover:bg-border-active transition-colors"
|
||||
classList={{
|
||||
"bg-border-active": isDragging() === "left",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Show when={local.layout.rightPane()}>
|
||||
<div
|
||||
class="fixed top-0 right-0 h-full border-l border-border-subtle/30 flex flex-col overflow-hidden"
|
||||
style={`width: ${local.layout.rightWidth()}px`}
|
||||
<ResizeablePane
|
||||
id="explorer"
|
||||
minSize="150px"
|
||||
maxSize="300px"
|
||||
class="border-r border-border-subtle/30 bg-background z-10 overflow-hidden"
|
||||
>
|
||||
<div class="relative flex-1 min-h-0 overflow-y-auto no-scrollbar">
|
||||
<Tabs class="relative flex flex-col h-full" defaultValue="files">
|
||||
<div class="sticky top-0 shrink-0 flex">
|
||||
<Tabs.List class="grow w-full after:hidden">
|
||||
<Tabs.Trigger value="files" class="flex-1 justify-center text-xs">
|
||||
Files
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="changes" class="flex-1 justify-center text-xs">
|
||||
Changes
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</div>
|
||||
<Tabs.Content value="files" class="grow min-h-0 py-2 bg-background">
|
||||
<FileTree path="" onFileClick={handleFileClick} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="changes" class="grow min-h-0 py-2 bg-background">
|
||||
<Show
|
||||
when={local.file.changes().length}
|
||||
fallback={<div class="px-2 text-xs text-text-muted">No changes</div>}
|
||||
>
|
||||
<ul class="">
|
||||
<For each={local.file.changes()}>
|
||||
{(path) => (
|
||||
<li>
|
||||
<button
|
||||
onClick={() => local.file.open(path, { view: "diff-unified", pinned: true })}
|
||||
class="w-full flex items-center px-2 py-0.5 gap-x-2 text-text-muted grow min-w-0 cursor-pointer hover:bg-background-element"
|
||||
>
|
||||
<FileIcon node={{ path, type: "file" }} class="shrink-0 size-3" />
|
||||
<span class="text-xs text-text whitespace-nowrap">{getFilename(path)}</span>
|
||||
<span class="text-xs text-text-muted/60 whitespace-nowrap truncate min-w-0">
|
||||
{getDirectory(path)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</Show>
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</ResizeablePane>
|
||||
<ResizeablePane id="editor" minSize={30} maxSize={80} class="bg-background">
|
||||
<EditorPane
|
||||
layoutKey={layoutKey}
|
||||
timelinePane={timelinePane}
|
||||
onFileClick={handleFileClick}
|
||||
onOpenModelSelect={() => setStore("modelSelectOpen", true)}
|
||||
onInputRefChange={(element: HTMLTextAreaElement | null) => {
|
||||
inputRef = element ?? undefined
|
||||
}}
|
||||
/>
|
||||
</ResizeablePane>
|
||||
<ResizeablePane
|
||||
id="timeline"
|
||||
minSize={20}
|
||||
maxSize={40}
|
||||
class="border-l border-border-subtle/30 bg-background z-10 overflow-hidden"
|
||||
>
|
||||
<div class="relative flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
|
||||
<Show when={local.session.active()} fallback={<SessionList />}>
|
||||
{(activeSession) => (
|
||||
<div class="relative">
|
||||
<div class="sticky top-0 bg-background z-50 p-2 h-9 border-b border-border-subtle/30">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="sticky top-0 bg-background z-50 px-2 h-8 border-b border-border-subtle/30">
|
||||
<div class="h-full flex items-center gap-2">
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
@@ -300,309 +202,77 @@ export default function Page() {
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<div
|
||||
class="pointer-events-none fixed top-0 right-px h-4
|
||||
bg-gradient-to-t from-transparent to-background"
|
||||
style={`width: ${local.layout.rightWidth() - 2}px`}
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none fixed bottom-0 right-px h-4
|
||||
bg-gradient-to-b from-transparent to-background"
|
||||
style={`width: ${local.layout.rightWidth() - 2}px`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="fixed top-0 h-full w-1.5 bg-transparent cursor-col-resize z-50 group flex justify-end"
|
||||
style={`right: ${local.layout.rightWidth()}px`}
|
||||
onMouseDown={(e) => handleRightDragStart(e)}
|
||||
>
|
||||
<div
|
||||
class="w-0.5 h-full bg-transparent group-hover:bg-border-active transition-colors"
|
||||
classList={{ "bg-border-active": isDragging() === "right" }}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
style={`margin-left: ${local.layout.leftWidth()}px; margin-right: ${local.layout.rightPane() ? local.layout.rightWidth() : 0}px`}
|
||||
>
|
||||
<DragDropProvider
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={onDragOver}
|
||||
collisionDetector={closestCenter}
|
||||
>
|
||||
<DragDropSensors />
|
||||
<ConstrainDragYAxis />
|
||||
<Tabs
|
||||
class="relative grow w-full flex flex-col h-screen"
|
||||
value={local.file.active()?.path}
|
||||
onChange={handleTabChange}
|
||||
>
|
||||
<div class="sticky top-0 shrink-0 flex">
|
||||
<Tabs.List class="grow">
|
||||
<SortableProvider ids={local.file.opened().map((f) => f.path)}>
|
||||
<For each={local.file.opened()}>
|
||||
{(file) => <SortableTab file={file} onTabClick={handleFileClick} onTabClose={handleTabClose} />}
|
||||
</For>
|
||||
</SortableProvider>
|
||||
</Tabs.List>
|
||||
<div class="shrink-0 h-full flex items-center gap-1 px-2 border-b border-border-subtle/40">
|
||||
<Show when={local.file.active() && local.file.active()!.content?.diff}>
|
||||
{(() => {
|
||||
const f = local.file.active()!
|
||||
const view = local.file.view(f.path)
|
||||
return (
|
||||
<div class="flex items-center gap-1">
|
||||
<Show when={view !== "raw"}>
|
||||
<div class="mr-1 flex items-center gap-1">
|
||||
<Tooltip value="Previous change" placement="bottom">
|
||||
<IconButton size="xs" variant="ghost" onClick={() => navigateChange(-1)}>
|
||||
<Icon name="arrow-up" size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip value="Next change" placement="bottom">
|
||||
<IconButton size="xs" variant="ghost" onClick={() => navigateChange(1)}>
|
||||
<Icon name="arrow-down" size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Show>
|
||||
<Tooltip value="Raw" placement="bottom">
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
classList={{
|
||||
"text-text": view === "raw",
|
||||
"text-text-muted/70": view !== "raw",
|
||||
"bg-background-element": view === "raw",
|
||||
}}
|
||||
onClick={() => local.file.setView(f.path, "raw")}
|
||||
>
|
||||
<Icon name="file-text" size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip value="Unified diff" placement="bottom">
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
classList={{
|
||||
"text-text": view === "diff-unified",
|
||||
"text-text-muted/70": view !== "diff-unified",
|
||||
"bg-background-element": view === "diff-unified",
|
||||
}}
|
||||
onClick={() => local.file.setView(f.path, "diff-unified")}
|
||||
>
|
||||
<Icon name="checklist" size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip value="Split diff" placement="bottom">
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
classList={{
|
||||
"text-text": view === "diff-split",
|
||||
"text-text-muted/70": view !== "diff-split",
|
||||
"bg-background-element": view === "diff-split",
|
||||
}}
|
||||
onClick={() => local.file.setView(f.path, "diff-split")}
|
||||
>
|
||||
<Icon name="columns" size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</Show>
|
||||
<Tooltip value={local.layout.rightPane() ? "Close pane" : "Open pane"} placement="bottom">
|
||||
<IconButton size="xs" variant="ghost" onClick={() => local.layout.toggleRightPane()}>
|
||||
<Icon name={local.layout.rightPane() ? "close-pane" : "open-pane"} size={14} />
|
||||
</IconButton>
|
||||
</ResizeablePane>
|
||||
</ResizeableLayout>
|
||||
<Show when={store.modelSelectOpen}>
|
||||
<SelectDialog
|
||||
key={(x) => `${x.provider.id}:${x.id}`}
|
||||
items={local.model.list()}
|
||||
current={local.model.current()}
|
||||
render={(i) => (
|
||||
<div class="w-full flex items-center justify-between">
|
||||
<div class="flex items-center gap-x-2 text-text-muted grow min-w-0">
|
||||
<img src={`https://models.dev/logos/${i.provider.id}.svg`} class="size-4 invert opacity-40" />
|
||||
<span class="text-xs text-text whitespace-nowrap">{i.name}</span>
|
||||
<span class="text-xs text-text-muted/80 whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
|
||||
{i.id}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-x-1 text-text-muted/40 shrink-0">
|
||||
<Tooltip forceMount={false} value="Reasoning">
|
||||
<Icon name="brain" size={16} classList={{ "text-accent": i.reasoning }} />
|
||||
</Tooltip>
|
||||
<Tooltip forceMount={false} value="Tools">
|
||||
<Icon name="hammer" size={16} classList={{ "text-secondary": i.tool_call }} />
|
||||
</Tooltip>
|
||||
<Tooltip forceMount={false} value="Attachments">
|
||||
<Icon name="photo" size={16} classList={{ "text-success": i.attachment }} />
|
||||
</Tooltip>
|
||||
<div class="rounded-full bg-text-muted/20 text-text-muted/80 w-9 h-4 flex items-center justify-center text-[10px]">
|
||||
{new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
compactDisplay: "short",
|
||||
}).format(i.limit.context)}
|
||||
</div>
|
||||
<Tooltip forceMount={false} value={`$${i.cost?.input}/1M input, $${i.cost?.output}/1M output`}>
|
||||
<div class="rounded-full bg-success/20 text-success/80 w-9 h-4 flex items-center justify-center text-[10px]">
|
||||
<Switch fallback="FREE">
|
||||
<Match when={i.cost?.input > 10}>$$$</Match>
|
||||
<Match when={i.cost?.input > 1}>$$</Match>
|
||||
<Match when={i.cost?.input > 0.1}>$</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<For each={local.file.opened()}>
|
||||
{(file) => (
|
||||
<Tabs.Content value={file.path} class="grow h-full pt-1 select-text">
|
||||
{(() => {
|
||||
const view = local.file.view(file.path)
|
||||
const showRaw = view === "raw" || !file.content?.diff
|
||||
const code = showRaw ? (file.content?.content ?? "") : (file.content?.diff ?? "")
|
||||
return <Code path={file.path} code={code} />
|
||||
})()}
|
||||
</Tabs.Content>
|
||||
)}
|
||||
</For>
|
||||
</Tabs>
|
||||
<DragOverlay>
|
||||
{activeItem() &&
|
||||
(() => {
|
||||
const draggedFile = local.file.node(activeItem()!)
|
||||
return (
|
||||
<div
|
||||
class="relative px-3 h-9 flex items-center
|
||||
text-sm font-medium text-text whitespace-nowrap
|
||||
shrink-0 bg-background-panel
|
||||
border-x border-border-subtle/40 border-b border-b-transparent"
|
||||
>
|
||||
<TabVisual file={draggedFile} />
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
class="peer/editor absolute bottom-8 z-50 flex items-center justify-center"
|
||||
style={`left: ${local.layout.leftWidth() + 40}px; right: ${local.layout.rightPane() ? local.layout.rightWidth() + 40 : 40}px`}
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-2xl min-w-1/2 p-2 mx-auto rounded-lg isolate backdrop-blur-xs
|
||||
flex flex-col gap-1
|
||||
bg-gradient-to-b from-background-panel/90 to-background/90
|
||||
ring-1 ring-border-active/50 border border-transparent
|
||||
shadow-[0_0_33px_rgba(0,0,0,0.8)]
|
||||
focus-within:ring-2 focus-within:ring-primary/40 focus-within:border-primary"
|
||||
>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Show when={local.file.active()}>
|
||||
<FileTag
|
||||
default
|
||||
file={local.file.active()!}
|
||||
onClose={() => local.file.close(local.file.active()?.path ?? "")}
|
||||
/>
|
||||
</Show>
|
||||
<For each={local.file.opened().filter((x) => x.selection)}>
|
||||
{(file) => <FileTag file={file} onClose={() => local.file.select(file.path, undefined)} />}
|
||||
</For>
|
||||
</div>
|
||||
<input
|
||||
ref={(el) => (inputRef = el)}
|
||||
type="text"
|
||||
value={inputValue()}
|
||||
onInput={(e) => setInputValue(e.currentTarget.value)}
|
||||
placeholder="It all starts with a prompt..."
|
||||
class="w-full p-1 pb-4 text-text font-light placeholder-text-muted/70 text-sm focus:outline-none"
|
||||
/>
|
||||
<div class="px-1 flex justify-between items-center text-xs text-text-muted">
|
||||
<span>
|
||||
<span class="text-primary uppercase">{local.agent.current()?.name ?? "unknown"}</span> /{" "}
|
||||
{local.model.parsed().provider} / {local.model.parsed().model}
|
||||
</span>
|
||||
<div class="flex gap-1 items-center">
|
||||
<IconButton class="text-text-muted" size="xs" variant="ghost">
|
||||
<Icon name="photo" size={16} />
|
||||
</IconButton>
|
||||
<IconButton class="text-background-panel! bg-primary rounded-full!" size="xs" variant="ghost">
|
||||
<Icon name="arrow-up" size={14} />
|
||||
</IconButton>
|
||||
)}
|
||||
filter={["provider.name", "name", "id"]}
|
||||
groupBy={(x) => x.provider.name}
|
||||
onClose={() => setStore("modelSelectOpen", false)}
|
||||
onSelect={(x) => local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined)}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={store.fileSelectOpen}>
|
||||
<SelectDialog
|
||||
items={local.file.search}
|
||||
key={(x) => x}
|
||||
render={(i) => (
|
||||
<div class="w-full flex items-center justify-between">
|
||||
<div class="flex items-center gap-x-2 text-text-muted grow min-w-0">
|
||||
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
|
||||
<span class="text-xs text-text whitespace-nowrap">{getFilename(i)}</span>
|
||||
<span class="text-xs text-text-muted/80 whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
|
||||
{getDirectory(i)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TabVisual = (props: { file: LocalFile }) => {
|
||||
const local = useLocal()
|
||||
return (
|
||||
<div class="flex items-center gap-x-1.5">
|
||||
<FileIcon node={props.file} class="" />
|
||||
<span
|
||||
classList={{ "text-xs": true, "text-primary": local.file.changed(props.file.path), italic: !props.file.pinned }}
|
||||
>
|
||||
{props.file.name}
|
||||
</span>
|
||||
<span class="text-xs opacity-70">
|
||||
<Switch>
|
||||
<Match when={local.file.status(props.file.path)?.status === "modified"}>
|
||||
<span class="text-primary">M</span>
|
||||
</Match>
|
||||
<Match when={local.file.status(props.file.path)?.status === "added"}>
|
||||
<span class="text-success">A</span>
|
||||
</Match>
|
||||
<Match when={local.file.status(props.file.path)?.status === "deleted"}>
|
||||
<span class="text-error">D</span>
|
||||
</Match>
|
||||
</Switch>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SortableTab = (props: {
|
||||
file: LocalFile
|
||||
onTabClick: (file: LocalFile) => void
|
||||
onTabClose: (file: LocalFile) => void
|
||||
}) => {
|
||||
const sortable = createSortable(props.file.path)
|
||||
|
||||
return (
|
||||
// @ts-ignore
|
||||
<div use:sortable classList={{ "opacity-0": sortable.isActiveDraggable }}>
|
||||
<Tooltip value={props.file.path} placement="bottom">
|
||||
<div class="relative">
|
||||
<Tabs.Trigger value={props.file.path} class="peer/tab pr-7" onClick={() => props.onTabClick(props.file)}>
|
||||
<TabVisual file={props.file} />
|
||||
</Tabs.Trigger>
|
||||
<IconButton
|
||||
class="absolute right-1 top-2 opacity-0 text-text-muted/60
|
||||
peer-data-[selected]/tab:opacity-100 peer-data-[selected]/tab:text-text
|
||||
peer-data-[selected]/tab:hover:bg-border-subtle
|
||||
hover:opacity-100 peer-hover/tab:opacity-100"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={() => props.onTabClose(props.file)}
|
||||
>
|
||||
<Icon name="close" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const FileTag = (props: { file: LocalFile; default?: boolean; onClose: () => void }) => (
|
||||
<div
|
||||
class="flex items-center bg-background group/tag
|
||||
border border-border-subtle/60 border-dashed
|
||||
rounded-md text-xs text-text-muted"
|
||||
>
|
||||
<IconButton class="text-text-muted" size="xs" variant="ghost" onClick={props.onClose}>
|
||||
<Switch fallback={<FileIcon node={props.file} class="group-hover/tag:hidden size-3!" />}>
|
||||
<Match when={props.default}>
|
||||
<Icon name="file" class="group-hover/tag:hidden" size={12} />
|
||||
</Match>
|
||||
</Switch>
|
||||
<Icon name="close" class="hidden group-hover/tag:block" size={12} />
|
||||
</IconButton>
|
||||
<div class="pr-1 flex gap-1 items-center">
|
||||
<span>{props.file.name}</span>
|
||||
<Show when={!props.default && props.file.selection}>
|
||||
<span class="">
|
||||
({props.file.selection!.startLine}-{props.file.selection!.endLine})
|
||||
</span>
|
||||
)}
|
||||
onClose={() => setStore("fileSelectOpen", false)}
|
||||
onSelect={(x) => (x ? local.file.open(x, { pinned: true }) : undefined)}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const ConstrainDragYAxis = () => {
|
||||
const context = useDragDropContext()
|
||||
if (!context) return <></>
|
||||
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
|
||||
const transformer: Transformer = {
|
||||
id: "constrain-y-axis",
|
||||
order: 100,
|
||||
callback: (transform) => ({ ...transform, y: 0 }),
|
||||
}
|
||||
onDragStart((event: any) => {
|
||||
addTransformer("draggables", event.draggable.id, transformer)
|
||||
})
|
||||
onDragEnd((event: any) => {
|
||||
removeTransformer("draggables", event.draggable.id, transformer.id)
|
||||
})
|
||||
return <></>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/// <reference types="vite/client" />
|
||||
interface ImportMetaEnv {}
|
||||
interface ImportMetaEnv {
|
||||
|
||||
}
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
}
|
||||
@@ -1,49 +1,36 @@
|
||||
import { Button as KobalteButton } from "@kobalte/core/button"
|
||||
import { splitProps } from "solid-js"
|
||||
import type { ComponentProps } from "solid-js"
|
||||
import { Button as Kobalte } from "@kobalte/core/button"
|
||||
import { type ComponentProps, splitProps } from "solid-js"
|
||||
|
||||
export interface ButtonProps extends ComponentProps<typeof KobalteButton> {
|
||||
variant?: "primary" | "secondary" | "outline" | "ghost"
|
||||
export interface ButtonProps {
|
||||
variant?: "primary" | "secondary" | "ghost"
|
||||
size?: "sm" | "md" | "lg"
|
||||
}
|
||||
|
||||
export const buttonStyles = {
|
||||
base: "inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 cursor-pointer",
|
||||
variants: {
|
||||
primary: "bg-primary text-background hover:bg-secondary focus-visible:ring-primary data-[disabled]:opacity-50",
|
||||
secondary:
|
||||
"bg-background-panel text-text hover:bg-background-element focus-visible:ring-secondary data-[disabled]:opacity-50",
|
||||
outline:
|
||||
"border border-border bg-transparent text-text hover:bg-background-panel focus-visible:ring-border-active data-[disabled]:border-border-subtle data-[disabled]:text-text-muted",
|
||||
ghost: "text-text hover:bg-background-panel focus-visible:ring-border-active data-[disabled]:text-text-muted",
|
||||
},
|
||||
sizes: {
|
||||
sm: "h-8 px-3 text-sm",
|
||||
md: "h-10 px-4 text-sm",
|
||||
lg: "h-12 px-6 text-base",
|
||||
},
|
||||
}
|
||||
|
||||
export function getButtonClasses(
|
||||
variant: keyof typeof buttonStyles.variants = "primary",
|
||||
size: keyof typeof buttonStyles.sizes = "md",
|
||||
className?: string,
|
||||
) {
|
||||
return `${buttonStyles.base} ${buttonStyles.variants[variant]} ${buttonStyles.sizes[size]}${className ? ` ${className}` : ""}`
|
||||
}
|
||||
|
||||
export function Button(props: ButtonProps) {
|
||||
const [local, others] = splitProps(props, ["variant", "size", "class", "classList"])
|
||||
export function Button(props: ComponentProps<"button"> & ButtonProps) {
|
||||
const [split, rest] = splitProps(props, ["variant", "size", "class", "classList"])
|
||||
return (
|
||||
<KobalteButton
|
||||
<Kobalte
|
||||
{...rest}
|
||||
data-size={split.size || "sm"}
|
||||
data-variant={split.variant || "secondary"}
|
||||
class="inline-flex items-center justify-center rounded-md cursor-pointer font-medium transition-colors
|
||||
min-w-0 whitespace-nowrap truncate
|
||||
data-[size=sm]:h-6 data-[size=sm]:pl-2 data-[size=sm]:text-xs
|
||||
data-[size=md]:h-8 data-[size=md]:pl-3 data-[size=md]:text-sm
|
||||
data-[size=lg]:h-10 data-[size=lg]:pl-4 data-[size=lg]:text-base
|
||||
data-[variant=primary]:bg-primary data-[variant=primary]:text-background
|
||||
data-[variant=primary]:hover:bg-secondary data-[variant=primary]:focus-visible:ring-primary
|
||||
data-[variant=secondary]:bg-background-element data-[variant=secondary]:text-text
|
||||
data-[variant=secondary]:hover:bg-background-element data-[variant=secondary]:focus-visible:ring-secondary
|
||||
data-[variant=ghost]:text-text data-[variant=ghost]:hover:bg-background-panel data-[variant=ghost]:focus-visible:ring-border-active
|
||||
focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-offset-transparent
|
||||
disabled:pointer-events-none disabled:opacity-50"
|
||||
classList={{
|
||||
...(local.classList ?? {}),
|
||||
[buttonStyles.base]: true,
|
||||
[buttonStyles.variants[local.variant || "primary"]]: true,
|
||||
[buttonStyles.sizes[local.size || "md"]]: true,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
...(split.classList ?? {}),
|
||||
[split.class ?? ""]: !!split.class,
|
||||
}}
|
||||
{...others}
|
||||
/>
|
||||
>
|
||||
{props.children}
|
||||
</Kobalte>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -118,13 +118,19 @@ const icons = {
|
||||
message: '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 18.25C15.5 18.25 19.25 16.5 19.25 12C19.25 7.5 15.5 5.75 12 5.75C8.5 5.75 4.75 7.5 4.75 12C4.75 13.0298 4.94639 13.9156 5.29123 14.6693C5.50618 15.1392 5.62675 15.6573 5.53154 16.1651L5.26934 17.5635C5.13974 18.2547 5.74527 18.8603 6.43651 18.7307L9.64388 18.1293C9.896 18.082 10.1545 18.0861 10.4078 18.1263C10.935 18.2099 11.4704 18.25 12 18.25Z"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M9.5 12C9.5 12.2761 9.27614 12.5 9 12.5C8.72386 12.5 8.5 12.2761 8.5 12C8.5 11.7239 8.72386 11.5 9 11.5C9.27614 11.5 9.5 11.7239 9.5 12Z"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M12.5 12C12.5 12.2761 12.2761 12.5 12 12.5C11.7239 12.5 11.5 12.2761 11.5 12C11.5 11.7239 11.7239 11.5 12 11.5C12.2761 11.5 12.5 11.7239 12.5 12Z"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M15.5 12C15.5 12.2761 15.2761 12.5 15 12.5C14.7239 12.5 14.5 12.2761 14.5 12C14.5 11.7239 14.7239 11.5 15 11.5C15.2761 11.5 15.5 11.7239 15.5 12Z"></path>',
|
||||
annotation: '<path d="M4.75 6.75C4.75 5.64543 5.64543 4.75 6.75 4.75H17.25C18.3546 4.75 19.25 5.64543 19.25 6.75V15.25C19.25 16.3546 18.3546 17.25 17.25 17.25H14.625L12 19.25L9.375 17.25H6.75C5.64543 17.25 4.75 16.3546 4.75 15.25V6.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>',
|
||||
square: '<path d="M17.2502 19.25H6.75C5.64543 19.25 4.75 18.3546 4.75 17.25V6.75C4.75 5.64543 5.64543 4.75 6.75 4.75H17.2502C18.3548 4.75 19.2502 5.64543 19.2502 6.75V17.25C19.2502 18.3546 18.3548 19.25 17.2502 19.25Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>',
|
||||
"pull-request": '<path d="M9.25 7C9.25 8.24264 8.24264 9.25 7 9.25C5.75736 9.25 4.75 8.24264 4.75 7C4.75 5.75736 5.75736 4.75 7 4.75C8.24264 4.75 9.25 5.75736 9.25 7Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M9.25 17C9.25 18.2426 8.24264 19.25 7 19.25C5.75736 19.25 4.75 18.2426 4.75 17C4.75 15.7574 5.75736 14.75 7 14.75C8.24264 14.75 9.25 15.7574 9.25 17Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M19.25 17C19.25 18.2426 18.2426 19.25 17 19.25C15.7574 19.25 14.75 18.2426 14.75 17C14.75 15.7574 15.7574 14.75 17 14.75C18.2426 14.75 19.25 15.7574 19.25 17Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M6.75 9.5V14.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M17.25 14.25V10.4701C17.25 9.83943 17.0513 9.22483 16.682 8.71359L14 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M13.75 8.25V4.75H17.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>',
|
||||
"pull-request": '<path d="M9.25 7C9.25 8.24264 8.24264 9.25 7 9.25C5.75736 9.25 4.75 8.24264 4.75 7C4.75 5.75736 5.75736 4.75 7 4.75C8.24264 4.75 9.25 5.75736 9.25 7Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M9.25 17C9.25 18.2426 8.24264 19.25 7 19.25C5.75736 19.25 4.75 18.2426 4.75 17C4.75 15.7574 5.75736 14.75 7 14.75C8.24264 14.75 9.25 15.7574 9.25 17Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M19.25 17C19.25 18.2426 18.2426 19.25 17 19.25C15.7574 19.25 14.75 18.2426 14.75 17C14.75 15.7574 15.7574 14.75 17 14.75C18.2426 14.75 19.25 15.7574 19.25 17Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M6.75 9.5V14.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M17.25 14.25V10.4701C17.25 9.83943 17.0513 9.22483 16.682 8.71359L14 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M13.75 8.25V4.75H17.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>',
|
||||
pencil: '<path d="M4.75 19.25L9 18.25L18.9491 8.30083C19.3397 7.9103 19.3397 7.27714 18.9491 6.88661L17.1134 5.05083C16.7228 4.6603 16.0897 4.6603 15.6991 5.05083L5.75 15L4.75 19.25Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M14.0234 7.03906L17.0234 10.0391" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>',
|
||||
sparkles: '<path d="M17 4.75C17 5.89705 15.8971 7 14.75 7C15.8971 7 17 8.10295 17 9.25C17 8.10295 18.1029 7 19.25 7C18.1029 7 17 5.89705 17 4.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M17 14.75C17 15.8971 15.8971 17 14.75 17C15.8971 17 17 18.1029 17 19.25C17 18.1029 18.1029 17 19.25 17C18.1029 17 17 15.8971 17 14.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M9 7.75C9 9.91666 6.91666 12 4.75 12C6.91666 12 9 14.0833 9 16.25C9 14.0833 11.0833 12 13.25 12C11.0833 12 9 9.91666 9 7.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>',
|
||||
photo: '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4.75 16L7.49619 12.5067C8.2749 11.5161 9.76453 11.4837 10.5856 12.4395L13 15.25M10.915 12.823C11.9522 11.5037 13.3973 9.63455 13.4914 9.51294C13.4947 9.50859 13.4979 9.50448 13.5013 9.50017C14.2815 8.51598 15.7663 8.48581 16.5856 9.43947L19 12.25M6.75 19.25H17.25C18.3546 19.25 19.25 18.3546 19.25 17.25V6.75C19.25 5.64543 18.3546 4.75 17.25 4.75H6.75C5.64543 4.75 4.75 5.64543 4.75 6.75V17.25C4.75 18.3546 5.64543 19.25 6.75 19.25Z"></path>',
|
||||
columns: '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4.75 6.75C4.75 5.64543 5.64543 4.75 6.75 4.75H17.25C18.3546 4.75 19.25 5.64543 19.25 6.75V17.25C19.25 18.3546 18.3546 19.25 17.25 19.25H6.75C5.64543 19.25 4.75 18.3546 4.75 17.25V6.75Z"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 5V19"></path>',
|
||||
"open-pane": '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M14.25 4.75v14.5m-3-8.5L9.75 12l1.5 1.25m-4.5 6h10.5a2 2 0 0 0 2-2V6.75a2 2 0 0 0-2-2H6.75a2 2 0 0 0-2 2v10.5a2 2 0 0 0 2 2Z"></path>',
|
||||
"close-pane": '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.75 4.75v14.5m3-8.5L14.25 12l-1.5 1.25M6.75 19.25h10.5a2 2 0 0 0 2-2V6.75a2 2 0 0 0-2-2H6.75a2 2 0 0 0-2 2v10.5a2 2 0 0 0 2 2Z"></path>',
|
||||
"open-pane": '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M14.25 4.75v14.5m-3-8.5L9.75 12l1.5 1.25m-4.5 6h10.5a2 2 0 0 0 2-2V6.75a2 2 0 0 0-2-2H6.75a2 2 0 0 0-2 2v10.5a2 2 0 0 0 2 2Z"></path>',
|
||||
"close-pane": '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.75 4.75v14.5m3-8.5L14.25 12l-1.5 1.25M6.75 19.25h10.5a2 2 0 0 0 2-2V6.75a2 2 0 0 0-2-2H6.75a2 2 0 0 0-2 2v10.5a2 2 0 0 0 2 2Z"></path>',
|
||||
"file-search": '<path fill="currentColor" d="M17.25 9.25V10a.75.75 0 0 0 .53-1.28l-.53.53Zm-4.5-4.5.53-.53a.75.75 0 0 0-.53-.22v.75ZM10.25 20a.75.75 0 0 0 0-1.5V20Zm7.427-3.383a.75.75 0 0 0-1.06 1.06l1.06-1.06Zm1.043 3.163a.75.75 0 1 0 1.06-1.06l-1.06 1.06Zm-.94-11.06-4.5-4.5-1.06 1.06 4.5 4.5 1.06-1.06ZM12.75 4h-6v1.5h6V4ZM4 6.75v10.5h1.5V6.75H4ZM6.75 20h3.5v-1.5h-3.5V20ZM12 4.75v3.5h1.5v-3.5H12ZM13.75 10h3.5V8.5h-3.5V10ZM12 8.25c0 .966.784 1.75 1.75 1.75V8.5a.25.25 0 0 1-.25-.25H12Zm-8 9A2.75 2.75 0 0 0 6.75 20v-1.5c-.69 0-1.25-.56-1.25-1.25H4ZM6.75 4A2.75 2.75 0 0 0 4 6.75h1.5c0-.69.56-1.25 1.25-1.25V4Zm8.485 14.47a3.235 3.235 0 0 0 3.236-3.235h-1.5c0 .959-.777 1.736-1.736 1.736v1.5Zm0-4.97c.959 0 1.736.777 1.736 1.735h1.5A3.235 3.235 0 0 0 15.235 12v1.5Zm0-1.5A3.235 3.235 0 0 0 12 15.235h1.5c0-.958.777-1.735 1.735-1.735V12Zm0 4.97a1.735 1.735 0 0 1-1.735-1.735H12a3.235 3.235 0 0 0 3.235 3.236v-1.5Zm1.382.707 2.103 2.103 1.06-1.06-2.103-2.103-1.06 1.06Z"></path>',
|
||||
"folder-search": '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M10.25 19.25h-3.5a2 2 0 0 1-2-2v-9.5h12.5a2 2 0 0 1 2 2v.5m-5.75-2.5-.931-1.958a2 2 0 0 0-1.756-1.042H6.75a2 2 0 0 0-2 2V11m12.695 6.445 1.805 1.805m-3.75-1a2.75 2.75 0 1 0 0-5.5 2.75 2.75 0 0 0 0 5.5Z"></path>',
|
||||
search: '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19.25 19.25L15.5 15.5M4.75 11C4.75 7.54822 7.54822 4.75 11 4.75C14.4518 4.75 17.25 7.54822 17.25 11C17.25 14.4518 14.4518 17.25 11 17.25C7.54822 17.25 4.75 14.4518 4.75 11Z"></path>',
|
||||
"web-search": '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19.25 8.25v-.5a2 2 0 0 0-2-2H6.75a2 2 0 0 0-2 2v.5m14.5 0H4.75m14.5 0v2m-14.5-2v8a2 2 0 0 0 2 2h2.5m7.743-1.257 2.257 2.257m-4.015-1.53a2.485 2.485 0 1 0 0-4.97 2.485 2.485 0 0 0 0 4.97Z"></path>',
|
||||
loading: '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 4.75v1.5m5.126.624L16 8m3.25 4h-1.5m-.624 5.126-1.768-1.768M12 16.75v2.5m-3.36-3.891-1.768 1.768M7.25 12h-2.5m3.891-3.358L6.874 6.874"></path>',
|
||||
mic: '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8.75 8C8.75 6.20507 10.2051 4.75 12 4.75C13.7949 4.75 15.25 6.20507 15.25 8V11C15.25 12.7949 13.7949 14.25 12 14.25C10.2051 14.25 8.75 12.7949 8.75 11V8Z"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5.75 12.75C5.75 12.75 6 17.25 12 17.25C18 17.25 18.25 12.75 18.25 12.75"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 17.75V19.25"></path>',
|
||||
} as const
|
||||
|
||||
export function Icon(props: IconProps) {
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { A } from "@solidjs/router"
|
||||
import { splitProps } from "solid-js"
|
||||
import type { ComponentProps } from "solid-js"
|
||||
import { getButtonClasses } from "./button"
|
||||
|
||||
export interface LinkProps extends ComponentProps<typeof A> {
|
||||
variant?: "primary" | "secondary" | "outline" | "ghost"
|
||||
variant?: "primary" | "secondary" | "ghost"
|
||||
size?: "sm" | "md" | "lg"
|
||||
}
|
||||
|
||||
export function Link(props: LinkProps) {
|
||||
const [local, others] = splitProps(props, ["variant", "size", "class"])
|
||||
const classes = local.variant ? getButtonClasses(local.variant, local.size, local.class) : local.class
|
||||
return <A class={classes} {...others} />
|
||||
const [, others] = splitProps(props, ["variant", "size", "class"])
|
||||
return <A {...others} />
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ function TabsList(props: TabsListProps) {
|
||||
classList={{
|
||||
"relative flex items-center bg-background overflow-x-auto no-scrollbar": true,
|
||||
"divide-x divide-border-subtle/40": true,
|
||||
"after:content-[''] after:block after:grow after:h-9": true,
|
||||
"after:content-[''] after:block after:grow after:h-8": true,
|
||||
"after:border-l empty:after:border-l-0! after:border-b after:border-border-subtle/40": true,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
@@ -32,7 +32,7 @@ function TabsTrigger(props: ParentProps<TabsTriggerProps>) {
|
||||
return (
|
||||
<KobalteTabs.Trigger
|
||||
classList={{
|
||||
"relative px-3 h-9 flex items-center": true,
|
||||
"relative px-3 h-8 flex items-center": true,
|
||||
"text-sm font-medium text-text-muted/60 cursor-pointer": true,
|
||||
"whitespace-nowrap shrink-0 border-b border-border-subtle/40": true,
|
||||
"disabled:pointer-events-none disabled:opacity-50": true,
|
||||
|
||||
@@ -30,11 +30,13 @@ export function Tooltip(props: TooltipProps) {
|
||||
|
||||
return (
|
||||
<KobalteTooltip forceMount {...others} open={open()} onOpenChange={setOpen}>
|
||||
<KobalteTooltip.Trigger as={"div"}>{c()}</KobalteTooltip.Trigger>
|
||||
<KobalteTooltip.Trigger as={"div"} class="flex items-center">
|
||||
{c()}
|
||||
</KobalteTooltip.Trigger>
|
||||
<KobalteTooltip.Portal>
|
||||
<KobalteTooltip.Content
|
||||
classList={{
|
||||
"z-50 max-w-[320px] rounded-md bg-background-element px-2 py-1": true,
|
||||
"z-[1000] max-w-[320px] rounded-md bg-background-element px-2 py-1": true,
|
||||
"text-xs font-medium text-text shadow-md pointer-events-none!": true,
|
||||
"transition-all duration-150 ease-out": true,
|
||||
"transform-gpu transform-origin-[var(--kb-tooltip-content-transform-origin)]": true,
|
||||
|
||||
@@ -3,6 +3,11 @@ export function getFilename(path: string) {
|
||||
return parts[parts.length - 1]
|
||||
}
|
||||
|
||||
export function getDirectory(path: string) {
|
||||
const parts = path.split("/")
|
||||
return parts.slice(0, parts.length - 1).join("/")
|
||||
}
|
||||
|
||||
export function getFileExtension(path: string) {
|
||||
const parts = path.split(".")
|
||||
return parts[parts.length - 1]
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import { createSignal, onCleanup } from "solid-js"
|
||||
|
||||
// Minimal types to avoid relying on non-standard DOM typings
|
||||
type RecognitionResult = {
|
||||
0: { transcript: string }
|
||||
isFinal: boolean
|
||||
}
|
||||
|
||||
type RecognitionEvent = {
|
||||
results: RecognitionResult[]
|
||||
resultIndex: number
|
||||
}
|
||||
|
||||
interface Recognition {
|
||||
continuous: boolean
|
||||
interimResults: boolean
|
||||
lang: string
|
||||
start: () => void
|
||||
stop: () => void
|
||||
onresult: ((e: RecognitionEvent) => void) | null
|
||||
onerror: ((e: { error: string }) => void) | null
|
||||
onend: (() => void) | null
|
||||
onstart: (() => void) | null
|
||||
}
|
||||
|
||||
const COMMIT_DELAY = 250
|
||||
|
||||
const appendSegment = (base: string, addition: string) => {
|
||||
const trimmed = addition.trim()
|
||||
if (!trimmed) return base
|
||||
if (!base) return trimmed
|
||||
const needsSpace = /\S$/.test(base) && !/^[,.;!?]/.test(trimmed)
|
||||
return `${base}${needsSpace ? " " : ""}${trimmed}`
|
||||
}
|
||||
|
||||
const extractSuffix = (committed: string, hypothesis: string) => {
|
||||
const cleanHypothesis = hypothesis.trim()
|
||||
if (!cleanHypothesis) return ""
|
||||
const baseTokens = committed.trim() ? committed.trim().split(/\s+/) : []
|
||||
const hypothesisTokens = cleanHypothesis.split(/\s+/)
|
||||
let index = 0
|
||||
while (
|
||||
index < baseTokens.length &&
|
||||
index < hypothesisTokens.length &&
|
||||
baseTokens[index] === hypothesisTokens[index]
|
||||
) {
|
||||
index += 1
|
||||
}
|
||||
if (index < baseTokens.length) return ""
|
||||
return hypothesisTokens.slice(index).join(" ")
|
||||
}
|
||||
|
||||
export function createSpeechRecognition(opts?: {
|
||||
lang?: string
|
||||
onFinal?: (text: string) => void
|
||||
onInterim?: (text: string) => void
|
||||
}) {
|
||||
const hasSupport =
|
||||
typeof window !== "undefined" &&
|
||||
Boolean((window as any).webkitSpeechRecognition || (window as any).SpeechRecognition)
|
||||
|
||||
const [isRecording, setIsRecording] = createSignal(false)
|
||||
const [committed, setCommitted] = createSignal("")
|
||||
const [interim, setInterim] = createSignal("")
|
||||
|
||||
let recognition: Recognition | undefined
|
||||
let shouldContinue = false
|
||||
let committedText = ""
|
||||
let sessionCommitted = ""
|
||||
let pendingHypothesis = ""
|
||||
let lastInterimSuffix = ""
|
||||
let shrinkCandidate: string | undefined
|
||||
let commitTimer: number | undefined
|
||||
|
||||
const cancelPendingCommit = () => {
|
||||
if (commitTimer === undefined) return
|
||||
clearTimeout(commitTimer)
|
||||
commitTimer = undefined
|
||||
}
|
||||
|
||||
const commitSegment = (segment: string) => {
|
||||
const nextCommitted = appendSegment(committedText, segment)
|
||||
if (nextCommitted === committedText) return
|
||||
committedText = nextCommitted
|
||||
setCommitted(committedText)
|
||||
if (opts?.onFinal) opts.onFinal(segment.trim())
|
||||
}
|
||||
|
||||
const promotePending = () => {
|
||||
if (!pendingHypothesis) return
|
||||
const suffix = extractSuffix(sessionCommitted, pendingHypothesis)
|
||||
if (!suffix) {
|
||||
pendingHypothesis = ""
|
||||
return
|
||||
}
|
||||
sessionCommitted = appendSegment(sessionCommitted, suffix)
|
||||
commitSegment(suffix)
|
||||
pendingHypothesis = ""
|
||||
lastInterimSuffix = ""
|
||||
shrinkCandidate = undefined
|
||||
setInterim("")
|
||||
if (opts?.onInterim) opts.onInterim("")
|
||||
}
|
||||
|
||||
const applyInterim = (suffix: string, hypothesis: string) => {
|
||||
cancelPendingCommit()
|
||||
pendingHypothesis = hypothesis
|
||||
lastInterimSuffix = suffix
|
||||
shrinkCandidate = undefined
|
||||
setInterim(suffix)
|
||||
if (opts?.onInterim) {
|
||||
opts.onInterim(suffix ? appendSegment(committedText, suffix) : "")
|
||||
}
|
||||
if (!suffix) return
|
||||
const snapshot = hypothesis
|
||||
commitTimer = window.setTimeout(() => {
|
||||
if (pendingHypothesis !== snapshot) return
|
||||
const currentSuffix = extractSuffix(sessionCommitted, pendingHypothesis)
|
||||
if (!currentSuffix) return
|
||||
sessionCommitted = appendSegment(sessionCommitted, currentSuffix)
|
||||
commitSegment(currentSuffix)
|
||||
pendingHypothesis = ""
|
||||
lastInterimSuffix = ""
|
||||
shrinkCandidate = undefined
|
||||
setInterim("")
|
||||
if (opts?.onInterim) opts.onInterim("")
|
||||
}, COMMIT_DELAY)
|
||||
}
|
||||
|
||||
if (hasSupport) {
|
||||
const Ctor: new () => Recognition = (window as any).webkitSpeechRecognition || (window as any).SpeechRecognition
|
||||
|
||||
recognition = new Ctor()
|
||||
recognition.continuous = false
|
||||
recognition.interimResults = true
|
||||
recognition.lang = opts?.lang || (typeof navigator !== "undefined" ? navigator.language : "en-US")
|
||||
|
||||
recognition.onresult = (event: RecognitionEvent) => {
|
||||
if (!event.results.length) return
|
||||
|
||||
let aggregatedFinal = ""
|
||||
let latestHypothesis = ""
|
||||
|
||||
for (let i = 0; i < event.results.length; i += 1) {
|
||||
const result = event.results[i]
|
||||
const transcript = (result[0]?.transcript || "").trim()
|
||||
if (!transcript) continue
|
||||
if (result.isFinal) {
|
||||
aggregatedFinal = appendSegment(aggregatedFinal, transcript)
|
||||
} else {
|
||||
latestHypothesis = transcript
|
||||
}
|
||||
}
|
||||
|
||||
if (aggregatedFinal) {
|
||||
cancelPendingCommit()
|
||||
const finalSuffix = extractSuffix(sessionCommitted, aggregatedFinal)
|
||||
if (finalSuffix) {
|
||||
sessionCommitted = appendSegment(sessionCommitted, finalSuffix)
|
||||
commitSegment(finalSuffix)
|
||||
}
|
||||
pendingHypothesis = ""
|
||||
lastInterimSuffix = ""
|
||||
shrinkCandidate = undefined
|
||||
setInterim("")
|
||||
if (opts?.onInterim) opts.onInterim("")
|
||||
return
|
||||
}
|
||||
|
||||
cancelPendingCommit()
|
||||
|
||||
if (!latestHypothesis) {
|
||||
shrinkCandidate = undefined
|
||||
applyInterim("", "")
|
||||
return
|
||||
}
|
||||
|
||||
const suffix = extractSuffix(sessionCommitted, latestHypothesis)
|
||||
|
||||
if (!suffix) {
|
||||
if (!lastInterimSuffix) {
|
||||
shrinkCandidate = undefined
|
||||
applyInterim("", latestHypothesis)
|
||||
return
|
||||
}
|
||||
if (shrinkCandidate === "") {
|
||||
applyInterim("", latestHypothesis)
|
||||
return
|
||||
}
|
||||
shrinkCandidate = ""
|
||||
pendingHypothesis = latestHypothesis
|
||||
return
|
||||
}
|
||||
|
||||
if (lastInterimSuffix && suffix.length < lastInterimSuffix.length) {
|
||||
if (shrinkCandidate === suffix) {
|
||||
applyInterim(suffix, latestHypothesis)
|
||||
return
|
||||
}
|
||||
shrinkCandidate = suffix
|
||||
pendingHypothesis = latestHypothesis
|
||||
return
|
||||
}
|
||||
|
||||
shrinkCandidate = undefined
|
||||
applyInterim(suffix, latestHypothesis)
|
||||
}
|
||||
|
||||
recognition.onerror = (e: { error: string }) => {
|
||||
cancelPendingCommit()
|
||||
lastInterimSuffix = ""
|
||||
shrinkCandidate = undefined
|
||||
if (e.error === "no-speech" && shouldContinue) {
|
||||
setInterim("")
|
||||
if (opts?.onInterim) opts.onInterim("")
|
||||
setTimeout(() => {
|
||||
try {
|
||||
recognition?.start()
|
||||
} catch {}
|
||||
}, 150)
|
||||
return
|
||||
}
|
||||
shouldContinue = false
|
||||
setIsRecording(false)
|
||||
}
|
||||
|
||||
recognition.onstart = () => {
|
||||
sessionCommitted = ""
|
||||
pendingHypothesis = ""
|
||||
cancelPendingCommit()
|
||||
lastInterimSuffix = ""
|
||||
shrinkCandidate = undefined
|
||||
setInterim("")
|
||||
if (opts?.onInterim) opts.onInterim("")
|
||||
setIsRecording(true)
|
||||
}
|
||||
|
||||
recognition.onend = () => {
|
||||
cancelPendingCommit()
|
||||
lastInterimSuffix = ""
|
||||
shrinkCandidate = undefined
|
||||
setIsRecording(false)
|
||||
if (shouldContinue) {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
recognition?.start()
|
||||
} catch {}
|
||||
}, 150)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const start = () => {
|
||||
if (!recognition) return
|
||||
shouldContinue = true
|
||||
sessionCommitted = ""
|
||||
pendingHypothesis = ""
|
||||
cancelPendingCommit()
|
||||
lastInterimSuffix = ""
|
||||
shrinkCandidate = undefined
|
||||
setInterim("")
|
||||
try {
|
||||
recognition.start()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
if (!recognition) return
|
||||
shouldContinue = false
|
||||
promotePending()
|
||||
cancelPendingCommit()
|
||||
lastInterimSuffix = ""
|
||||
shrinkCandidate = undefined
|
||||
setInterim("")
|
||||
if (opts?.onInterim) opts.onInterim("")
|
||||
try {
|
||||
recognition.stop()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
shouldContinue = false
|
||||
promotePending()
|
||||
cancelPendingCommit()
|
||||
lastInterimSuffix = ""
|
||||
shrinkCandidate = undefined
|
||||
setInterim("")
|
||||
if (opts?.onInterim) opts.onInterim("")
|
||||
try {
|
||||
recognition?.stop()
|
||||
} catch {}
|
||||
})
|
||||
|
||||
return {
|
||||
isSupported: () => hasSupport,
|
||||
isRecording,
|
||||
committed,
|
||||
interim,
|
||||
start,
|
||||
stop,
|
||||
}
|
||||
}
|
||||
@@ -6,4 +6,4 @@
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
export {}
|
||||
@@ -6,7 +6,6 @@
|
||||
"jsxImportSource": "solid-js",
|
||||
"types": ["vite/client"],
|
||||
"lib": ["DOM", "DOM.Iterable"],
|
||||
"customConditions": ["development"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
{
|
||||
"name": "@opencode/cloud-app",
|
||||
"name": "@opencode/console-app",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dev": "vinxi dev --host 0.0.0.0",
|
||||
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
|
||||
"build": "vinxi build && ../../packages/opencode/script/schema.ts ./.output/public/config.json",
|
||||
"build": "vinxi build && ../../opencode/script/schema.ts ./.output/public/config.json",
|
||||
"start": "vinxi start",
|
||||
"version": "0.9.6"
|
||||
"version": "0.14.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
"@solidjs/meta": "^0.29.4",
|
||||
"@solidjs/router": "^0.15.0",
|
||||
"@solidjs/start": "^1.1.0",
|
||||
"solid-js": "catalog:",
|
||||
"vinxi": "^0.5.7",
|
||||
"@opencode/cloud-core": "workspace:*"
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
@@ -0,0 +1 @@
|
||||
../../mail/emails/templates/static
|
||||
@@ -0,0 +1,23 @@
|
||||
<svg width="400" height="400" viewBox="0 0 400 400" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="400" height="400" fill="#FDFCFC"/>
|
||||
<path d="M96 122.001V70.001H148V122.001H96Z" fill="#17181C"/>
|
||||
<path d="M148.004 122.001V70.001H200.004V122.001H148.004Z" fill="#17181C"/>
|
||||
<path d="M200.008 122.001V70.001H252.008V122.001H200.008Z" fill="#17181C"/>
|
||||
<path d="M251.996 122.001V70.001H303.996V122.001H251.996Z" fill="#17181C"/>
|
||||
<path d="M251.996 173.988V121.988H303.996V173.988H251.996Z" fill="#17181C"/>
|
||||
<path d="M96 225.998V173.998H148V225.998H96Z" fill="#CFCECD"/>
|
||||
<rect width="52" height="52" transform="translate(148.004 173.998)" fill="#17181C"/>
|
||||
<path d="M148.004 225.998V173.998H200.004V225.998H148.004Z" fill="#17181C" fill-opacity="0.1"/>
|
||||
<path d="M200.008 225.998V173.998H252.008V225.998H200.008Z" fill="#17181C"/>
|
||||
<path d="M252.016 225.998V173.998H304.016V225.998H252.016Z" fill="#CFCECD"/>
|
||||
<rect width="52" height="52" transform="translate(96 226.002)" fill="#17181C"/>
|
||||
<path d="M96 278.002V226.002H148V278.002H96Z" fill="#17181C" fill-opacity="0.1"/>
|
||||
<rect width="52" height="52" transform="translate(148.004 226.002)" fill="white"/>
|
||||
<path d="M148.004 278.002V226.002H200.004V278.002H148.004Z" fill="#CFCECD"/>
|
||||
<path d="M200.008 278.002V226.002H252.008V278.002H200.008Z" fill="#CFCECD"/>
|
||||
<path d="M252.016 278.002V226.002H304.016V278.002H252.016Z" fill="#CFCECD"/>
|
||||
<path d="M96 330.012V278.012H148V330.012H96Z" fill="#17181C"/>
|
||||
<path d="M148.004 330.012V278.012H200.004V330.012H148.004Z" fill="#17181C"/>
|
||||
<path d="M200.008 329.99V277.99H252.008V329.99H200.008Z" fill="#17181C"/>
|
||||
<path d="M251.996 330.012V278.012H303.996V330.012H251.996Z" fill="#17181C"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg width="400" height="400" viewBox="0 0 400 400" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="400" height="400" fill="#0E0E0E"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M312 340H88V60H312V340ZM256 116H144V284H256V116Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 269 B |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 14.3581L10.0541 17.7027L18 7" stroke="#8E8B8B" stroke-width="1.5" stroke-linecap="square"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 207 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.75 8.75V2.75H21.25V15.25H15.25M15.25 8.75H2.75V21.25H15.25V8.75Z" stroke="#8E8B8B" stroke-width="1.5" stroke-linecap="square"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 243 B |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 227 KiB |