Compare commits

...
Author SHA1 Message Date
Dax Raad cd62829f77 cli: improve run output and network handling 2026-01-12 12:34:21 -05:00
Dax Raad 20399bbdfe tui: rename kvStore to store for consistency 2026-01-12 09:53:08 -05:00
Dax Raad 547a975707 tui: redesign tips display on home screen 2026-01-12 09:42:28 -05:00
David HillandDax c009cab15b Redesign tips display on home screen (#6126)
Co-authored-by: Dax <mail@thdxr.com>
2026-01-12 09:31:36 -05:00
Dax Raad e6bc3b253b tui: remove update complete toast notification 2026-01-12 09:30:31 -05:00
Brendan Allan f1a13f25a4 ci: don't continue-on-error in tauri action 2026-01-12 21:27:52 +08:00
GitHub Action 4695e685cf ignore: update download stats 2026-01-12 2026-01-12 12:05:37 +00:00
Brendan Allan c6092e4ad9 disable appimage 2026-01-12 19:28:55 +08:00
GitHub Action e6045ca925 chore: generate 2026-01-12 09:58:50 +00:00
Brendan Allan ebbb4dd88a fix(desktop): improve server detection & connection logic (#7962) 2026-01-12 17:58:13 +08:00
GitHub Action 087473be6e chore: generate 2026-01-12 05:22:12 +00:00
Brendan Allan e0eb460fc8 app: resolve defaultServerUrl inside AppInterface
The desktop app sometimes modified __OPENCODE__.serverUrl after the
window is created. Previously this was ignored since defaultServerUrl
was created at module scope, now it isn't created until desktop's
ensure_server_started query complete, after which point serverUrl is
guaranteed to be updated.
2026-01-12 13:21:21 +08:00
opencode 7c6b3f981e release: v1.1.14
Update Nix Hashes / update (push) Has been cancelled
2026-01-12 05:08:25 +00:00
Aiden Cline a5b6c57a76 tweak: make the subagent header have clickable nav 2026-01-11 22:43:09 -06:00
Eyal Cherevatsky 65724b693b docs: fix scroll_speed default value (#7867) 2026-01-11 21:40:23 -06:00
Andrey Taranov 8b9a85b7e7 fix(mcp): support resource content type in MCP tool output (#7879) 2026-01-11 21:39:42 -06:00
Zeke Sikelianos 7cbec9a1a7 docs: fix typos in settings doc (#7892) 2026-01-11 21:20:19 -06:00
Prince Carlo Juguilon c4ba5961c8 chore: update GitHub stars count to 60K (#7899) 2026-01-11 21:19:59 -06:00
indeep99 82b432349e feat(tui): add mouse hover and click support to questions tool (#7905) 2026-01-11 22:01:48 -05:00
Dax Raad 68ed664a3f tui: fix prompt ref initialization to prevent undefined reference errors 2026-01-11 19:40:16 -05:00
Dax Raad 3a30773874 tui: refactor event streaming to use SDK instead of manual RPC subscription 2026-01-11 18:53:39 -05:00
Octane 0c0057a7de Fix: TUI single-line paste cursor position (#7277) 2026-01-12 00:50:27 +01:00
Dillon Mulroy fa79736b87 fix: check worktree for external_directory permission in subdirs (#7811) 2026-01-11 14:17:36 -06:00
Jacob Gillespie 2e0c2c9db7 chore(lander): fix spacing in desktop app banner (#7822) 2026-01-11 14:16:29 -06:00
Daniel Polito 3e9366487a feat(desktop): User Message Badges on Hover (#7835) 2026-01-11 14:14:26 -06:00
Daniel Polito 025ed04da0 feat(desktop): Image Preview support for Image Attachments (#7841) 2026-01-11 14:12:52 -06:00
Zeke Sikelianos b81eca4ebc docs: fix typos on the providers page (#7829) 2026-01-11 13:04:04 -06:00
Aiden Cline 20c18689c0 bump copilot plugin version 2026-01-11 12:58:14 -06:00
Kit Langton a803cf8aee feat(tui): add mouse hover and click support to autocomplete (#7820) 2026-01-11 12:45:20 -05:00
King'ori Maina c526e2d908 fix(tui): copy oauth url when no device code (#7812) 2026-01-11 10:27:17 -06:00
GitHub Action bdbbcd8a0c chore: generate 2026-01-11 16:12:14 +00:00
Kit Langton 43c2da24d0 fix(tui): slash command autocomplete highlighted row jumping (#7815) 2026-01-11 11:11:40 -05:00
GitHub Action 3205db9c16 ignore: update download stats 2026-01-11 2026-01-11 12:04:38 +00:00
opencode efbab087df release: v1.1.13
Update Nix Hashes / update (push) Has been cancelled
2026-01-11 07:10:41 +00:00
Mani Sundararajan 9280db3297 fix(tui): move props.ref to onMount (#7702) 2026-01-11 00:20:04 -06:00
Aiden Cline 0cc3c3bc78 tweak: ensure codex built in plugin is always available 2026-01-10 22:59:39 -06:00
Aiden Cline ee8b38ab26 fix: ensure /connect works for openai business plans too (was missing field) 2026-01-10 22:48:28 -06:00
wid4tandAiden Cline 44fa3d5392 feat(acp): track file modified (#7723)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-01-10 21:46:53 -06:00
Kit Langton a457828a67 fix(opencode): command palette mouse hover highlights wrong item (#7721) 2026-01-10 19:52:17 -05:00
Spoon 4752c83155 feat: pass sessionID to chat.system.transform (#7718) 2026-01-10 18:21:51 -06:00
Dax Raad f94ee5ce90 core: extract external directory validation to shared utility to reduce code duplication across tools 2026-01-10 18:49:42 -05:00
Daniel Polito 76386f5cfc feat(desktop): Fork Session (#7673) 2026-01-10 16:04:01 -06:00
Daniel Polito a9275def43 feat(desktop): Make mouse hover / click work on prompt input autocomplete (#7661) 2026-01-10 16:02:47 -06:00
Daniel Polito 50ed4c6b5d feat(deskop): Add Copy to Messages (#7658) 2026-01-10 16:01:23 -06:00
Jérôme Benoit f882cca98a fix(desktop): add missing StoreExt import and fix unused mut warning (#7707) 2026-01-10 15:26:24 -06:00
Lekë Dobruna 2d2a044961 fix(desktop): build failing - missing import (#7697) 2026-01-10 15:24:15 -06:00
Frank b41fbda68f wip: black 2026-01-10 13:57:49 -05:00
Alejandro Chinchilla 794c5981a5 fix: exclude 'none' variant for gpt-5.2-codex (#7682) 2026-01-10 12:22:44 -06:00
Kenny d5738f542c fix(grep): follow symlinks by default in ripgrep searches (#7501) 2026-01-10 10:57:18 -06:00
Andrew Thal 02b7eb59f8 feat: support configuring default server URL for desktop (#7363) 2026-01-10 08:56:48 -06:00
GitHub Action a8f23fb548 chore: generate 2026-01-10 14:55:55 +00:00
OpeOginni 1a642a79a6 fix(desktop): remove split operation in serverDisplayName function (#7397) 2026-01-10 08:55:20 -06:00
zerone0x b6b0097755 fix(ui): allow text editing shortcuts in search dialogs on macOS (#7419) 2026-01-10 08:53:52 -06:00
Daniel Polito afb1cad26d fix(desktop): Allow Selecting Card Errors (#7506) 2026-01-10 08:52:11 -06:00
GitHub Action 58186004db chore: generate 2026-01-10 14:44:47 +00:00
madflow a593ed4c9b fix: disable telemetry in the php lsp server (#7649) 2026-01-10 08:44:12 -06:00
opencode 449270aacc release: v1.1.12
Update Nix Hashes / update (push) Has been cancelled
2026-01-10 13:56:12 +00:00
GitHub Action e20535655b chore: generate 2026-01-10 13:50:58 +00:00
Frédéric DE MATOS 1662e149b3 fix: add ChatGPT-Account-Id header for organization subscriptions (#7603) 2026-01-10 07:50:24 -06:00
Vlad Temian dfe3e79304 docs(ecosystem): add micode and octto plugins (#7327) 2026-01-10 07:44:21 -06:00
GitHub Action e92a2ec9db ignore: update download stats 2026-01-10 2026-01-10 12:04:24 +00:00
Github Action 7cba1ff793 Update Nix flake.lock and hashes 2026-01-10 03:10:45 +00:00
Adam 8c3cc0d447 chore: prep 2026-01-09 21:09:26 -06:00
Adam e30562d5f4 chore: prep 2026-01-09 20:18:42 -06:00
GitHub Action 7c06ef2477 chore: generate 2026-01-10 02:07:24 +00:00
Adam 58eccf7f53 chore: prep 2026-01-09 19:53:04 -06:00
Adam 6da60bd5d9 fix(app): more defensive dom access 2026-01-09 19:53:03 -06:00
opencode 8a43c24934 release: v1.1.11
Update Nix Hashes / update (push) Has been cancelled
2026-01-10 01:25:30 +00:00
Aiden Cline b03172d723 fix: ensure gpt-5.2-codex has variants 2026-01-09 19:15:57 -06:00
Spoon e342795bd0 docs: add url based instructions to web docs (#7216) 2026-01-09 19:01:13 -06:00
Aiden Cline a44d4acb3f tweak: adjust codex styling and fix hint 2026-01-09 18:59:57 -06:00
Aiden Cline 8b8a358de1 update docs and auth methods for openai 2026-01-09 18:45:03 -06:00
Aiden Cline 075fa2c0e8 fix: instance dispose issue 2026-01-09 18:26:32 -06:00
Aiden Cline 8b287caaad tweak codex instructions prompt for codex plan 2026-01-09 18:07:24 -06:00
Frank b3e6b7a985 wip: black 2026-01-09 19:00:51 -05:00
GitHub Action 5c74bff8e1 chore: generate 2026-01-09 23:48:13 +00:00
Aiden Cline 172bbdaced feat: codex auth support (#7537) 2026-01-09 17:47:37 -06:00
opencode 563b4c33f2 release: v1.1.10
Update Nix Hashes / update (push) Has been cancelled
2026-01-09 21:38:04 +00:00
Dax 982b71e861 disable server unless explicitly opted in (#7529) 2026-01-09 16:33:46 -05:00
Ariane Emory 75df5040ea fix: report config errors instead of silent termination (#7522) 2026-01-09 14:21:01 -06:00
Github Action f2b2940298 Update Nix flake.lock and hashes 2026-01-09 19:20:11 +00:00
Sebastian Herrlinger 22f51c6b47 upgrade opentui to v0.1.72 2026-01-09 20:18:34 +01:00
Aiden Cline de286b08f6 ignore: bump plugin version 2026-01-09 12:33:51 -06:00
adamelmore 0f2124db32 fix(app): no inline js 2026-01-09 11:57:31 -06:00
Ravi Kumar e30a159264 fix(cli): enable API key prompt for Bedrock in auth login (#7332) 2026-01-09 11:56:19 -06:00
ryanwyler a5edf3a311 fix: resolve broken forked sessions with compactions due to missing parent-child message references (#6445) 2026-01-09 11:42:55 -06:00
Aiden Cline 7c2907cbb4 ci: fix failing pipeline 2026-01-09 11:38:01 -06:00
GitHub Action bce9dc040f chore: generate 2026-01-09 17:03:51 +00:00
adamelmore 44297ffe78 fix(app): break words in user message 2026-01-09 11:03:11 -06:00
adamelmore cbb3141130 fix(app): no custom url param 2026-01-09 11:03:11 -06:00
Frank 18cf4df6c6 wip: zen 2026-01-09 11:52:31 -05:00
Aiden Cline f3e8a275b8 docs: update brew formula references 2026-01-09 10:39:59 -06:00
Github Action 7df36cf0fa Update Nix flake.lock and hashes 2026-01-09 16:28:31 +00:00
Sebastian Herrlinger e82b112759 upgrade opentui to v0.1.71, fixing capability detection and therefor kitty keyboard 2026-01-09 17:26:33 +01:00
AJ Ka 030b14ac4e fix: prevent model picker overflow with Zen in new sessions (#7495) 2026-01-09 09:53:18 -06:00
Jérôme Benoit b1e381cff7 fix(desktop): complete symlink support implementation and enable CI for PRs (#7113) 2026-01-09 22:43:49 +08:00
adamelmore 0433d4d064 fix(app): store terminal and review pane visibility per session 2026-01-09 08:20:00 -06:00
GitHub Action d34fdac854 ignore: update download stats 2026-01-09 2026-01-09 12:04:56 +00:00
adamelmore ec828619ca fix(app): more defensive todo access 2026-01-09 06:02:45 -06:00
adamelmore 559013e124 docs: perf plans 2026-01-09 05:07:16 -06:00
Jeon Suyeol 8e3ab4afa7 feat(config): deduplicate plugins by name with priority-based resolution (#5957) 2026-01-09 02:11:24 -06:00
Aiden Cline 13305966e5 ci: tweak pr standards workflow 2026-01-09 00:54:42 -06:00
Aiden Cline a618fbe8cf bump copilot plugin version 2026-01-09 01:32:00 -05:00
Aiden Cline 07dc1f8ecc fix: model dialog issue when searching for models in fav/recents list, also ensure that deprecated models dont appear in list (#7429) 2026-01-09 01:32:00 -05:00
GitHub Action 445c8631a2 chore: generate 2026-01-09 01:32:00 -05:00
Frank c4eacd0ccf wip: black 2026-01-09 01:32:00 -05:00
Frank dd5ec26c8c wip: black 2026-01-09 01:32:00 -05:00
Daniel Polito 8b062ed621 feat(desktop): Change Terminal's Cursor to Bar and Stop Blinking when not focused (#7402) 2026-01-09 01:32:00 -05:00
Frank ab97a95032 wip: black 2026-01-09 01:32:00 -05:00
GitHub Action a98d108d2e chore: generate 2026-01-09 01:32:00 -05:00
Frank 2e875b2d65 wip: black 2026-01-09 01:32:00 -05:00
Frank 790baec41f wip: zen 2026-01-09 01:32:00 -05:00
Frank 52fbd16e08 wip: zen
wip: zen
2026-01-09 01:32:00 -05:00
GitHub Action cf97633d7d chore: generate 2026-01-09 00:13:26 +00:00
Danil 3fe2e89d55 docs: Add Scaleway to provider docs (#7389) 2026-01-08 18:12:22 -06:00
Luke Parker eb5c113cff ignore: add PR template (#7391) 2026-01-08 18:11:59 -06:00
opencode bd9c13bb27 release: v1.1.8
Update Nix Hashes / update (push) Has been cancelled
2026-01-08 23:58:57 +00:00
Adam 3019b1f825 Revert "fix(desktop): open external links in default browser (#7221)"
This reverts commit 361a962673.
2026-01-08 17:51:30 -06:00
Anthony HuangandAnthony Huang 119cc8c795 feat(desktop): add Vesper theme (#7331)
Co-authored-by: Anthony Huang <ahuang@ramp.com>
2026-01-08 17:48:57 -06:00
Adam be9b2bab15 feat(app): cache session-scoped stores, optional context gating 2026-01-08 17:48:15 -06:00
Adam c949e5b390 feat(app): incrementally render turns, markdown cache, lazily render diffs 2026-01-08 17:48:15 -06:00
Adam 1c717d62e4 feat(app): gate hydration side-effects, handoff while stores load 2026-01-08 17:48:15 -06:00
Adam 27675dfd70 feat(app): prefetch adjacent sessions 2026-01-08 17:48:15 -06:00
Adam 374275eeb6 feat(app): chunk message loading, lazy load diffs 2026-01-08 17:48:15 -06:00
AJ Ka faa848cfb1 Fix for #7229 Fix/project sidebar folder name (#7232) 2026-01-08 17:47:15 -06:00
Dax Raad 80772e5ac2 ci 2026-01-08 18:26:51 -05:00
Dax Raad be34747526 add sponsored 2026-01-08 18:21:21 -05:00
171 changed files with 13470 additions and 1643 deletions
+3
View File
@@ -0,0 +1,3 @@
### What does this PR do?
### How did you verify your code works?
+7
View File
@@ -9,6 +9,13 @@ on:
- "nix/**" - "nix/**"
- "packages/app/**" - "packages/app/**"
- "packages/desktop/**" - "packages/desktop/**"
pull_request:
paths:
- "flake.nix"
- "flake.lock"
- "nix/**"
- "packages/app/**"
- "packages/desktop/**"
workflow_dispatch: workflow_dispatch:
jobs: jobs:
+1 -1
View File
@@ -92,7 +92,7 @@ jobs:
publish-tauri: publish-tauri:
needs: publish needs: publish
continue-on-error: true continue-on-error: false
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
+1
View File
@@ -24,3 +24,4 @@ target
# Local dev files # Local dev files
opencode-dev opencode-dev
logs/ logs/
*.bun-build
+1 -1
View File
@@ -29,7 +29,7 @@ npm i -g opencode-ai@latest # or bun/pnpm/yarn
scoop bucket add extras; scoop install extras/opencode # Windows scoop bucket add extras; scoop install extras/opencode # Windows
choco install opencode # Windows choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date) brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date)
brew install opencode # macOS and Linux (official brew formula, updated less frequently) brew install opencode # macOS and Linux (official brew formula, updated less)
paru -S opencode-bin # Arch Linux paru -S opencode-bin # Arch Linux
mise use -g opencode # Any OS mise use -g opencode # Any OS
nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch
+4
View File
@@ -195,3 +195,7 @@
| 2026-01-06 | 1,960,988 (+222,817) | 1,377,377 (+24,334) | 3,338,365 (+247,151) | | 2026-01-06 | 1,960,988 (+222,817) | 1,377,377 (+24,334) | 3,338,365 (+247,151) |
| 2026-01-07 | 2,123,239 (+162,251) | 1,398,648 (+21,271) | 3,521,887 (+183,522) | | 2026-01-07 | 2,123,239 (+162,251) | 1,398,648 (+21,271) | 3,521,887 (+183,522) |
| 2026-01-08 | 2,272,630 (+149,391) | 1,432,480 (+33,832) | 3,705,110 (+183,223) | | 2026-01-08 | 2,272,630 (+149,391) | 1,432,480 (+33,832) | 3,705,110 (+183,223) |
| 2026-01-09 | 2,443,565 (+170,935) | 1,469,451 (+36,971) | 3,913,016 (+207,906) |
| 2026-01-10 | 2,632,023 (+188,458) | 1,503,670 (+34,219) | 4,135,693 (+222,677) |
| 2026-01-11 | 2,836,394 (+204,371) | 1,530,479 (+26,809) | 4,366,873 (+231,180) |
| 2026-01-12 | 3,053,594 (+217,200) | 1,553,671 (+23,192) | 4,607,265 (+240,392) |
+65 -2
View File
@@ -4,8 +4,71 @@
- AVOID unnecessary destructuring of variables. instead of doing `const { a, b } - AVOID unnecessary destructuring of variables. instead of doing `const { a, b }
= obj` just reference it as obj.a and obj.b. this preserves context = obj` just reference it as obj.a and obj.b. this preserves context
- AVOID `try`/`catch` where possible - AVOID `try`/`catch` where possible
- AVOID `else` statements
- AVOID using `any` type - AVOID using `any` type
- AVOID `let` statements
- PREFER single word variable names where possible - PREFER single word variable names where possible
- Use as many bun apis as possible like Bun.file() - Use as many bun apis as possible like Bun.file()
# Avoid let statements
we don't like let statements, especially combined with if/else statements.
prefer const
This is bad:
Good:
```ts
const foo = condition ? 1 : 2
```
Bad:
```ts
let foo
if (condition) foo = 1
else foo = 2
```
# Avoid else statements
Prefer early returns or even using `iife` to avoid else statements
Good:
```ts
function foo() {
if (condition) return 1
return 2
}
```
Bad:
```ts
function foo() {
if (condition) return 1
else return 2
}
```
# Prefer single word naming
Try your best to find a single word name for your variables, functions, etc.
Only use multiple words if you cannot.
Good:
```ts
const foo = 1
const bar = 2
const baz = 3
```
Bad:
```ts
const fooBar = 1
const barBaz = 2
const bazFoo = 3
```
+26 -25
View File
@@ -22,7 +22,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.1.7", "version": "1.1.14",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -70,7 +70,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.1.7", "version": "1.1.14",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@@ -87,6 +87,7 @@
"chart.js": "4.5.1", "chart.js": "4.5.1",
"nitro": "3.0.1-alpha.1", "nitro": "3.0.1-alpha.1",
"solid-js": "catalog:", "solid-js": "catalog:",
"solid-list": "0.3.0",
"vite": "catalog:", "vite": "catalog:",
"zod": "catalog:", "zod": "catalog:",
}, },
@@ -98,7 +99,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.1.7", "version": "1.1.14",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@@ -125,7 +126,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.1.7", "version": "1.1.14",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "2.0.0", "@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2", "@ai-sdk/openai": "2.0.2",
@@ -149,7 +150,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.1.7", "version": "1.1.14",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@@ -173,7 +174,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.1.7", "version": "1.1.14",
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@@ -202,7 +203,7 @@
}, },
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.1.7", "version": "1.1.14",
"dependencies": { "dependencies": {
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
@@ -231,7 +232,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.1.7", "version": "1.1.14",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@@ -247,7 +248,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.1.7", "version": "1.1.14",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -286,8 +287,8 @@
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.2", "@openrouter/ai-sdk-provider": "1.5.2",
"@opentui/core": "0.1.70", "@opentui/core": "0.1.72",
"@opentui/solid": "0.1.70", "@opentui/solid": "0.1.72",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-bus": "1.1.2",
@@ -350,7 +351,7 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.1.7", "version": "1.1.14",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"zod": "catalog:", "zod": "catalog:",
@@ -370,7 +371,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.1.7", "version": "1.1.14",
"devDependencies": { "devDependencies": {
"@hey-api/openapi-ts": "0.88.1", "@hey-api/openapi-ts": "0.88.1",
"@tsconfig/node22": "catalog:", "@tsconfig/node22": "catalog:",
@@ -381,7 +382,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.1.7", "version": "1.1.14",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@@ -394,7 +395,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.1.7", "version": "1.1.14",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -433,7 +434,7 @@
}, },
"packages/util": { "packages/util": {
"name": "@opencode-ai/util", "name": "@opencode-ai/util",
"version": "1.1.7", "version": "1.1.14",
"dependencies": { "dependencies": {
"zod": "catalog:", "zod": "catalog:",
}, },
@@ -444,7 +445,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.1.7", "version": "1.1.14",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
@@ -1201,21 +1202,21 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentui/core": ["@opentui/core@0.1.70", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.70", "@opentui/core-darwin-x64": "0.1.70", "@opentui/core-linux-arm64": "0.1.70", "@opentui/core-linux-x64": "0.1.70", "@opentui/core-win32-arm64": "0.1.70", "@opentui/core-win32-x64": "0.1.70", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-6cPAlbCnaiUUtQtvZNpkr0Xv8AdVAgJuy2VAwIsDN1pIv0zMpa0ZG+mr7afCGygw1eeDRveefrjfgFAB1r0SVw=="], "@opentui/core": ["@opentui/core@0.1.72", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.72", "@opentui/core-darwin-x64": "0.1.72", "@opentui/core-linux-arm64": "0.1.72", "@opentui/core-linux-x64": "0.1.72", "@opentui/core-win32-arm64": "0.1.72", "@opentui/core-win32-x64": "0.1.72", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-l4WQzubBJ80Q0n77Lxuodjwwm8qj/sOa7IXxEAzzDDXY/7bsIhdSpVhRTt+KevBRlok5J+w/KMKYr8UzkA4/hA=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.70", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rM8EnvW1tOAXWnp2Iy2M82I+ViSmRwUagx3v1/ni6N8GCcw/3mE0C6eB3sVlYNXVMwBEgiKpWFn85RCe4+qXQw=="], "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.72", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RoU48kOrhLZYDBiXaDu1LXS2bwRdlJlFle8eUQiqJjLRbMIY34J/srBuL0JnAS3qKW4J34NepUQa0l0/S43Q3w=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.70", "", { "os": "darwin", "cpu": "x64" }, "sha512-XdBgW+em8J+YGSUpaKF8/NxPjikJygK3dIkeMAw5xQ2lt7jXKxeM5MMmN/V4MfK3pLMtO56rLJlXaLH/h50uQA=="], "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.72", "", { "os": "darwin", "cpu": "x64" }, "sha512-hHUQw8i2LWPToRW1rjAiRqmNf34iJPS9ve9CJDygvFs5JOqUxN5yrfLfKfE+1bQjfFDHnpqW1HUk96iLhkPj8Q=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.70", "", { "os": "linux", "cpu": "arm64" }, "sha512-oSVWNMSOx0Na0M0LCqtWCxeh4SuLSK5lg8ZwVzsEoimIAxh0snp9nRUo/Qi8yD9BP0DSDmXuM/B3ONtzFaf0dw=="], "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.72", "", { "os": "linux", "cpu": "arm64" }, "sha512-63yml0OQ8tVa0JuDF9lBAWiChX6Q+iDO7lKv7c2n0352n/WyPr3iAgq4uSoH49HXuKeAXY/VwHGjvPzjXD/SDA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.70", "", { "os": "linux", "cpu": "x64" }, "sha512-WUrhukefMghcZ7sAjkxEy50vA6ii0X21xh7m8c4omXyYYfQXyDs25pNExB8cwoCrZEaC8RTlF4lRSNPIXsZKhA=="], "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.72", "", { "os": "linux", "cpu": "x64" }, "sha512-51veiQXNLvzDsFzsEvt71uK7WhiRe2DnvlJSGBSe6aRRHHxjCFYHzYi7t6bitJqtDTUj+EaMPbH81oZ6xy7tyg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.70", "", { "os": "win32", "cpu": "arm64" }, "sha512-p1K2VJXGmZqSV7mR61v7KJpT1Zth7DS99wEtaqqfK68OWt33K2XxLmGO0KD142R2JLfXu32NnRmBHxmVx8IjBA=="], "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.72", "", { "os": "win32", "cpu": "arm64" }, "sha512-1Ep6OcaYTy1RlLOln+LNN7DL1iNyLwLjG2M8aO0pVJKFvxeD5P7rdRzY065E4uhkHeJIHuduUqxvUjD0dyuwbw=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.70", "", { "os": "win32", "cpu": "x64" }, "sha512-G6b8te1twMeDhjg1oZa0IcUjhOJZFCSdlQt+q5gu5vVtjCrIwAn9o7m5EwNMPakc31pDWUZ7v0ktgv0Xw1AQVA=="], "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.72", "", { "os": "win32", "cpu": "x64" }, "sha512-5QUv91UkOINlkEaPky3kaxmJvshcJMBAX7LZtIroduaKBGpWRA1aogNhPZzp+30WkvgOU7aOtUktAZuFXb9WdQ=="],
"@opentui/solid": ["@opentui/solid@0.1.70", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.70", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-8Cw/w4Of2OJhsFhcp/Wdj8cJRVaGvVsIiUoYiFtyToM01J4en0bg/vnbeZteyuZWeEtA4iz1/rSEQf7Dp+2FIQ=="], "@opentui/solid": ["@opentui/solid@0.1.72", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.72", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-hytoLPboL/MTY/BQUnf/HlBuNXTVONney0X+PIQI82wT7kMx7+HHI2wnowpM3dyvA7l6NfORSud2cs9kIUBFBw=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": { "nodes": {
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1767364772, "lastModified": 1767966113,
"narHash": "sha256-fFUnEYMla8b7UKjijLnMe+oVFOz6HjijGGNS1l7dYaQ=", "narHash": "sha256-mSTsvXa4WveSRJexsmCbm9dY17B1fKp7NLpJxllpQw4=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "16c7794d0a28b5a37904d55bcca36003b9109aaa", "rev": "5f02c91314c8ba4afe83b256b023756412218535",
"type": "github" "type": "github"
}, },
"original": { "original": {
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"nodeModules": "sha256-KjBAaI9Kv6huOmPvUbtyYsMhbScI91w1lOZyXpIWqI0=" "nodeModules": "sha256-aIP+P0hSnQ50+lhnKvfRzQ8DlOhLjrrwarckxI3LBmE="
} }
+1 -30
View File
@@ -14,36 +14,7 @@
<meta property="og:image" content="/social-share.png" /> <meta property="og:image" content="/social-share.png" />
<meta property="twitter:image" content="/social-share.png" /> <meta property="twitter:image" content="/social-share.png" />
<!-- Theme preload script - applies cached theme to avoid FOUC --> <!-- Theme preload script - applies cached theme to avoid FOUC -->
<script id="oc-theme-preload-script"> <script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
;(function () {
var themeId = localStorage.getItem("opencode-theme-id")
if (!themeId) return
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
var mode = isDark ? "dark" : "light"
document.documentElement.dataset.theme = themeId
document.documentElement.dataset.colorScheme = mode
if (themeId === "oc-1") return
var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
if (css) {
var style = document.createElement("style")
style.id = "oc-theme-preload"
style.textContent =
":root{color-scheme:" +
mode +
";--text-mix-blend-mode:" +
(isDark ? "plus-lighter" : "multiply") +
";" +
css +
"}"
document.head.appendChild(style)
}
})()
</script>
</head> </head>
<body class="antialiased overscroll-none text-12-regular overflow-hidden"> <body class="antialiased overscroll-none text-12-regular overflow-hidden">
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.1.7", "version": "1.1.14",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
+28
View File
@@ -0,0 +1,28 @@
;(function () {
var themeId = localStorage.getItem("opencode-theme-id")
if (!themeId) return
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
var mode = isDark ? "dark" : "light"
document.documentElement.dataset.theme = themeId
document.documentElement.dataset.colorScheme = mode
if (themeId === "oc-1") return
var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
if (css) {
var style = document.createElement("style")
style.id = "oc-theme-preload"
style.textContent =
":root{color-scheme:" +
mode +
";--text-mix-blend-mode:" +
(isDark ? "plus-lighter" : "multiply") +
";" +
css +
"}"
document.head.appendChild(style)
}
})()
+22 -27
View File
@@ -33,22 +33,10 @@ const Loading = () => <div class="size-full flex items-center justify-center tex
declare global { declare global {
interface Window { interface Window {
__OPENCODE__?: { updaterEnabled?: boolean; port?: number; serverReady?: boolean } __OPENCODE__?: { updaterEnabled?: boolean }
} }
} }
const defaultServerUrl = iife(() => {
const param = new URLSearchParams(document.location.search).get("url")
if (param) return param
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (window.__OPENCODE__) return `http://127.0.0.1:${window.__OPENCODE__.port}`
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return window.location.origin
})
export function AppBaseProviders(props: ParentProps) { export function AppBaseProviders(props: ParentProps) {
return ( return (
<MetaProvider> <MetaProvider>
@@ -77,9 +65,18 @@ function ServerKey(props: ParentProps) {
) )
} }
export function AppInterface() { export function AppInterface(props: { defaultUrl?: string }) {
const defaultServerUrl = () => {
if (props.defaultUrl) return props.defaultUrl
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return window.location.origin
}
return ( return (
<ServerProvider defaultUrl={defaultServerUrl}> <ServerProvider defaultUrl={defaultServerUrl()}>
<ServerKey> <ServerKey>
<GlobalSDKProvider> <GlobalSDKProvider>
<GlobalSyncProvider> <GlobalSyncProvider>
@@ -108,18 +105,16 @@ export function AppInterface() {
<Route path="/" component={() => <Navigate href="session" />} /> <Route path="/" component={() => <Navigate href="session" />} />
<Route <Route
path="/session/:id?" path="/session/:id?"
component={(p) => ( component={() => (
<Show when={p.params.id ?? "new"} keyed> <TerminalProvider>
<TerminalProvider> <FileProvider>
<FileProvider> <PromptProvider>
<PromptProvider> <Suspense fallback={<Loading />}>
<Suspense fallback={<Loading />}> <Session />
<Session /> </Suspense>
</Suspense> </PromptProvider>
</PromptProvider> </FileProvider>
</FileProvider> </TerminalProvider>
</TerminalProvider>
</Show>
)} )}
/> />
</Route> </Route>
@@ -7,15 +7,11 @@ import { createMemo, createSignal, For, Show } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSDK } from "@/context/global-sdk"
import { type LocalProject, getAvatarColors } from "@/context/layout" import { type LocalProject, getAvatarColors } from "@/context/layout"
import { getFilename } from "@opencode-ai/util/path"
import { Avatar } from "@opencode-ai/ui/avatar" import { Avatar } from "@opencode-ai/ui/avatar"
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
function getFilename(input: string) {
const parts = input.split("/")
return parts[parts.length - 1] || input
}
export function DialogEditProject(props: { project: LocalProject }) { export function DialogEditProject(props: { project: LocalProject }) {
const dialog = useDialog() const dialog = useDialog()
const globalSDK = useGlobalSDK() const globalSDK = useGlobalSDK()
@@ -0,0 +1,99 @@
import { Component, createMemo } from "solid-js"
import { useNavigate, useParams } from "@solidjs/router"
import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk"
import { usePrompt } from "@/context/prompt"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { extractPromptFromParts } from "@/utils/prompt"
import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client"
import { base64Encode } from "@opencode-ai/util/encode"
interface ForkableMessage {
id: string
text: string
time: string
}
function formatTime(date: Date): string {
return date.toLocaleTimeString(undefined, { timeStyle: "short" })
}
export const DialogFork: Component = () => {
const params = useParams()
const navigate = useNavigate()
const sync = useSync()
const sdk = useSDK()
const prompt = usePrompt()
const dialog = useDialog()
const messages = createMemo((): ForkableMessage[] => {
const sessionID = params.id
if (!sessionID) return []
const msgs = sync.data.message[sessionID] ?? []
const result: ForkableMessage[] = []
for (const message of msgs) {
if (message.role !== "user") continue
const parts = sync.data.part[message.id] ?? []
const textPart = parts.find((x): x is SDKTextPart => x.type === "text" && !x.synthetic && !x.ignored)
if (!textPart) continue
result.push({
id: message.id,
text: textPart.text.replace(/\n/g, " ").slice(0, 200),
time: formatTime(new Date(message.time.created)),
})
}
return result.reverse()
})
const handleSelect = (item: ForkableMessage | undefined) => {
if (!item) return
const sessionID = params.id
if (!sessionID) return
const parts = sync.data.part[item.id] ?? []
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
dialog.close()
sdk.client.session.fork({ sessionID, messageID: item.id }).then((forked) => {
if (!forked.data) return
navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`)
requestAnimationFrame(() => {
prompt.set(restored)
})
})
}
return (
<Dialog title="Fork from message">
<List
class="flex-1 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0"
search={{ placeholder: "Search", autofocus: true }}
emptyMessage="No messages to fork from"
key={(x) => x.id}
items={messages}
filterKeys={["text"]}
onSelect={handleSelect}
>
{(item) => (
<div class="w-full flex items-center gap-2">
<span class="truncate flex-1 min-w-0 text-left" style={{ "font-weight": "400" }}>
{item.text}
</span>
<span class="text-text-weak shrink-0" style={{ "font-weight": "400" }}>
{item.time}
</span>
</div>
)}
</List>
</Dialog>
)
}
@@ -15,6 +15,7 @@ export function DialogSelectFile() {
const params = useParams() const params = useParams()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey())) const tabs = createMemo(() => layout.tabs(sessionKey()))
const view = createMemo(() => layout.view(sessionKey()))
return ( return (
<Dialog title="Select file"> <Dialog title="Select file">
<List <List
@@ -27,7 +28,7 @@ export function DialogSelectFile() {
const value = file.tab(path) const value = file.tab(path)
tabs().open(value) tabs().open(value)
file.load(path) file.load(path)
layout.review.open() view().reviewPanel.open()
} }
dialog.close() dialog.close()
}} }}
@@ -76,7 +76,7 @@ export const ModelSelectorPopover: Component<{
<Kobalte open={open()} onOpenChange={setOpen} placement="top-start" gutter={8}> <Kobalte open={open()} onOpenChange={setOpen} placement="top-start" gutter={8}>
<Kobalte.Trigger as="div">{props.children}</Kobalte.Trigger> <Kobalte.Trigger as="div">{props.children}</Kobalte.Trigger>
<Kobalte.Portal> <Kobalte.Portal>
<Kobalte.Content class="w-72 h-80 flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none"> <Kobalte.Content class="w-72 h-80 flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden">
<Kobalte.Title class="sr-only">Select model</Kobalte.Title> <Kobalte.Title class="sr-only">Select model</Kobalte.Title>
<ModelList provider={props.provider} onSelect={() => setOpen(false)} class="p-1" /> <ModelList provider={props.provider} onSelect={() => setOpen(false)} class="p-1" />
</Kobalte.Content> </Kobalte.Content>
@@ -1,4 +1,4 @@
import { createEffect, createMemo, onCleanup } from "solid-js" import { createResource, createEffect, createMemo, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store" import { createStore, reconcile } from "solid-js/store"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
@@ -35,6 +35,8 @@ export function DialogSelectServer() {
error: "", error: "",
status: {} as Record<string, ServerStatus | undefined>, status: {} as Record<string, ServerStatus | undefined>,
}) })
const [defaultUrl, defaultUrlActions] = createResource(() => platform.getDefaultServerUrl?.())
const isDesktop = platform.platform === "desktop"
const items = createMemo(() => { const items = createMemo(() => {
const current = server.url const current = server.url
@@ -173,6 +175,53 @@ export function DialogSelectServer() {
</div> </div>
</form> </form>
</div> </div>
<Show when={isDesktop}>
<div class="mt-6 px-3 flex flex-col gap-1.5">
<div class="px-3">
<h3 class="text-14-regular text-text-weak">Default server</h3>
<p class="text-12-regular text-text-weak mt-1">
Connect to this server on app launch instead of starting a local server. Requires restart.
</p>
</div>
<div class="flex items-center gap-2 px-3 py-2">
<Show
when={defaultUrl()}
fallback={
<Show
when={server.url}
fallback={<span class="text-14-regular text-text-weak">No server selected</span>}
>
<Button
variant="secondary"
size="small"
onClick={async () => {
await platform.setDefaultServerUrl?.(server.url)
defaultUrlActions.refetch(server.url)
}}
>
Set current server as default
</Button>
</Show>
}
>
<div class="flex items-center gap-2 flex-1 min-w-0">
<span class="truncate text-14-regular">{serverDisplayName(defaultUrl()!)}</span>
</div>
<Button
variant="ghost"
size="small"
onClick={async () => {
await platform.setDefaultServerUrl?.(null)
defaultUrlActions.refetch()
}}
>
Clear
</Button>
</Show>
</div>
</div>
</Show>
</div> </div>
</Dialog> </Dialog>
) )
+10 -1
View File
@@ -38,6 +38,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select" import { Select } from "@opencode-ai/ui/select"
import { getDirectory, getFilename } from "@opencode-ai/util/path" import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { ModelSelectorPopover } from "@/components/dialog-select-model" import { ModelSelectorPopover } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
@@ -386,6 +387,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const { const {
flat: atFlat, flat: atFlat,
active: atActive, active: atActive,
setActive: setAtActive,
onInput: atOnInput, onInput: atOnInput,
onKeyDown: atOnKeyDown, onKeyDown: atOnKeyDown,
} = useFilteredList<AtOption>({ } = useFilteredList<AtOption>({
@@ -452,6 +454,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const { const {
flat: slashFlat, flat: slashFlat,
active: slashActive, active: slashActive,
setActive: setSlashActive,
onInput: slashOnInput, onInput: slashOnInput,
onKeyDown: slashOnKeyDown, onKeyDown: slashOnKeyDown,
refetch: slashRefetch, refetch: slashRefetch,
@@ -1299,6 +1302,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10 class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10
overflow-auto no-scrollbar flex flex-col p-2 rounded-md overflow-auto no-scrollbar flex flex-col p-2 rounded-md
border border-border-base bg-surface-raised-stronger-non-alpha shadow-md" border border-border-base bg-surface-raised-stronger-non-alpha shadow-md"
onMouseDown={(e) => e.preventDefault()}
> >
<Switch> <Switch>
<Match when={store.popover === "at"}> <Match when={store.popover === "at"}>
@@ -1314,6 +1318,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
"bg-surface-raised-base-hover": atActive() === atKey(item), "bg-surface-raised-base-hover": atActive() === atKey(item),
}} }}
onClick={() => handleAtSelect(item)} onClick={() => handleAtSelect(item)}
onMouseEnter={() => setAtActive(atKey(item))}
> >
<Show <Show
when={item.type === "agent"} when={item.type === "agent"}
@@ -1360,6 +1365,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
"bg-surface-raised-base-hover": slashActive() === cmd.id, "bg-surface-raised-base-hover": slashActive() === cmd.id,
}} }}
onClick={() => handleSlashSelect(cmd)} onClick={() => handleSlashSelect(cmd)}
onMouseEnter={() => setSlashActive(cmd.id)}
> >
<div class="flex items-center gap-2 min-w-0"> <div class="flex items-center gap-2 min-w-0">
<span class="text-14-regular text-text-strong whitespace-nowrap">/{cmd.trigger}</span> <span class="text-14-regular text-text-strong whitespace-nowrap">/{cmd.trigger}</span>
@@ -1479,7 +1485,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<img <img
src={attachment.dataUrl} src={attachment.dataUrl}
alt={attachment.filename} alt={attachment.filename}
class="size-16 rounded-md object-cover border border-border-base" class="size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
onClick={() =>
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
}
/> />
</Show> </Show>
<button <button
@@ -20,6 +20,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const variant = createMemo(() => props.variant ?? "button") const variant = createMemo(() => props.variant ?? "button")
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey())) const tabs = createMemo(() => layout.tabs(sessionKey()))
const view = createMemo(() => layout.view(sessionKey()))
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
const cost = createMemo(() => { const cost = createMemo(() => {
@@ -48,7 +49,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const openContext = () => { const openContext = () => {
if (!params.id) return if (!params.id) return
layout.review.open() view().reviewPanel.open()
tabs().open("context") tabs().open("context")
tabs().setActive("context") tabs().setActive("context")
} }
@@ -43,6 +43,8 @@ export function SessionHeader() {
}) })
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled") const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
const worktrees = createMemo(() => layout.projects.list().map((p) => p.worktree), [], { equals: same }) const worktrees = createMemo(() => layout.projects.list().map((p) => p.worktree), [], { equals: same })
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const view = createMemo(() => layout.view(sessionKey()))
function navigateToProject(directory: string) { function navigateToProject(directory: string) {
navigate(`/${base64Encode(directory)}`) navigate(`/${base64Encode(directory)}`)
@@ -171,20 +173,24 @@ export function SessionHeader() {
title="Toggle review" title="Toggle review"
keybind={command.keybind("review.toggle")} keybind={command.keybind("review.toggle")}
> >
<Button variant="ghost" class="group/review-toggle size-6 p-0" onClick={layout.review.toggle}> <Button
variant="ghost"
class="group/review-toggle size-6 p-0"
onClick={() => view().reviewPanel.toggle()}
>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0"> <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon <Icon
name={layout.review.opened() ? "layout-right" : "layout-left"} name={view().reviewPanel.opened() ? "layout-right" : "layout-left"}
size="small" size="small"
class="group-hover/review-toggle:hidden" class="group-hover/review-toggle:hidden"
/> />
<Icon <Icon
name={layout.review.opened() ? "layout-right-partial" : "layout-left-partial"} name={view().reviewPanel.opened() ? "layout-right-partial" : "layout-left-partial"}
size="small" size="small"
class="hidden group-hover/review-toggle:inline-block" class="hidden group-hover/review-toggle:inline-block"
/> />
<Icon <Icon
name={layout.review.opened() ? "layout-right-full" : "layout-left-full"} name={view().reviewPanel.opened() ? "layout-right-full" : "layout-left-full"}
size="small" size="small"
class="hidden group-active/review-toggle:inline-block" class="hidden group-active/review-toggle:inline-block"
/> />
@@ -197,11 +203,11 @@ export function SessionHeader() {
title="Toggle terminal" title="Toggle terminal"
keybind={command.keybind("terminal.toggle")} keybind={command.keybind("terminal.toggle")}
> >
<Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.terminal.toggle}> <Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={() => view().terminal.toggle()}>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0"> <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon <Icon
size="small" size="small"
name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"} name={view().terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
class="group-hover/terminal-toggle:hidden" class="group-hover/terminal-toggle:hidden"
/> />
<Icon <Icon
@@ -211,7 +217,7 @@ export function SessionHeader() {
/> />
<Icon <Icon
size="small" size="small"
name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"} name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
class="hidden group-active/terminal-toggle:inline-block" class="hidden group-active/terminal-toggle:inline-block"
/> />
</div> </div>
+16
View File
@@ -45,6 +45,8 @@ export const Terminal = (props: TerminalProps) => {
let serializeAddon: SerializeAddon let serializeAddon: SerializeAddon
let fitAddon: FitAddon let fitAddon: FitAddon
let handleResize: () => void let handleResize: () => void
let handleTextareaFocus: () => void
let handleTextareaBlur: () => void
let reconnect: number | undefined let reconnect: number | undefined
let disposed = false let disposed = false
@@ -105,6 +107,7 @@ export const Terminal = (props: TerminalProps) => {
const t = new mod.Terminal({ const t = new mod.Terminal({
cursorBlink: true, cursorBlink: true,
cursorStyle: "bar",
fontSize: 14, fontSize: 14,
fontFamily: "IBM Plex Mono, monospace", fontFamily: "IBM Plex Mono, monospace",
allowTransparency: true, allowTransparency: true,
@@ -170,6 +173,17 @@ export const Terminal = (props: TerminalProps) => {
t.open(container) t.open(container)
container.addEventListener("pointerdown", handlePointerDown) container.addEventListener("pointerdown", handlePointerDown)
handleTextareaFocus = () => {
t.options.cursorBlink = true
}
handleTextareaBlur = () => {
t.options.cursorBlink = false
}
t.textarea?.addEventListener("focus", handleTextareaFocus)
t.textarea?.addEventListener("blur", handleTextareaBlur)
focusTerminal() focusTerminal()
if (local.pty.buffer) { if (local.pty.buffer) {
@@ -242,6 +256,8 @@ export const Terminal = (props: TerminalProps) => {
window.removeEventListener("resize", handleResize) window.removeEventListener("resize", handleResize)
} }
container.removeEventListener("pointerdown", handlePointerDown) container.removeEventListener("pointerdown", handlePointerDown)
term?.textarea?.removeEventListener("focus", handleTextareaFocus)
term?.textarea?.removeEventListener("blur", handleTextareaBlur)
const t = term const t = term
if (serializeAddon && props.onCleanup && t) { if (serializeAddon && props.onCleanup && t) {
+146 -64
View File
@@ -1,4 +1,4 @@
import { createEffect, createMemo, onCleanup } from "solid-js" import { createEffect, createMemo, createRoot, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import type { FileContent } from "@opencode-ai/sdk/v2" import type { FileContent } from "@opencode-ai/sdk/v2"
@@ -82,8 +82,106 @@ function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
} }
} }
const WORKSPACE_KEY = "__workspace__"
const MAX_FILE_VIEW_SESSIONS = 20
const MAX_VIEW_FILES = 500
type ViewSession = ReturnType<typeof createViewSession>
type ViewCacheEntry = {
value: ViewSession
dispose: VoidFunction
}
function createViewSession(dir: string, id: string | undefined) {
const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
const [view, setView, _, ready] = persisted(
Persist.scoped(dir, id, "file-view", [legacyViewKey]),
createStore<{
file: Record<string, FileViewState>
}>({
file: {},
}),
)
const meta = { pruned: false }
const pruneView = (keep?: string) => {
const keys = Object.keys(view.file)
if (keys.length <= MAX_VIEW_FILES) return
const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES)
if (drop.length === 0) return
setView(
produce((draft) => {
for (const key of drop) {
delete draft.file[key]
}
}),
)
}
createEffect(() => {
if (!ready()) return
if (meta.pruned) return
meta.pruned = true
pruneView()
})
const scrollTop = (path: string) => view.file[path]?.scrollTop
const scrollLeft = (path: string) => view.file[path]?.scrollLeft
const selectedLines = (path: string) => view.file[path]?.selectedLines
const setScrollTop = (path: string, top: number) => {
setView("file", path, (current) => {
if (current?.scrollTop === top) return current
return {
...(current ?? {}),
scrollTop: top,
}
})
pruneView(path)
}
const setScrollLeft = (path: string, left: number) => {
setView("file", path, (current) => {
if (current?.scrollLeft === left) return current
return {
...(current ?? {}),
scrollLeft: left,
}
})
pruneView(path)
}
const setSelectedLines = (path: string, range: SelectedLineRange | null) => {
const next = range ? normalizeSelectedLines(range) : null
setView("file", path, (current) => {
if (current?.selectedLines === next) return current
return {
...(current ?? {}),
selectedLines: next,
}
})
pruneView(path)
}
return {
ready,
scrollTop,
scrollLeft,
selectedLines,
setScrollTop,
setScrollLeft,
setSelectedLines,
}
}
export const { use: useFile, provider: FileProvider } = createSimpleContext({ export const { use: useFile, provider: FileProvider } = createSimpleContext({
name: "File", name: "File",
gate: false,
init: () => { init: () => {
const sdk = useSDK() const sdk = useSDK()
const sync = useSync() const sync = useSync()
@@ -134,42 +232,45 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
file: {}, file: {},
}) })
const legacyViewKey = createMemo(() => `${params.dir}/file${params.id ? "/" + params.id : ""}.v1`) const viewCache = new Map<string, ViewCacheEntry>()
const [view, setView, _, ready] = persisted( const disposeViews = () => {
Persist.scoped(params.dir!, params.id, "file-view", [legacyViewKey()]), for (const entry of viewCache.values()) {
createStore<{ entry.dispose()
file: Record<string, FileViewState> }
}>({ viewCache.clear()
file: {},
}),
)
const MAX_VIEW_FILES = 500
const viewMeta = { pruned: false }
const pruneView = (keep?: string) => {
const keys = Object.keys(view.file)
if (keys.length <= MAX_VIEW_FILES) return
const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES)
if (drop.length === 0) return
setView(
produce((draft) => {
for (const key of drop) {
delete draft.file[key]
}
}),
)
} }
createEffect(() => { const pruneViews = () => {
if (!ready()) return while (viewCache.size > MAX_FILE_VIEW_SESSIONS) {
if (viewMeta.pruned) return const first = viewCache.keys().next().value
viewMeta.pruned = true if (!first) return
pruneView() const entry = viewCache.get(first)
}) entry?.dispose()
viewCache.delete(first)
}
}
const loadView = (dir: string, id: string | undefined) => {
const key = `${dir}:${id ?? WORKSPACE_KEY}`
const existing = viewCache.get(key)
if (existing) {
viewCache.delete(key)
viewCache.set(key, existing)
return existing.value
}
const entry = createRoot((dispose) => ({
value: createViewSession(dir, id),
dispose,
}))
viewCache.set(key, entry)
pruneViews()
return entry.value
}
const view = createMemo(() => loadView(params.dir!, params.id))
function ensure(path: string) { function ensure(path: string) {
if (!path) return if (!path) return
@@ -246,51 +347,32 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const get = (input: string) => store.file[normalize(input)] const get = (input: string) => store.file[normalize(input)]
const scrollTop = (input: string) => view.file[normalize(input)]?.scrollTop const scrollTop = (input: string) => view().scrollTop(normalize(input))
const scrollLeft = (input: string) => view.file[normalize(input)]?.scrollLeft const scrollLeft = (input: string) => view().scrollLeft(normalize(input))
const selectedLines = (input: string) => view.file[normalize(input)]?.selectedLines const selectedLines = (input: string) => view().selectedLines(normalize(input))
const setScrollTop = (input: string, top: number) => { const setScrollTop = (input: string, top: number) => {
const path = normalize(input) const path = normalize(input)
setView("file", path, (current) => { view().setScrollTop(path, top)
if (current?.scrollTop === top) return current
return {
...(current ?? {}),
scrollTop: top,
}
})
pruneView(path)
} }
const setScrollLeft = (input: string, left: number) => { const setScrollLeft = (input: string, left: number) => {
const path = normalize(input) const path = normalize(input)
setView("file", path, (current) => { view().setScrollLeft(path, left)
if (current?.scrollLeft === left) return current
return {
...(current ?? {}),
scrollLeft: left,
}
})
pruneView(path)
} }
const setSelectedLines = (input: string, range: SelectedLineRange | null) => { const setSelectedLines = (input: string, range: SelectedLineRange | null) => {
const path = normalize(input) const path = normalize(input)
const next = range ? normalizeSelectedLines(range) : null view().setSelectedLines(path, range)
setView("file", path, (current) => {
if (current?.selectedLines === next) return current
return {
...(current ?? {}),
selectedLines: next,
}
})
pruneView(path)
} }
onCleanup(() => stop()) onCleanup(() => {
stop()
disposeViews()
})
return { return {
ready, ready: () => view().ready(),
normalize, normalize,
tab, tab,
pathFromTab, pathFromTab,
+61 -25
View File
@@ -33,6 +33,8 @@ type SessionTabs = {
type SessionView = { type SessionView = {
scroll: Record<string, SessionScroll> scroll: Record<string, SessionScroll>
reviewOpen?: string[] reviewOpen?: string[]
terminalOpened?: boolean
reviewPanelOpened?: boolean
} }
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean } export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
@@ -53,11 +55,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
width: 280, width: 280,
}, },
terminal: { terminal: {
opened: false,
height: 280, height: 280,
}, },
review: { review: {
opened: true,
diffStyle: "split" as ReviewDiffStyle, diffStyle: "split" as ReviewDiffStyle,
}, },
session: { session: {
@@ -150,7 +150,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const current = store.sessionView[sessionKey] const current = store.sessionView[sessionKey]
const keep = meta.active ?? sessionKey const keep = meta.active ?? sessionKey
if (!current) { if (!current) {
setStore("sessionView", sessionKey, { scroll: next }) setStore("sessionView", sessionKey, { scroll: next, terminalOpened: false, reviewPanelOpened: true })
prune(keep) prune(keep)
return return
} }
@@ -306,40 +306,20 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}, },
}, },
terminal: { terminal: {
opened: createMemo(() => store.terminal.opened),
open() {
setStore("terminal", "opened", true)
},
close() {
setStore("terminal", "opened", false)
},
toggle() {
setStore("terminal", "opened", (x) => !x)
},
height: createMemo(() => store.terminal.height), height: createMemo(() => store.terminal.height),
resize(height: number) { resize(height: number) {
setStore("terminal", "height", height) setStore("terminal", "height", height)
}, },
}, },
review: { review: {
opened: createMemo(() => store.review?.opened ?? true),
diffStyle: createMemo(() => store.review?.diffStyle ?? "split"), diffStyle: createMemo(() => store.review?.diffStyle ?? "split"),
setDiffStyle(diffStyle: ReviewDiffStyle) { setDiffStyle(diffStyle: ReviewDiffStyle) {
if (!store.review) { if (!store.review) {
setStore("review", { opened: true, diffStyle }) setStore("review", { diffStyle })
return return
} }
setStore("review", "diffStyle", diffStyle) setStore("review", "diffStyle", diffStyle)
}, },
open() {
setStore("review", "opened", true)
},
close() {
setStore("review", "opened", false)
},
toggle() {
setStore("review", "opened", (x) => !x)
},
}, },
session: { session: {
width: createMemo(() => store.session?.width ?? 600), width: createMemo(() => store.session?.width ?? 600),
@@ -367,6 +347,33 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
touch(sessionKey) touch(sessionKey)
scroll.seed(sessionKey) scroll.seed(sessionKey)
const s = createMemo(() => store.sessionView[sessionKey] ?? { scroll: {} }) const s = createMemo(() => store.sessionView[sessionKey] ?? { scroll: {} })
const terminalOpened = createMemo(() => s().terminalOpened ?? false)
const reviewPanelOpened = createMemo(() => s().reviewPanelOpened ?? true)
function setTerminalOpened(next: boolean) {
const current = store.sessionView[sessionKey]
if (!current) {
setStore("sessionView", sessionKey, { scroll: {}, terminalOpened: next, reviewPanelOpened: true })
return
}
const value = current.terminalOpened ?? false
if (value === next) return
setStore("sessionView", sessionKey, "terminalOpened", next)
}
function setReviewPanelOpened(next: boolean) {
const current = store.sessionView[sessionKey]
if (!current) {
setStore("sessionView", sessionKey, { scroll: {}, terminalOpened: false, reviewPanelOpened: next })
return
}
const value = current.reviewPanelOpened ?? true
if (value === next) return
setStore("sessionView", sessionKey, "reviewPanelOpened", next)
}
return { return {
scroll(tab: string) { scroll(tab: string) {
return scroll.scroll(sessionKey, tab) return scroll.scroll(sessionKey, tab)
@@ -374,12 +381,41 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setScroll(tab: string, pos: SessionScroll) { setScroll(tab: string, pos: SessionScroll) {
scroll.setScroll(sessionKey, tab, pos) scroll.setScroll(sessionKey, tab, pos)
}, },
terminal: {
opened: terminalOpened,
open() {
setTerminalOpened(true)
},
close() {
setTerminalOpened(false)
},
toggle() {
setTerminalOpened(!terminalOpened())
},
},
reviewPanel: {
opened: reviewPanelOpened,
open() {
setReviewPanelOpened(true)
},
close() {
setReviewPanelOpened(false)
},
toggle() {
setReviewPanelOpened(!reviewPanelOpened())
},
},
review: { review: {
open: createMemo(() => s().reviewOpen), open: createMemo(() => s().reviewOpen),
setOpen(open: string[]) { setOpen(open: string[]) {
const current = store.sessionView[sessionKey] const current = store.sessionView[sessionKey]
if (!current) { if (!current) {
setStore("sessionView", sessionKey, { scroll: {}, reviewOpen: open }) setStore("sessionView", sessionKey, {
scroll: {},
terminalOpened: false,
reviewPanelOpened: true,
reviewOpen: open,
})
return return
} }
+6
View File
@@ -37,6 +37,12 @@ export type Platform = {
/** Fetch override */ /** Fetch override */
fetch?: typeof fetch fetch?: typeof fetch
/** Get the configured default server URL (desktop only) */
getDefaultServerUrl?(): Promise<string | null>
/** Set the default server URL to use on app startup (desktop only) */
setDefaultServerUrl?(url: string | null): Promise<void>
} }
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({ export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
+131 -59
View File
@@ -1,6 +1,6 @@
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createMemo } from "solid-js" import { batch, createMemo, createRoot, onCleanup } from "solid-js"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import type { FileSelection } from "@/context/file" import type { FileSelection } from "@/context/file"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
@@ -99,74 +99,146 @@ function clonePrompt(prompt: Prompt): Prompt {
return prompt.map(clonePart) return prompt.map(clonePart)
} }
const WORKSPACE_KEY = "__workspace__"
const MAX_PROMPT_SESSIONS = 20
type PromptSession = ReturnType<typeof createPromptSession>
type PromptCacheEntry = {
value: PromptSession
dispose: VoidFunction
}
function createPromptSession(dir: string, id: string | undefined) {
const legacy = `${dir}/prompt${id ? "/" + id : ""}.v2`
const [store, setStore, _, ready] = persisted(
Persist.scoped(dir, id, "prompt", [legacy]),
createStore<{
prompt: Prompt
cursor?: number
context: {
activeTab: boolean
items: (ContextItem & { key: string })[]
}
}>({
prompt: clonePrompt(DEFAULT_PROMPT),
cursor: undefined,
context: {
activeTab: true,
items: [],
},
}),
)
function keyForItem(item: ContextItem) {
if (item.type !== "file") return item.type
const start = item.selection?.startLine
const end = item.selection?.endLine
return `${item.type}:${item.path}:${start}:${end}`
}
return {
ready,
current: createMemo(() => store.prompt),
cursor: createMemo(() => store.cursor),
dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
context: {
activeTab: createMemo(() => store.context.activeTab),
items: createMemo(() => store.context.items),
addActive() {
setStore("context", "activeTab", true)
},
removeActive() {
setStore("context", "activeTab", false)
},
add(item: ContextItem) {
const key = keyForItem(item)
if (store.context.items.find((x) => x.key === key)) return
setStore("context", "items", (items) => [...items, { key, ...item }])
},
remove(key: string) {
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
},
},
set(prompt: Prompt, cursorPosition?: number) {
const next = clonePrompt(prompt)
batch(() => {
setStore("prompt", next)
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
})
},
reset() {
batch(() => {
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
setStore("cursor", 0)
})
},
}
}
export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({
name: "Prompt", name: "Prompt",
gate: false,
init: () => { init: () => {
const params = useParams() const params = useParams()
const legacy = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v2`) const cache = new Map<string, PromptCacheEntry>()
const [store, setStore, _, ready] = persisted( const disposeAll = () => {
Persist.scoped(params.dir!, params.id, "prompt", [legacy()]), for (const entry of cache.values()) {
createStore<{ entry.dispose()
prompt: Prompt }
cursor?: number cache.clear()
context: {
activeTab: boolean
items: (ContextItem & { key: string })[]
}
}>({
prompt: clonePrompt(DEFAULT_PROMPT),
cursor: undefined,
context: {
activeTab: true,
items: [],
},
}),
)
function keyForItem(item: ContextItem) {
if (item.type !== "file") return item.type
const start = item.selection?.startLine
const end = item.selection?.endLine
return `${item.type}:${item.path}:${start}:${end}`
} }
onCleanup(disposeAll)
const prune = () => {
while (cache.size > MAX_PROMPT_SESSIONS) {
const first = cache.keys().next().value
if (!first) return
const entry = cache.get(first)
entry?.dispose()
cache.delete(first)
}
}
const load = (dir: string, id: string | undefined) => {
const key = `${dir}:${id ?? WORKSPACE_KEY}`
const existing = cache.get(key)
if (existing) {
cache.delete(key)
cache.set(key, existing)
return existing.value
}
const entry = createRoot((dispose) => ({
value: createPromptSession(dir, id),
dispose,
}))
cache.set(key, entry)
prune()
return entry.value
}
const session = createMemo(() => load(params.dir!, params.id))
return { return {
ready, ready: () => session().ready(),
current: createMemo(() => store.prompt), current: () => session().current(),
cursor: createMemo(() => store.cursor), cursor: () => session().cursor(),
dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)), dirty: () => session().dirty(),
context: { context: {
activeTab: createMemo(() => store.context.activeTab), activeTab: () => session().context.activeTab(),
items: createMemo(() => store.context.items), items: () => session().context.items(),
addActive() { addActive: () => session().context.addActive(),
setStore("context", "activeTab", true) removeActive: () => session().context.removeActive(),
}, add: (item: ContextItem) => session().context.add(item),
removeActive() { remove: (key: string) => session().context.remove(key),
setStore("context", "activeTab", false)
},
add(item: ContextItem) {
const key = keyForItem(item)
if (store.context.items.find((x) => x.key === key)) return
setStore("context", "items", (items) => [...items, { key, ...item }])
},
remove(key: string) {
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
},
},
set(prompt: Prompt, cursorPosition?: number) {
const next = clonePrompt(prompt)
batch(() => {
setStore("prompt", next)
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
})
},
reset() {
batch(() => {
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
setStore("cursor", 0)
})
}, },
set: (prompt: Prompt, cursorPosition?: number) => session().set(prompt, cursorPosition),
reset: () => session().reset(),
} }
}, },
}) })
+1 -4
View File
@@ -16,10 +16,7 @@ export function normalizeServerUrl(input: string) {
export function serverDisplayName(url: string) { export function serverDisplayName(url: string) {
if (!url) return "" if (!url) return ""
return url return url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
.replace(/^https?:\/\//, "")
.replace(/\/+$/, "")
.split("/")[0]
} }
function projectsKey(url: string) { function projectsKey(url: string) {
+160 -54
View File
@@ -1,5 +1,5 @@
import { batch, createMemo } from "solid-js" import { batch, createMemo } from "solid-js"
import { produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
import { Binary } from "@opencode-ai/util/binary" import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry" import { retry } from "@opencode-ai/util/retry"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
@@ -14,6 +14,76 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const sdk = useSDK() const sdk = useSDK()
const [store, setStore] = globalSync.child(sdk.directory) const [store, setStore] = globalSync.child(sdk.directory)
const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/") const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/")
const chunk = 200
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
const [meta, setMeta] = createStore({
limit: {} as Record<string, number>,
complete: {} as Record<string, boolean>,
loading: {} as Record<string, boolean>,
})
const getSession = (sessionID: string) => {
const match = Binary.search(store.session, sessionID, (s) => s.id)
if (match.found) return store.session[match.index]
return undefined
}
const limitFor = (count: number) => {
if (count <= chunk) return chunk
return Math.ceil(count / chunk) * chunk
}
const hydrateMessages = (sessionID: string) => {
if (meta.limit[sessionID] !== undefined) return
const messages = store.message[sessionID]
if (!messages) return
const limit = limitFor(messages.length)
setMeta("limit", sessionID, limit)
setMeta("complete", sessionID, messages.length < limit)
}
const loadMessages = async (sessionID: string, limit: number) => {
if (meta.loading[sessionID]) return
setMeta("loading", sessionID, true)
await retry(() => sdk.client.session.messages({ sessionID, limit }))
.then((messages) => {
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
const next = items
.map((x) => x.info)
.filter((m) => !!m?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
batch(() => {
setStore("message", sessionID, reconcile(next, { key: "id" }))
for (const message of items) {
setStore(
"part",
message.info.id,
reconcile(
message.parts
.filter((p) => !!p?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
}
setMeta("limit", sessionID, limit)
setMeta("complete", sessionID, next.length < limit)
})
})
.finally(() => {
setMeta("loading", sessionID, false)
})
}
return { return {
data: store, data: store,
@@ -30,11 +100,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return undefined return undefined
}, },
session: { session: {
get(sessionID: string) { get: getSession,
const match = Binary.search(store.session, sessionID, (s) => s.id)
if (match.found) return store.session[match.index]
return undefined
},
addOptimisticMessage(input: { addOptimisticMessage(input: {
sessionID: string sessionID: string
messageID: string messageID: string
@@ -66,58 +132,98 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}), }),
) )
}, },
async sync(sessionID: string, _isRetry = false) { async sync(sessionID: string) {
const [session, messages, todo, diff] = await Promise.all([ const hasSession = getSession(sessionID) !== undefined
retry(() => sdk.client.session.get({ sessionID })), hydrateMessages(sessionID)
retry(() => sdk.client.session.messages({ sessionID, limit: 1000 })),
retry(() => sdk.client.session.todo({ sessionID })),
retry(() => sdk.client.session.diff({ sessionID })),
])
batch(() => { const hasMessages = store.message[sessionID] !== undefined
setStore( if (hasSession && hasMessages) return
"session",
produce((draft) => {
const match = Binary.search(draft, sessionID, (s) => s.id)
if (match.found) {
draft[match.index] = session.data!
return
}
draft.splice(match.index, 0, session.data!)
}),
)
setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" })) const pending = inflight.get(sessionID)
setStore( if (pending) return pending
"message",
sessionID,
reconcile(
(messages.data ?? [])
.map((x) => x.info)
.filter((m) => !!m?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
for (const message of messages.data ?? []) { const limit = meta.limit[sessionID] ?? chunk
if (!message?.info?.id) continue
setStore(
"part",
message.info.id,
reconcile(
message.parts
.filter((p) => !!p?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
}
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" })) const sessionReq = hasSession
}) ? Promise.resolve()
: retry(() => sdk.client.session.get({ sessionID })).then((session) => {
const data = session.data
if (!data) return
setStore(
"session",
produce((draft) => {
const match = Binary.search(draft, sessionID, (s) => s.id)
if (match.found) {
draft[match.index] = data
return
}
draft.splice(match.index, 0, data)
}),
)
})
const messagesReq = hasMessages ? Promise.resolve() : loadMessages(sessionID, limit)
const promise = Promise.all([sessionReq, messagesReq])
.then(() => {})
.finally(() => {
inflight.delete(sessionID)
})
inflight.set(sessionID, promise)
return promise
},
async diff(sessionID: string) {
if (store.session_diff[sessionID] !== undefined) return
const pending = inflightDiff.get(sessionID)
if (pending) return pending
const promise = retry(() => sdk.client.session.diff({ sessionID }))
.then((diff) => {
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
})
.finally(() => {
inflightDiff.delete(sessionID)
})
inflightDiff.set(sessionID, promise)
return promise
},
async todo(sessionID: string) {
if (store.todo[sessionID] !== undefined) return
const pending = inflightTodo.get(sessionID)
if (pending) return pending
const promise = retry(() => sdk.client.session.todo({ sessionID }))
.then((todo) => {
setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
})
.finally(() => {
inflightTodo.delete(sessionID)
})
inflightTodo.set(sessionID, promise)
return promise
},
history: {
more(sessionID: string) {
if (store.message[sessionID] === undefined) return false
if (meta.limit[sessionID] === undefined) return false
if (meta.complete[sessionID]) return false
return true
},
loading(sessionID: string) {
return meta.loading[sessionID] ?? false
},
async loadMore(sessionID: string, count = chunk) {
if (meta.loading[sessionID]) return
if (meta.complete[sessionID]) return
const current = meta.limit[sessionID] ?? chunk
await loadMessages(sessionID, current + count)
},
}, },
fetch: async (count = 10) => { fetch: async (count = 10) => {
setStore("limit", (x) => x + count) setStore("limit", (x) => x + count)
+162 -95
View File
@@ -1,6 +1,6 @@
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createMemo } from "solid-js" import { batch, createMemo, createRoot, onCleanup } from "solid-js"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
@@ -14,108 +14,175 @@ export type LocalPTY = {
scrollY?: number scrollY?: number
} }
const WORKSPACE_KEY = "__workspace__"
const MAX_TERMINAL_SESSIONS = 20
type TerminalSession = ReturnType<typeof createTerminalSession>
type TerminalCacheEntry = {
value: TerminalSession
dispose: VoidFunction
}
function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, id: string | undefined) {
const legacy = `${dir}/terminal${id ? "/" + id : ""}.v1`
const [store, setStore, _, ready] = persisted(
Persist.scoped(dir, id, "terminal", [legacy]),
createStore<{
active?: string
all: LocalPTY[]
}>({
all: [],
}),
)
return {
ready,
all: createMemo(() => Object.values(store.all)),
active: createMemo(() => store.active),
new() {
sdk.client.pty
.create({ title: `Terminal ${store.all.length + 1}` })
.then((pty) => {
const id = pty.data?.id
if (!id) return
setStore("all", [
...store.all,
{
id,
title: pty.data?.title ?? "Terminal",
},
])
setStore("active", id)
})
.catch((e) => {
console.error("Failed to create terminal", e)
})
},
update(pty: Partial<LocalPTY> & { id: string }) {
setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
sdk.client.pty
.update({
ptyID: pty.id,
title: pty.title,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
})
.catch((e) => {
console.error("Failed to update terminal", e)
})
},
async clone(id: string) {
const index = store.all.findIndex((x) => x.id === id)
const pty = store.all[index]
if (!pty) return
const clone = await sdk.client.pty
.create({
title: pty.title,
})
.catch((e) => {
console.error("Failed to clone terminal", e)
return undefined
})
if (!clone?.data) return
setStore("all", index, {
...pty,
...clone.data,
})
if (store.active === pty.id) {
setStore("active", clone.data.id)
}
},
open(id: string) {
setStore("active", id)
},
async close(id: string) {
batch(() => {
setStore(
"all",
store.all.filter((x) => x.id !== id),
)
if (store.active === id) {
const index = store.all.findIndex((f) => f.id === id)
const previous = store.all[Math.max(0, index - 1)]
setStore("active", previous?.id)
}
})
await sdk.client.pty.remove({ ptyID: id }).catch((e) => {
console.error("Failed to close terminal", e)
})
},
move(id: string, to: number) {
const index = store.all.findIndex((f) => f.id === id)
if (index === -1) return
setStore(
"all",
produce((all) => {
all.splice(to, 0, all.splice(index, 1)[0])
}),
)
},
}
}
export const { use: useTerminal, provider: TerminalProvider } = createSimpleContext({ export const { use: useTerminal, provider: TerminalProvider } = createSimpleContext({
name: "Terminal", name: "Terminal",
gate: false,
init: () => { init: () => {
const sdk = useSDK() const sdk = useSDK()
const params = useParams() const params = useParams()
const legacy = createMemo(() => `${params.dir}/terminal${params.id ? "/" + params.id : ""}.v1`) const cache = new Map<string, TerminalCacheEntry>()
const [store, setStore, _, ready] = persisted( const disposeAll = () => {
Persist.scoped(params.dir!, params.id, "terminal", [legacy()]), for (const entry of cache.values()) {
createStore<{ entry.dispose()
active?: string }
all: LocalPTY[] cache.clear()
}>({ }
all: [],
}), onCleanup(disposeAll)
)
const prune = () => {
while (cache.size > MAX_TERMINAL_SESSIONS) {
const first = cache.keys().next().value
if (!first) return
const entry = cache.get(first)
entry?.dispose()
cache.delete(first)
}
}
const load = (dir: string, id: string | undefined) => {
const key = `${dir}:${id ?? WORKSPACE_KEY}`
const existing = cache.get(key)
if (existing) {
cache.delete(key)
cache.set(key, existing)
return existing.value
}
const entry = createRoot((dispose) => ({
value: createTerminalSession(sdk, dir, id),
dispose,
}))
cache.set(key, entry)
prune()
return entry.value
}
const session = createMemo(() => load(params.dir!, params.id))
return { return {
ready, ready: () => session().ready(),
all: createMemo(() => Object.values(store.all)), all: () => session().all(),
active: createMemo(() => store.active), active: () => session().active(),
new() { new: () => session().new(),
sdk.client.pty update: (pty: Partial<LocalPTY> & { id: string }) => session().update(pty),
.create({ title: `Terminal ${store.all.length + 1}` }) clone: (id: string) => session().clone(id),
.then((pty) => { open: (id: string) => session().open(id),
const id = pty.data?.id close: (id: string) => session().close(id),
if (!id) return move: (id: string, to: number) => session().move(id, to),
setStore("all", [
...store.all,
{
id,
title: pty.data?.title ?? "Terminal",
},
])
setStore("active", id)
})
.catch((e) => {
console.error("Failed to create terminal", e)
})
},
update(pty: Partial<LocalPTY> & { id: string }) {
setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
sdk.client.pty
.update({
ptyID: pty.id,
title: pty.title,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
})
.catch((e) => {
console.error("Failed to update terminal", e)
})
},
async clone(id: string) {
const index = store.all.findIndex((x) => x.id === id)
const pty = store.all[index]
if (!pty) return
const clone = await sdk.client.pty
.create({
title: pty.title,
})
.catch((e) => {
console.error("Failed to clone terminal", e)
return undefined
})
if (!clone?.data) return
setStore("all", index, {
...pty,
...clone.data,
})
if (store.active === pty.id) {
setStore("active", clone.data.id)
}
},
open(id: string) {
setStore("active", id)
},
async close(id: string) {
batch(() => {
setStore(
"all",
store.all.filter((x) => x.id !== id),
)
if (store.active === id) {
const index = store.all.findIndex((f) => f.id === id)
const previous = store.all[Math.max(0, index - 1)]
setStore("active", previous?.id)
}
})
await sdk.client.pty.remove({ ptyID: id }).catch((e) => {
console.error("Failed to close terminal", e)
})
},
move(id: string, to: number) {
const index = store.all.findIndex((f) => f.id === id)
if (index === -1) return
setStore(
"all",
produce((all) => {
all.splice(to, 0, all.splice(index, 1)[0])
}),
)
},
} }
}, },
}) })
+189 -3
View File
@@ -1,4 +1,5 @@
import { import {
batch,
createEffect, createEffect,
createMemo, createMemo,
createSignal, createSignal,
@@ -31,7 +32,7 @@ import { getFilename } from "@opencode-ai/util/path"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Session } from "@opencode-ai/sdk/v2/client" import { Session } from "@opencode-ai/sdk/v2/client"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { createStore, produce } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
import { import {
DragDropProvider, DragDropProvider,
DragDropSensors, DragDropSensors,
@@ -47,6 +48,7 @@ import { useGlobalSDK } from "@/context/global-sdk"
import { useNotification } from "@/context/notification" import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { Binary } from "@opencode-ai/util/binary" import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
@@ -55,6 +57,7 @@ import { DialogEditProject } from "@/components/dialog-edit-project"
import { DialogSelectServer } from "@/components/dialog-select-server" import { DialogSelectServer } from "@/components/dialog-select-server"
import { useCommand, type CommandOption } from "@/context/command" import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis } from "@/utils/solid-dnd" import { ConstrainDragXAxis } from "@/utils/solid-dnd"
import { navStart } from "@/utils/perf"
import { DialogSelectDirectory } from "@/components/dialog-select-directory" import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { useServer } from "@/context/server" import { useServer } from "@/context/server"
@@ -284,6 +287,146 @@ export default function Layout(props: ParentProps) {
const currentSessions = createMemo(() => projectSessions(currentProject())) const currentSessions = createMemo(() => projectSessions(currentProject()))
type PrefetchQueue = {
inflight: Set<string>
pending: string[]
pendingSet: Set<string>
running: number
}
const prefetchChunk = 200
const prefetchConcurrency = 1
const prefetchPendingLimit = 6
const prefetchToken = { value: 0 }
const prefetchQueues = new Map<string, PrefetchQueue>()
createEffect(() => {
params.dir
globalSDK.url
prefetchToken.value += 1
for (const q of prefetchQueues.values()) {
q.pending.length = 0
q.pendingSet.clear()
}
})
const queueFor = (directory: string) => {
const existing = prefetchQueues.get(directory)
if (existing) return existing
const created: PrefetchQueue = {
inflight: new Set(),
pending: [],
pendingSet: new Set(),
running: 0,
}
prefetchQueues.set(directory, created)
return created
}
const prefetchMessages = (directory: string, sessionID: string, token: number) => {
const [, setStore] = globalSync.child(directory)
return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
.then((messages) => {
if (prefetchToken.value !== token) return
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
const next = items
.map((x) => x.info)
.filter((m) => !!m?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
batch(() => {
setStore("message", sessionID, reconcile(next, { key: "id" }))
for (const message of items) {
setStore(
"part",
message.info.id,
reconcile(
message.parts
.filter((p) => !!p?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
}
})
})
.catch(() => undefined)
}
const pumpPrefetch = (directory: string) => {
const q = queueFor(directory)
if (q.running >= prefetchConcurrency) return
const sessionID = q.pending.shift()
if (!sessionID) return
q.pendingSet.delete(sessionID)
q.inflight.add(sessionID)
q.running += 1
const token = prefetchToken.value
void prefetchMessages(directory, sessionID, token).finally(() => {
q.running -= 1
q.inflight.delete(sessionID)
pumpPrefetch(directory)
})
}
const prefetchSession = (session: Session, priority: "high" | "low" = "low") => {
const directory = session.directory
if (!directory) return
const [store] = globalSync.child(directory)
if (store.message[session.id] !== undefined) return
const q = queueFor(directory)
if (q.inflight.has(session.id)) return
if (q.pendingSet.has(session.id)) return
if (priority === "high") q.pending.unshift(session.id)
if (priority !== "high") q.pending.push(session.id)
q.pendingSet.add(session.id)
while (q.pending.length > prefetchPendingLimit) {
const dropped = q.pending.pop()
if (!dropped) continue
q.pendingSet.delete(dropped)
}
pumpPrefetch(directory)
}
createEffect(() => {
const sessions = currentSessions()
const id = params.id
if (!id) {
const first = sessions[0]
if (first) prefetchSession(first)
const second = sessions[1]
if (second) prefetchSession(second)
return
}
const index = sessions.findIndex((s) => s.id === id)
if (index === -1) return
const next = sessions[index + 1]
if (next) prefetchSession(next)
const prev = sessions[index - 1]
if (prev) prefetchSession(prev)
})
function navigateSessionByOffset(offset: number) { function navigateSessionByOffset(offset: number) {
const projects = layout.projects.list() const projects = layout.projects.list()
if (projects.length === 0) return if (projects.length === 0) return
@@ -309,6 +452,27 @@ export default function Layout(props: ParentProps) {
if (targetIndex >= 0 && targetIndex < sessions.length) { if (targetIndex >= 0 && targetIndex < sessions.length) {
const session = sessions[targetIndex] const session = sessions[targetIndex]
const next = sessions[targetIndex + 1]
const prev = sessions[targetIndex - 1]
if (offset > 0) {
if (next) prefetchSession(next, "high")
if (prev) prefetchSession(prev)
}
if (offset < 0) {
if (prev) prefetchSession(prev, "high")
if (next) prefetchSession(next)
}
if (import.meta.env.DEV) {
navStart({
dir: base64Encode(session.directory),
from: params.id,
to: session.id,
trigger: offset > 0 ? "alt+arrowdown" : "alt+arrowup",
})
}
navigateToSession(session) navigateToSession(session)
queueMicrotask(() => scrollToSession(session.id)) queueMicrotask(() => scrollToSession(session.id))
return return
@@ -324,7 +488,27 @@ export default function Layout(props: ParentProps) {
return return
} }
const targetSession = offset > 0 ? nextProjectSessions[0] : nextProjectSessions[nextProjectSessions.length - 1] const index = offset > 0 ? 0 : nextProjectSessions.length - 1
const targetSession = nextProjectSessions[index]
const nextSession = nextProjectSessions[index + 1]
const prevSession = nextProjectSessions[index - 1]
if (offset > 0) {
if (nextSession) prefetchSession(nextSession, "high")
}
if (offset < 0) {
if (prevSession) prefetchSession(prevSession, "high")
}
if (import.meta.env.DEV) {
navStart({
dir: base64Encode(targetSession.directory),
from: params.id,
to: targetSession.id,
trigger: offset > 0 ? "alt+arrowdown" : "alt+arrowup",
})
}
navigateToSession(targetSession) navigateToSession(targetSession)
queueMicrotask(() => scrollToSession(targetSession.id)) queueMicrotask(() => scrollToSession(targetSession.id))
} }
@@ -679,6 +863,8 @@ export default function Layout(props: ParentProps) {
<A <A
href={`${props.slug}/session/${props.session.id}`} href={`${props.slug}/session/${props.session.id}`}
class="flex flex-col min-w-0 text-left w-full focus:outline-none pl-4 pr-2 py-1" class="flex flex-col min-w-0 text-left w-full focus:outline-none pl-4 pr-2 py-1"
onMouseEnter={() => prefetchSession(props.session, "high")}
onFocus={() => prefetchSession(props.session, "high")}
> >
<div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7"> <div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7">
<span <span
@@ -875,7 +1061,7 @@ export default function Layout(props: ParentProps) {
</Collapsible> </Collapsible>
</Match> </Match>
<Match when={true}> <Match when={true}>
<Tooltip placement="right" value={props.project.worktree}> <Tooltip placement="right" value={getFilename(props.project.worktree)}>
<ProjectVisual project={props.project} /> <ProjectVisual project={props.project} />
</Tooltip> </Tooltip>
</Match> </Match>
+533 -203
View File
@@ -1,4 +1,4 @@
import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on } from "solid-js" import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media" import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer" import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
@@ -8,6 +8,7 @@ import { createStore } from "solid-js/store"
import { PromptInput } from "@/components/prompt-input" import { PromptInput } from "@/components/prompt-input"
import { SessionContextUsage } from "@/components/session-context-usage" import { SessionContextUsage } from "@/components/session-context-usage"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { DiffChanges } from "@opencode-ai/ui/diff-changes"
@@ -30,6 +31,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectFile } from "@/components/dialog-select-file" import { DialogSelectFile } from "@/components/dialog-select-file"
import { DialogSelectModel } from "@/components/dialog-select-model" import { DialogSelectModel } from "@/components/dialog-select-model"
import { DialogSelectMcp } from "@/components/dialog-select-mcp" import { DialogSelectMcp } from "@/components/dialog-select-mcp"
import { DialogFork } from "@/components/dialog-fork"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { useNavigate, useParams } from "@solidjs/router" import { useNavigate, useParams } from "@solidjs/router"
import { UserMessage } from "@opencode-ai/sdk/v2" import { UserMessage } from "@opencode-ai/sdk/v2"
@@ -49,10 +51,17 @@ import {
NewSessionView, NewSessionView,
} from "@/components/session" } from "@/components/session"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { navMark, navParams } from "@/utils/perf"
import { same } from "@/utils/same" import { same } from "@/utils/same"
type DiffStyle = "unified" | "split" type DiffStyle = "unified" | "split"
const handoff = {
prompt: "",
terminals: [] as string[],
files: {} as Record<string, SelectedLineRange | null>,
}
interface SessionReviewTabProps { interface SessionReviewTabProps {
diffs: () => FileDiff[] diffs: () => FileDiff[]
view: () => ReturnType<ReturnType<typeof useLayout>["view"]> view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
@@ -162,6 +171,46 @@ export default function Page() {
const tabs = createMemo(() => layout.tabs(sessionKey())) const tabs = createMemo(() => layout.tabs(sessionKey()))
const view = createMemo(() => layout.view(sessionKey())) const view = createMemo(() => layout.view(sessionKey()))
if (import.meta.env.DEV) {
createEffect(
on(
() => [params.dir, params.id] as const,
([dir, id], prev) => {
if (!id) return
navParams({ dir, from: prev?.[1], to: id })
},
),
)
createEffect(() => {
const id = params.id
if (!id) return
if (!prompt.ready()) return
navMark({ dir: params.dir, to: id, name: "storage:prompt-ready" })
})
createEffect(() => {
const id = params.id
if (!id) return
if (!terminal.ready()) return
navMark({ dir: params.dir, to: id, name: "storage:terminal-ready" })
})
createEffect(() => {
const id = params.id
if (!id) return
if (!file.ready()) return
navMark({ dir: params.dir, to: id, name: "storage:file-view-ready" })
})
createEffect(() => {
const id = params.id
if (!id) return
if (sync.data.message[id] === undefined) return
navMark({ dir: params.dir, to: id, name: "session:data-ready" })
})
}
const isDesktop = createMediaQuery("(min-width: 768px)") const isDesktop = createMediaQuery("(min-width: 768px)")
function normalizeTab(tab: string) { function normalizeTab(tab: string) {
@@ -216,6 +265,8 @@ export default function Page() {
}) })
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const reviewCount = createMemo(() => info()?.summary?.files ?? 0)
const hasReview = createMemo(() => reviewCount() > 0)
const revertMessageID = createMemo(() => info()?.revert?.messageID) const revertMessageID = createMemo(() => info()?.revert?.messageID)
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
const messagesReady = createMemo(() => { const messagesReady = createMemo(() => {
@@ -223,6 +274,16 @@ export default function Page() {
if (!id) return true if (!id) return true
return sync.data.message[id] !== undefined return sync.data.message[id] !== undefined
}) })
const historyMore = createMemo(() => {
const id = params.id
if (!id) return false
return sync.session.history.more(id)
})
const historyLoading = createMemo(() => {
const id = params.id
if (!id) return false
return sync.session.history.loading(id)
})
const emptyUserMessages: UserMessage[] = [] const emptyUserMessages: UserMessage[] = []
const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[], emptyUserMessages) const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[], emptyUserMessages)
const visibleUserMessages = createMemo(() => { const visibleUserMessages = createMemo(() => {
@@ -249,11 +310,20 @@ export default function Page() {
activeTerminalDraggable: undefined as string | undefined, activeTerminalDraggable: undefined as string | undefined,
expanded: {} as Record<string, boolean>, expanded: {} as Record<string, boolean>,
messageId: undefined as string | undefined, messageId: undefined as string | undefined,
turnStart: 0,
mobileTab: "session" as "session" | "review", mobileTab: "session" as "session" | "review",
newSessionWorktree: "main", newSessionWorktree: "main",
promptHeight: 0, promptHeight: 0,
}) })
const renderedUserMessages = createMemo(() => {
const msgs = visibleUserMessages()
const start = store.turnStart
if (start <= 0) return msgs
if (start >= msgs.length) return emptyUserMessages
return msgs.slice(start)
}, emptyUserMessages)
const newSessionWorktree = createMemo(() => { const newSessionWorktree = createMemo(() => {
if (store.newSessionWorktree === "create") return "create" if (store.newSessionWorktree === "create") return "create"
const project = sync.project const project = sync.project
@@ -290,6 +360,12 @@ export default function Page() {
} }
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
const diffsReady = createMemo(() => {
const id = params.id
if (!id) return true
if (!hasReview()) return true
return sync.data.session_diff[id] !== undefined
})
const idle = { type: "idle" as const } const idle = { type: "idle" as const }
let inputRef!: HTMLDivElement let inputRef!: HTMLDivElement
@@ -302,11 +378,10 @@ export default function Page() {
}) })
createEffect(() => { createEffect(() => {
if (layout.terminal.opened()) { if (!view().terminal.opened()) return
if (terminal.all().length === 0) { if (!terminal.ready()) return
terminal.new() if (terminal.all().length !== 0) return
} terminal.new()
}
}) })
createEffect( createEffect(
@@ -366,7 +441,7 @@ export default function Page() {
category: "View", category: "View",
keybind: "ctrl+`", keybind: "ctrl+`",
slash: "terminal", slash: "terminal",
onSelect: () => layout.terminal.toggle(), onSelect: () => view().terminal.toggle(),
}, },
{ {
id: "review.toggle", id: "review.toggle",
@@ -374,7 +449,7 @@ export default function Page() {
description: "Show or hide the review panel", description: "Show or hide the review panel",
category: "View", category: "View",
keybind: "mod+shift+r", keybind: "mod+shift+r",
onSelect: () => layout.review.toggle(), onSelect: () => view().reviewPanel.toggle(),
}, },
{ {
id: "terminal.new", id: "terminal.new",
@@ -571,6 +646,15 @@ export default function Page() {
}) })
}, },
}, },
{
id: "session.fork",
title: "Fork from message",
description: "Create a new session from a previous message",
category: "Session",
slash: "fork",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: () => dialog.show(() => <DialogFork />),
},
]) ])
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
@@ -643,11 +727,11 @@ export default function Page() {
.filter((tab) => tab !== "context"), .filter((tab) => tab !== "context"),
) )
const reviewTab = createMemo(() => diffs().length > 0 || tabs().active() === "review") const reviewTab = createMemo(() => hasReview() || tabs().active() === "review")
const mobileReview = createMemo(() => !isDesktop() && diffs().length > 0 && store.mobileTab === "review") const mobileReview = createMemo(() => !isDesktop() && hasReview() && store.mobileTab === "review")
const showTabs = createMemo( const showTabs = createMemo(
() => layout.review.opened() && (diffs().length > 0 || tabs().all().length > 0 || contextOpen()), () => view().reviewPanel.opened() && (hasReview() || tabs().all().length > 0 || contextOpen()),
) )
const activeTab = createMemo(() => { const activeTab = createMemo(() => {
@@ -664,10 +748,22 @@ export default function Page() {
createEffect(() => { createEffect(() => {
if (!layout.ready()) return if (!layout.ready()) return
if (tabs().active()) return if (tabs().active()) return
if (diffs().length === 0 && openedTabs().length === 0 && !contextOpen()) return if (!hasReview() && openedTabs().length === 0 && !contextOpen()) return
tabs().setActive(activeTab()) tabs().setActive(activeTab())
}) })
createEffect(() => {
const id = params.id
if (!id) return
if (!hasReview()) return
const wants = isDesktop() ? view().reviewPanel.opened() && activeTab() === "review" : store.mobileTab === "review"
if (!wants) return
if (diffsReady()) return
sync.session.diff(id)
})
const isWorking = createMemo(() => status().type !== "idle") const isWorking = createMemo(() => status().type !== "idle")
const autoScroll = createAutoScroll({ const autoScroll = createAutoScroll({
working: isWorking, working: isWorking,
@@ -683,6 +779,88 @@ export default function Page() {
autoScroll.scrollRef(el) autoScroll.scrollRef(el)
} }
const turnInit = 20
const turnBatch = 20
let turnHandle: number | undefined
let turnIdle = false
function cancelTurnBackfill() {
const handle = turnHandle
if (handle === undefined) return
turnHandle = undefined
if (turnIdle && window.cancelIdleCallback) {
window.cancelIdleCallback(handle)
return
}
clearTimeout(handle)
}
function scheduleTurnBackfill() {
if (turnHandle !== undefined) return
if (store.turnStart <= 0) return
if (window.requestIdleCallback) {
turnIdle = true
turnHandle = window.requestIdleCallback(() => {
turnHandle = undefined
backfillTurns()
})
return
}
turnIdle = false
turnHandle = window.setTimeout(() => {
turnHandle = undefined
backfillTurns()
}, 0)
}
function backfillTurns() {
const start = store.turnStart
if (start <= 0) return
const next = start - turnBatch
const nextStart = next > 0 ? next : 0
const el = scroller
if (!el) {
setStore("turnStart", nextStart)
scheduleTurnBackfill()
return
}
const beforeTop = el.scrollTop
const beforeHeight = el.scrollHeight
setStore("turnStart", nextStart)
requestAnimationFrame(() => {
const delta = el.scrollHeight - beforeHeight
if (delta) el.scrollTop = beforeTop + delta
})
scheduleTurnBackfill()
}
createEffect(
on(
() => [params.id, messagesReady()] as const,
([id, ready]) => {
cancelTurnBackfill()
setStore("turnStart", 0)
if (!id || !ready) return
const len = visibleUserMessages().length
const start = len > turnInit ? len - turnInit : 0
setStore("turnStart", start)
scheduleTurnBackfill()
},
{ defer: true },
),
)
createResizeObserver( createResizeObserver(
() => promptDock, () => promptDock,
({ height }) => { ({ height }) => {
@@ -710,6 +888,21 @@ export default function Page() {
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => { const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
setActiveMessage(message) setActiveMessage(message)
const msgs = visibleUserMessages()
const index = msgs.findIndex((m) => m.id === message.id)
if (index !== -1 && index < store.turnStart) {
setStore("turnStart", index)
scheduleTurnBackfill()
requestAnimationFrame(() => {
const el = document.getElementById(anchor(message.id))
if (el) el.scrollIntoView({ behavior, block: "start" })
})
updateHash(message.id)
return
}
const el = document.getElementById(anchor(message.id)) const el = document.getElementById(anchor(message.id))
if (el) el.scrollIntoView({ behavior, block: "start" }) if (el) el.scrollIntoView({ behavior, block: "start" })
updateHash(message.id) updateHash(message.id)
@@ -755,12 +948,27 @@ export default function Page() {
if (!sessionID || !ready) return if (!sessionID || !ready) return
requestAnimationFrame(() => { requestAnimationFrame(() => {
const id = window.location.hash.slice(1) const hash = window.location.hash.slice(1)
const hashTarget = id ? document.getElementById(id) : undefined if (!hash) {
autoScroll.forceScrollToBottom()
return
}
const hashTarget = document.getElementById(hash)
if (hashTarget) { if (hashTarget) {
hashTarget.scrollIntoView({ behavior: "auto", block: "start" }) hashTarget.scrollIntoView({ behavior: "auto", block: "start" })
return return
} }
const match = hash.match(/^message-(.+)$/)
if (match) {
const msg = visibleUserMessages().find((m) => m.id === match[1])
if (msg) {
scrollToMessage(msg, "auto")
return
}
}
autoScroll.forceScrollToBottom() autoScroll.forceScrollToBottom()
}) })
}) })
@@ -769,7 +977,43 @@ export default function Page() {
document.addEventListener("keydown", handleKeyDown) document.addEventListener("keydown", handleKeyDown)
}) })
const previewPrompt = () =>
prompt
.current()
.map((part) => {
if (part.type === "file") return `[file:${part.path}]`
if (part.type === "agent") return `@${part.name}`
if (part.type === "image") return `[image:${part.filename}]`
return part.content
})
.join("")
.trim()
createEffect(() => {
if (!prompt.ready()) return
handoff.prompt = previewPrompt()
})
createEffect(() => {
if (!terminal.ready()) return
handoff.terminals = terminal.all().map((t) => t.title)
})
createEffect(() => {
if (!file.ready()) return
handoff.files = Object.fromEntries(
tabs()
.all()
.flatMap((tab) => {
const path = file.pathFromTab(tab)
if (!path) return []
return [[path, file.selectedLines(path) ?? null] as const]
}),
)
})
onCleanup(() => { onCleanup(() => {
cancelTurnBackfill()
document.removeEventListener("keydown", handleKeyDown) document.removeEventListener("keydown", handleKeyDown)
if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame) if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
}) })
@@ -779,7 +1023,7 @@ export default function Page() {
<SessionHeader /> <SessionHeader />
<div class="flex-1 min-h-0 flex flex-col md:flex-row"> <div class="flex-1 min-h-0 flex flex-col md:flex-row">
{/* Mobile tab bar - only shown on mobile when there are diffs */} {/* Mobile tab bar - only shown on mobile when there are diffs */}
<Show when={!isDesktop() && diffs().length > 0}> <Show when={!isDesktop() && hasReview()}>
<Tabs class="h-auto"> <Tabs class="h-auto">
<Tabs.List> <Tabs.List>
<Tabs.Trigger <Tabs.Trigger
@@ -796,7 +1040,7 @@ export default function Page() {
classes={{ button: "w-full" }} classes={{ button: "w-full" }}
onClick={() => setStore("mobileTab", "review")} onClick={() => setStore("mobileTab", "review")}
> >
{diffs().length} Files Changed {reviewCount()} Files Changed
</Tabs.Trigger> </Tabs.Trigger>
</Tabs.List> </Tabs.List>
</Tabs> </Tabs>
@@ -821,21 +1065,26 @@ export default function Page() {
when={!mobileReview()} when={!mobileReview()}
fallback={ fallback={
<div class="relative h-full overflow-hidden"> <div class="relative h-full overflow-hidden">
<SessionReviewTab <Show
diffs={diffs} when={diffsReady()}
view={view} fallback={<div class="px-4 py-4 text-text-weak">Loading changes...</div>}
diffStyle="unified" >
onViewFile={(path) => { <SessionReviewTab
const value = file.tab(path) diffs={diffs}
tabs().open(value) view={view}
file.load(path) diffStyle="unified"
}} onViewFile={(path) => {
classes={{ const value = file.tab(path)
root: "pb-[calc(var(--prompt-height,8rem)+32px)]", tabs().open(value)
header: "px-4", file.load(path)
container: "px-4", }}
}} classes={{
/> root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
header: "px-4",
container: "px-4",
}}
/>
</Show>
</div> </div>
} }
> >
@@ -868,42 +1117,82 @@ export default function Page() {
"mt-0": showTabs(), "mt-0": showTabs(),
}} }}
> >
<For each={visibleUserMessages()}> <Show when={store.turnStart > 0}>
{(message) => ( <div class="w-full flex justify-center">
<div <Button
id={anchor(message.id)} variant="ghost"
data-message-id={message.id} size="large"
classList={{ class="text-12-medium opacity-50"
"min-w-0 w-full max-w-full": true, onClick={() => setStore("turnStart", 0)}
"last:min-h-[calc(100vh-5.5rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-4.5rem-var(--prompt-height,10rem)-64px)]": >
platform.platform !== "desktop", Render earlier messages
"last:min-h-[calc(100vh-7rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-6rem-var(--prompt-height,10rem)-64px)]": </Button>
platform.platform === "desktop", </div>
</Show>
<Show when={historyMore()}>
<div class="w-full flex justify-center">
<Button
variant="ghost"
size="large"
class="text-12-medium opacity-50"
disabled={historyLoading()}
onClick={() => {
const id = params.id
if (!id) return
setStore("turnStart", 0)
sync.session.history.loadMore(id)
}} }}
> >
<SessionTurn {historyLoading() ? "Loading earlier messages..." : "Load earlier messages"}
sessionID={params.id!} </Button>
messageID={message.id} </div>
lastUserMessageID={lastUserMessage()?.id} </Show>
stepsExpanded={store.expanded[message.id] ?? false} <For each={renderedUserMessages()}>
onStepsExpandedToggle={() => {(message) => {
setStore("expanded", message.id, (open: boolean | undefined) => !open) if (import.meta.env.DEV) {
} onMount(() => {
classes={{ const id = params.id
root: "min-w-0 w-full relative", if (!id) return
content: navMark({ dir: params.dir, to: id, name: "session:first-turn-mounted" })
"flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]", })
container: }
"px-4 md:px-6 " +
(!showTabs() return (
? "md:max-w-200 md:mx-auto" <div
: visibleUserMessages().length > 1 id={anchor(message.id)}
? "md:pr-6 md:pl-18" data-message-id={message.id}
: ""), classList={{
"min-w-0 w-full max-w-full": true,
"last:min-h-[calc(100vh-5.5rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-4.5rem-var(--prompt-height,10rem)-64px)]":
platform.platform !== "desktop",
"last:min-h-[calc(100vh-7rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-6rem-var(--prompt-height,10rem)-64px)]":
platform.platform === "desktop",
}} }}
/> >
</div> <SessionTurn
)} sessionID={params.id!}
messageID={message.id}
lastUserMessageID={lastUserMessage()?.id}
stepsExpanded={store.expanded[message.id] ?? false}
onStepsExpandedToggle={() =>
setStore("expanded", message.id, (open: boolean | undefined) => !open)
}
classes={{
root: "min-w-0 w-full relative",
content:
"flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]",
container:
"px-4 md:px-6 " +
(!showTabs()
? "md:max-w-200 md:mx-auto"
: visibleUserMessages().length > 1
? "md:pr-6 md:pl-18"
: ""),
}}
/>
</div>
)
}}
</For> </For>
</div> </div>
</div> </div>
@@ -944,13 +1233,22 @@ export default function Page() {
"md:max-w-200": !showTabs(), "md:max-w-200": !showTabs(),
}} }}
> >
<PromptInput <Show
ref={(el) => { when={prompt.ready()}
inputRef = el fallback={
}} <div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
newSessionWorktree={newSessionWorktree()} {handoff.prompt || "Loading prompt..."}
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")} </div>
/> }
>
<PromptInput
ref={(el) => {
inputRef = el
}}
newSessionWorktree={newSessionWorktree()}
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
/>
</Show>
</div> </div>
</div> </div>
@@ -1034,31 +1332,40 @@ export default function Page() {
</div> </div>
<Show when={reviewTab()}> <Show when={reviewTab()}>
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict"> <Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden"> <Show when={activeTab() === "review"}>
<SessionReviewTab <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
diffs={diffs} <Show
view={view} when={diffsReady()}
diffStyle={layout.review.diffStyle()} fallback={<div class="px-6 py-4 text-text-weak">Loading changes...</div>}
onDiffStyleChange={layout.review.setDiffStyle} >
onViewFile={(path) => { <SessionReviewTab
const value = file.tab(path) diffs={diffs}
tabs().open(value) view={view}
file.load(path) diffStyle={layout.review.diffStyle()}
}} onDiffStyleChange={layout.review.setDiffStyle}
/> onViewFile={(path) => {
</div> const value = file.tab(path)
tabs().open(value)
file.load(path)
}}
/>
</Show>
</div>
</Show>
</Tabs.Content> </Tabs.Content>
</Show> </Show>
<Show when={contextOpen()}> <Show when={contextOpen()}>
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict"> <Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden"> <Show when={activeTab() === "context"}>
<SessionContextTab <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
messages={messages} <SessionContextTab
visibleUserMessages={visibleUserMessages} messages={messages}
view={view} visibleUserMessages={visibleUserMessages}
info={info} view={view}
/> info={info}
</div> />
</div>
</Show>
</Tabs.Content> </Tabs.Content>
</Show> </Show>
<For each={openedTabs()}> <For each={openedTabs()}>
@@ -1107,7 +1414,8 @@ export default function Page() {
const selectedLines = createMemo(() => { const selectedLines = createMemo(() => {
const p = path() const p = path()
if (!p) return null if (!p) return null
return file.selectedLines(p) ?? null if (file.ready()) return file.selectedLines(p) ?? null
return handoff.files[p] ?? null
}) })
const selection = createMemo(() => { const selection = createMemo(() => {
const range = selectedLines() const range = selectedLines()
@@ -1204,37 +1512,63 @@ export default function Page() {
}} }}
onScroll={handleScroll} onScroll={handleScroll}
> >
<Show when={selection()}> <Show when={activeTab() === tab}>
{(sel) => ( <Show when={selection()}>
<div class="hidden sticky top-0 z-10 px-6 py-2 _flex justify-end bg-background-base border-b border-border-weak-base"> {(sel) => (
<button <div class="hidden sticky top-0 z-10 px-6 py-2 _flex justify-end bg-background-base border-b border-border-weak-base">
type="button" <button
class="flex items-center gap-2 px-2 py-1 rounded-md bg-surface-base border border-border-base text-12-regular text-text-strong hover:bg-surface-raised-base-hover" type="button"
onClick={() => { class="flex items-center gap-2 px-2 py-1 rounded-md bg-surface-base border border-border-base text-12-regular text-text-strong hover:bg-surface-raised-base-hover"
const p = path() onClick={() => {
if (!p) return const p = path()
prompt.context.add({ type: "file", path: p, selection: sel() }) if (!p) return
}} prompt.context.add({ type: "file", path: p, selection: sel() })
> }}
<Icon name="plus-small" size="small" /> >
<span>Add {selectionLabel()} to context</span> <Icon name="plus-small" size="small" />
</button> <span>Add {selectionLabel()} to context</span>
</div> </button>
)} </div>
</Show> )}
<Switch> </Show>
<Match when={state()?.loaded && isImage()}> <Switch>
<div class="px-6 py-4 pb-40"> <Match when={state()?.loaded && isImage()}>
<img src={imageDataUrl()} alt={path()} class="max-w-full" /> <div class="px-6 py-4 pb-40">
</div> <img src={imageDataUrl()} alt={path()} class="max-w-full" />
</Match> </div>
<Match when={state()?.loaded && isSvg()}> </Match>
<div class="flex flex-col gap-4 px-6 py-4"> <Match when={state()?.loaded && isSvg()}>
<div class="flex flex-col gap-4 px-6 py-4">
<Dynamic
component={codeComponent}
file={{
name: path() ?? "",
contents: svgContent() ?? "",
cacheKey: cacheKey(),
}}
enableLineSelection
selectedLines={selectedLines()}
onLineSelected={(range: SelectedLineRange | null) => {
const p = path()
if (!p) return
file.setSelectedLines(p, range)
}}
overflow="scroll"
class="select-text"
/>
<Show when={svgPreviewUrl()}>
<div class="flex justify-center pb-40">
<img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
</div>
</Show>
</div>
</Match>
<Match when={state()?.loaded}>
<Dynamic <Dynamic
component={codeComponent} component={codeComponent}
file={{ file={{
name: path() ?? "", name: path() ?? "",
contents: svgContent() ?? "", contents: contents(),
cacheKey: cacheKey(), cacheKey: cacheKey(),
}} }}
enableLineSelection enableLineSelection
@@ -1245,41 +1579,17 @@ export default function Page() {
file.setSelectedLines(p, range) file.setSelectedLines(p, range)
}} }}
overflow="scroll" overflow="scroll"
class="select-text" class="select-text pb-40"
/> />
<Show when={svgPreviewUrl()}> </Match>
<div class="flex justify-center pb-40"> <Match when={state()?.loading}>
<img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" /> <div class="px-6 py-4 text-text-weak">Loading...</div>
</div> </Match>
</Show> <Match when={state()?.error}>
</div> {(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}
</Match> </Match>
<Match when={state()?.loaded}> </Switch>
<Dynamic </Show>
component={codeComponent}
file={{
name: path() ?? "",
contents: contents(),
cacheKey: cacheKey(),
}}
enableLineSelection
selectedLines={selectedLines()}
onLineSelected={(range: SelectedLineRange | null) => {
const p = path()
if (!p) return
file.setSelectedLines(p, range)
}}
overflow="scroll"
class="select-text pb-40"
/>
</Match>
<Match when={state()?.loading}>
<div class="px-6 py-4 text-text-weak">Loading...</div>
</Match>
<Match when={state()?.error}>
{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}
</Match>
</Switch>
</Tabs.Content> </Tabs.Content>
) )
}} }}
@@ -1302,7 +1612,7 @@ export default function Page() {
</Show> </Show>
</div> </div>
<Show when={isDesktop() && layout.terminal.opened()}> <Show when={isDesktop() && view().terminal.opened()}>
<div <div
class="relative w-full flex-col shrink-0 border-t border-border-weak-base" class="relative w-full flex-col shrink-0 border-t border-border-weak-base"
style={{ height: `${layout.terminal.height()}px` }} style={{ height: `${layout.terminal.height()}px` }}
@@ -1314,56 +1624,76 @@ export default function Page() {
max={window.innerHeight * 0.6} max={window.innerHeight * 0.6}
collapseThreshold={50} collapseThreshold={50}
onResize={layout.terminal.resize} onResize={layout.terminal.resize}
onCollapse={layout.terminal.close} onCollapse={view().terminal.close}
/> />
<DragDropProvider <Show
onDragStart={handleTerminalDragStart} when={terminal.ready()}
onDragEnd={handleTerminalDragEnd} fallback={
onDragOver={handleTerminalDragOver} <div class="flex flex-col h-full pointer-events-none">
collisionDetector={closestCenter} <div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden">
> <For each={handoff.terminals}>
<DragDropSensors /> {(title) => (
<ConstrainDragYAxis /> <div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
<Tabs variant="alt" value={terminal.active()} onChange={terminal.open}> {title}
<Tabs.List class="h-10"> </div>
<SortableProvider ids={terminal.all().map((t: LocalPTY) => t.id)}> )}
<For each={terminal.all()}>{(pty) => <SortableTerminalTab terminal={pty} />}</For> </For>
</SortableProvider> <div class="flex-1" />
<div class="h-full flex items-center justify-center"> <div class="text-text-weak pr-2">Loading...</div>
<TooltipKeybind
title="New terminal"
keybind={command.keybind("terminal.new")}
class="flex items-center"
>
<IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={terminal.new} />
</TooltipKeybind>
</div> </div>
</Tabs.List> <div class="flex-1 flex items-center justify-center text-text-weak">Loading terminal...</div>
<For each={terminal.all()}> </div>
{(pty) => ( }
<Tabs.Content value={pty.id}> >
<Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} /> <DragDropProvider
</Tabs.Content> onDragStart={handleTerminalDragStart}
)} onDragEnd={handleTerminalDragEnd}
</For> onDragOver={handleTerminalDragOver}
</Tabs> collisionDetector={closestCenter}
<DragOverlay> >
<Show when={store.activeTerminalDraggable}> <DragDropSensors />
{(draggedId) => { <ConstrainDragYAxis />
const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId())) <Tabs variant="alt" value={terminal.active()} onChange={terminal.open}>
return ( <Tabs.List class="h-10">
<Show when={pty()}> <SortableProvider ids={terminal.all().map((t: LocalPTY) => t.id)}>
{(t) => ( <For each={terminal.all()}>{(pty) => <SortableTerminalTab terminal={pty} />}</For>
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular"> </SortableProvider>
{t().title} <div class="h-full flex items-center justify-center">
</div> <TooltipKeybind
)} title="New terminal"
</Show> keybind={command.keybind("terminal.new")}
) class="flex items-center"
}} >
</Show> <IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={terminal.new} />
</DragOverlay> </TooltipKeybind>
</DragDropProvider> </div>
</Tabs.List>
<For each={terminal.all()}>
{(pty) => (
<Tabs.Content value={pty.id}>
<Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} />
</Tabs.Content>
)}
</For>
</Tabs>
<DragOverlay>
<Show when={store.activeTerminalDraggable}>
{(draggedId) => {
const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId()))
return (
<Show when={pty()}>
{(t) => (
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
{t().title}
</div>
)}
</Show>
)
}}
</Show>
</DragOverlay>
</DragDropProvider>
</Show>
</div> </div>
</Show> </Show>
</div> </div>
+135
View File
@@ -0,0 +1,135 @@
type Nav = {
id: string
dir?: string
from?: string
to: string
trigger?: string
start: number
marks: Record<string, number>
logged: boolean
timer?: ReturnType<typeof setTimeout>
}
const dev = import.meta.env.DEV
const key = (dir: string | undefined, to: string) => `${dir ?? ""}:${to}`
const now = () => performance.now()
const uid = () => crypto.randomUUID?.() ?? Math.random().toString(16).slice(2)
const navs = new Map<string, Nav>()
const pending = new Map<string, string>()
const active = new Map<string, string>()
const required = [
"session:params",
"session:data-ready",
"session:first-turn-mounted",
"storage:prompt-ready",
"storage:terminal-ready",
"storage:file-view-ready",
]
function flush(id: string, reason: "complete" | "timeout") {
if (!dev) return
const nav = navs.get(id)
if (!nav) return
if (nav.logged) return
nav.logged = true
if (nav.timer) clearTimeout(nav.timer)
const baseName = nav.marks["navigate:start"] !== undefined ? "navigate:start" : "session:params"
const base = nav.marks[baseName] ?? nav.start
const ms = Object.fromEntries(
Object.entries(nav.marks)
.slice()
.sort(([a], [b]) => a.localeCompare(b))
.map(([name, t]) => [name, Math.round((t - base) * 100) / 100]),
)
console.log(
"perf.session-nav " +
JSON.stringify({
type: "perf.session-nav.v0",
id: nav.id,
dir: nav.dir,
from: nav.from,
to: nav.to,
trigger: nav.trigger,
base: baseName,
reason,
ms,
}),
)
navs.delete(id)
}
function maybeFlush(id: string) {
if (!dev) return
const nav = navs.get(id)
if (!nav) return
if (nav.logged) return
if (!required.every((name) => nav.marks[name] !== undefined)) return
flush(id, "complete")
}
function ensure(id: string, data: Omit<Nav, "marks" | "logged" | "timer">) {
const existing = navs.get(id)
if (existing) return existing
const nav: Nav = {
...data,
marks: {},
logged: false,
}
nav.timer = setTimeout(() => flush(id, "timeout"), 5000)
navs.set(id, nav)
return nav
}
export function navStart(input: { dir?: string; from?: string; to: string; trigger?: string }) {
if (!dev) return
const id = uid()
const start = now()
const nav = ensure(id, { ...input, id, start })
nav.marks["navigate:start"] = start
pending.set(key(input.dir, input.to), id)
return id
}
export function navParams(input: { dir?: string; from?: string; to: string }) {
if (!dev) return
const k = key(input.dir, input.to)
const pendingId = pending.get(k)
if (pendingId) pending.delete(k)
const id = pendingId ?? uid()
const start = now()
const nav = ensure(id, { ...input, id, start, trigger: pendingId ? "key" : "route" })
nav.marks["session:params"] = start
active.set(k, id)
maybeFlush(id)
return id
}
export function navMark(input: { dir?: string; to: string; name: string }) {
if (!dev) return
const id = active.get(key(input.dir, input.to))
if (!id) return
const nav = navs.get(id)
if (!nav) return
if (nav.marks[input.name] !== undefined) return
nav.marks[input.name] = now()
maybeFlush(id)
}
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.1.7", "version": "1.1.14",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
@@ -26,6 +26,7 @@
"chart.js": "4.5.1", "chart.js": "4.5.1",
"nitro": "3.0.1-alpha.1", "nitro": "3.0.1-alpha.1",
"solid-js": "catalog:", "solid-js": "catalog:",
"solid-list": "0.3.0",
"vite": "catalog:", "vite": "catalog:",
"zod": "catalog:" "zod": "catalog:"
}, },
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+2 -2
View File
@@ -9,8 +9,8 @@ export const config = {
github: { github: {
repoUrl: "https://github.com/anomalyco/opencode", repoUrl: "https://github.com/anomalyco/opencode",
starsFormatted: { starsFormatted: {
compact: "50K", compact: "60K",
full: "50,000", full: "60,000",
}, },
}, },
@@ -0,0 +1,221 @@
[data-page="black"] {
background: #000;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: stretch;
font-family: var(--font-mono);
color: #fff;
[data-component="header-gradient"] {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 288px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.16) 0%, rgba(0, 0, 0, 0) 100%);
}
[data-component="header"] {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 40px;
flex-shrink: 0;
/* [data-component="header-logo"] { */
/* } */
}
[data-component="content"] {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
flex-grow: 1;
[data-slot="hero-black"] {
margin-top: 110px;
@media (min-width: 768px) {
margin-top: 150px;
}
}
[data-slot="cta"] {
display: flex;
flex-direction: column;
gap: 32px;
align-items: center;
text-align: center;
margin-top: -18px;
@media (min-width: 768px) {
margin-top: 40px;
}
[data-slot="heading"] {
color: rgba(255, 255, 255, 0.92);
text-align: center;
font-size: 18px;
font-style: normal;
font-weight: 400;
line-height: 160%; /* 28.8px */
span {
display: inline-block;
}
}
[data-slot="subheading"] {
color: rgba(255, 255, 255, 0.59);
font-size: 15px;
font-style: normal;
font-weight: 400;
line-height: 160%;
@media (min-width: 768px) {
font-size: 18px;
line-height: 160%;
}
}
[data-slot="button"] {
display: inline-flex;
height: 40px;
padding: 0 12px;
justify-content: center;
align-items: center;
gap: 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.92);
text-decoration: none;
color: #000;
font-family: "JetBrains Mono Nerd Font";
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: normal;
&:hover {
background: #e0e0e0;
}
&:active {
transform: scale(0.98);
}
}
[data-slot="back-soon"] {
color: rgba(255, 255, 255, 0.59);
text-align: center;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 160%; /* 20.8px */
}
[data-slot="follow-us"] {
display: inline-flex;
height: 40px;
padding: 0 12px;
justify-content: center;
align-items: center;
gap: 8px;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.17);
color: rgba(255, 255, 255, 0.59);
font-family: "JetBrains Mono Nerd Font";
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: normal;
text-decoration: none;
}
[data-slot="fine-print"] {
color: rgba(255, 255, 255, 0.39);
text-align: center;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 160%; /* 20.8px */
}
}
}
[data-component="footer"] {
display: flex;
flex-direction: column;
width: 100%;
justify-content: center;
align-items: center;
gap: 24px;
flex-shrink: 0;
@media (min-width: 768px) {
height: 120px;
}
[data-slot="footer-content"] {
display: flex;
gap: 24px;
align-items: center;
justify-content: center;
@media (min-width: 768px) {
gap: 40px;
}
span,
a {
color: rgba(255, 255, 255, 0.39);
font-family: "JetBrains Mono Nerd Font";
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: normal;
text-decoration: none;
}
[data-slot="github-stars"] {
color: rgba(255, 255, 255, 0.25);
font-family: "JetBrains Mono Nerd Font";
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: normal;
}
[data-slot="anomaly"] {
display: none;
@media (min-width: 768px) {
display: block;
}
}
}
[data-slot="anomaly-alt"] {
color: rgba(255, 255, 255, 0.39);
font-family: "JetBrains Mono Nerd Font";
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: normal;
text-decoration: none;
margin-bottom: 24px;
a {
color: rgba(255, 255, 255, 0.39);
font-family: "JetBrains Mono Nerd Font";
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: normal;
text-decoration: none;
}
@media (min-width: 768px) {
display: none;
}
}
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,214 @@
[data-page="black"] {
background: #000;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: stretch;
font-family: var(--font-mono);
color: #fff;
[data-component="header-gradient"] {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 288px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.16) 0%, rgba(0, 0, 0, 0) 100%);
}
[data-component="header"] {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 40px;
flex-shrink: 0;
/* [data-component="header-logo"] { */
/* } */
}
[data-component="content"] {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
flex-grow: 1;
[data-slot="hero-black"] {
margin-top: 110px;
@media (min-width: 768px) {
margin-top: 150px;
}
}
[data-slot="select-workspace"] {
display: flex;
margin-top: -24px;
width: 100%;
max-width: 480px;
height: 305px;
padding: 32px 20px 0 20px;
flex-direction: column;
align-items: flex-start;
gap: 24px;
border: 1px solid #303030;
background: #0a0a0a;
box-shadow:
0 100px 80px 0 rgba(0, 0, 0, 0.04),
0 41.778px 33.422px 0 rgba(0, 0, 0, 0.05),
0 22.336px 17.869px 0 rgba(0, 0, 0, 0.06),
0 12.522px 10.017px 0 rgba(0, 0, 0, 0.08),
0 6.65px 5.32px 0 rgba(0, 0, 0, 0.09),
0 2.767px 2.214px 0 rgba(0, 0, 0, 0.13);
[data-slot="select-workspace-title"] {
flex-shrink: 0;
align-self: stretch;
color: rgba(255, 255, 255, 0.59);
text-align: center;
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: 160%; /* 25.6px */
}
[data-slot="workspaces"] {
width: 100%;
padding: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
align-self: stretch;
outline: none;
overflow-y: auto;
flex: 1;
min-height: 0;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
[data-slot="workspace"] {
width: 100%;
display: flex;
padding: 8px 12px;
align-items: center;
gap: 8px;
align-self: stretch;
cursor: pointer;
[data-slot="selected-icon"] {
visibility: hidden;
color: rgba(255, 255, 255, 0.39);
font-family: "IBM Plex Mono";
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: 160%; /* 25.6px */
}
a {
color: rgba(255, 255, 255, 0.92);
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: 160%; /* 25.6px */
text-decoration: none;
}
}
[data-slot="workspace"]:hover,
[data-slot="workspace"][data-active="true"] {
background: #161616;
[data-slot="selected-icon"] {
visibility: visible;
}
}
}
}
}
[data-component="footer"] {
display: flex;
flex-direction: column;
width: 100%;
justify-content: center;
align-items: center;
gap: 24px;
flex-shrink: 0;
@media (min-width: 768px) {
height: 120px;
}
[data-slot="footer-content"] {
display: flex;
gap: 24px;
align-items: center;
justify-content: center;
@media (min-width: 768px) {
gap: 40px;
}
span,
a {
color: rgba(255, 255, 255, 0.39);
font-family: "JetBrains Mono Nerd Font";
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: normal;
text-decoration: none;
}
[data-slot="github-stars"] {
color: rgba(255, 255, 255, 0.25);
font-family: "JetBrains Mono Nerd Font";
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: normal;
}
[data-slot="anomaly"] {
display: none;
@media (min-width: 768px) {
display: block;
}
}
}
[data-slot="anomaly-alt"] {
color: rgba(255, 255, 255, 0.39);
font-family: "JetBrains Mono Nerd Font";
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: normal;
text-decoration: none;
margin-bottom: 24px;
a {
color: rgba(255, 255, 255, 0.39);
font-family: "JetBrains Mono Nerd Font";
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: normal;
text-decoration: none;
}
@media (min-width: 768px) {
display: none;
}
}
}
}
File diff suppressed because one or more lines are too long
@@ -129,9 +129,9 @@ export default function Download() {
</code> </code>
<CopyStatus /> <CopyStatus />
</button> </button>
<button data-component="cli-row" onClick={handleCopyClick("brew install opencode")}> <button data-component="cli-row" onClick={handleCopyClick("brew install anomalyco/tap/opencode")}>
<code> <code>
brew install <strong>opencode</strong> brew install <strong>anomalyco/tap/opencode</strong>
</code> </code>
<CopyStatus /> <CopyStatus />
</button> </button>
@@ -244,7 +244,8 @@ export default function Download() {
Download Download
</a> </a>
</div> </div>
<div data-component="download-row"> {/* Disabled temporarily as it doesn't work */}
{/*<div data-component="download-row">
<div data-component="download-info"> <div data-component="download-info">
<span data-slot="icon"> <span data-slot="icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -259,7 +260,7 @@ export default function Download() {
<a href={getDownloadHref("linux-x64-appimage")} data-component="action-button"> <a href={getDownloadHref("linux-x64-appimage")} data-component="action-button">
Download Download
</a> </a>
</div> </div>*/}
</div> </div>
</section> </section>
+1 -1
View File
@@ -522,7 +522,7 @@ body {
[data-slot="content"] { [data-slot="content"] {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 1ch;
} }
[data-slot="text"] { [data-slot="text"] {
+7 -1
View File
@@ -140,7 +140,7 @@ export default function Home() {
<button data-copy data-slot="command" onClick={handleCopyClick}> <button data-copy data-slot="command" onClick={handleCopyClick}>
<span> <span>
<span data-slot="protocol">brew install </span> <span data-slot="protocol">brew install </span>
<span data-slot="highlight">opencode</span> <span data-slot="highlight">anomalyco/tap/opencode</span>
</span> </span>
<CopyStatus /> <CopyStatus />
</button> </button>
@@ -195,6 +195,12 @@ export default function Home() {
<strong>Claude Pro</strong> Log in with Anthropic to use your Claude Pro or Max account <strong>Claude Pro</strong> Log in with Anthropic to use your Claude Pro or Max account
</div> </div>
</li> </li>
<li>
<span>[*]</span>
<div>
<strong>ChatGPT Plus/Pro</strong> Log in with OpenAI to use your ChatGPT Plus or Pro account
</div>
</li>
<li> <li>
<span>[*]</span> <span>[*]</span>
<div> <div>
+201 -71
View File
@@ -1,12 +1,13 @@
import { Billing } from "@opencode-ai/console-core/billing.js" import { Billing } from "@opencode-ai/console-core/billing.js"
import type { APIEvent } from "@solidjs/start/server" import type { APIEvent } from "@solidjs/start/server"
import { and, Database, eq, sql } from "@opencode-ai/console-core/drizzle/index.js" import { and, Database, eq, isNull, sql } from "@opencode-ai/console-core/drizzle/index.js"
import { BillingTable, PaymentTable } from "@opencode-ai/console-core/schema/billing.sql.js" import { BillingTable, PaymentTable, SubscriptionTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
import { Identifier } from "@opencode-ai/console-core/identifier.js" import { Identifier } from "@opencode-ai/console-core/identifier.js"
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js" import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
import { Actor } from "@opencode-ai/console-core/actor.js" import { Actor } from "@opencode-ai/console-core/actor.js"
import { Resource } from "@opencode-ai/console-resource" import { Resource } from "@opencode-ai/console-resource"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
import { AuthTable } from "@opencode-ai/console-core/schema/auth.sql.js"
export async function POST(input: APIEvent) { export async function POST(input: APIEvent) {
const body = await Billing.stripe().webhooks.constructEventAsync( const body = await Billing.stripe().webhooks.constructEventAsync(
@@ -40,7 +41,7 @@ export async function POST(input: APIEvent) {
.where(eq(BillingTable.customerID, customerID)) .where(eq(BillingTable.customerID, customerID))
}) })
} }
if (body.type === "checkout.session.completed") { if (body.type === "checkout.session.completed" && body.data.object.mode === "payment") {
const workspaceID = body.data.object.metadata?.workspaceID const workspaceID = body.data.object.metadata?.workspaceID
const amountInCents = body.data.object.metadata?.amount && parseInt(body.data.object.metadata?.amount) const amountInCents = body.data.object.metadata?.amount && parseInt(body.data.object.metadata?.amount)
const customerID = body.data.object.customer as string const customerID = body.data.object.customer as string
@@ -103,85 +104,112 @@ export async function POST(input: APIEvent) {
}) })
}) })
} }
if (body.type === "charge.refunded") { if (body.type === "checkout.session.completed" && body.data.object.mode === "subscription") {
const workspaceID = body.data.object.custom_fields.find((f) => f.key === "workspaceid")?.text?.value
const amountInCents = body.data.object.amount_total as number
const customerID = body.data.object.customer as string const customerID = body.data.object.customer as string
const paymentIntentID = body.data.object.payment_intent as string const customerEmail = body.data.object.customer_details?.email as string
if (!customerID) throw new Error("Customer ID not found") const invoiceID = body.data.object.invoice as string
if (!paymentIntentID) throw new Error("Payment ID not found") const subscriptionID = body.data.object.subscription as string
const promoCode = body.data.object.discounts?.[0]?.promotion_code as string
const workspaceID = await Database.use((tx) =>
tx
.select({
workspaceID: BillingTable.workspaceID,
})
.from(BillingTable)
.where(eq(BillingTable.customerID, customerID))
.then((rows) => rows[0]?.workspaceID),
)
if (!workspaceID) throw new Error("Workspace ID not found") if (!workspaceID) throw new Error("Workspace ID not found")
const amount = await Database.use((tx) =>
tx
.select({
amount: PaymentTable.amount,
})
.from(PaymentTable)
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
.then((rows) => rows[0]?.amount),
)
if (!amount) throw new Error("Payment not found")
await Database.transaction(async (tx) => {
await tx
.update(PaymentTable)
.set({
timeRefunded: new Date(body.created * 1000),
})
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
await tx
.update(BillingTable)
.set({
balance: sql`${BillingTable.balance} - ${amount}`,
})
.where(eq(BillingTable.workspaceID, workspaceID))
})
}
if (body.type === "invoice.payment_succeeded" && body.data.object.billing_reason === "subscription_cycle") {
const invoiceID = body.data.object.id as string
const amountInCents = body.data.object.amount_paid
const customerID = body.data.object.customer as string
const subscriptionID = body.data.object.parent?.subscription_details?.subscription as string
if (!customerID) throw new Error("Customer ID not found") if (!customerID) throw new Error("Customer ID not found")
if (!amountInCents) throw new Error("Amount not found")
if (!invoiceID) throw new Error("Invoice ID not found") if (!invoiceID) throw new Error("Invoice ID not found")
if (!subscriptionID) throw new Error("Subscription ID not found") if (!subscriptionID) throw new Error("Subscription ID not found")
// get payment id from invoice
const invoice = await Billing.stripe().invoices.retrieve(invoiceID, { const invoice = await Billing.stripe().invoices.retrieve(invoiceID, {
expand: ["payments"], expand: ["payments"],
}) })
const paymentID = invoice.payments?.data[0].payment.payment_intent as string const paymentID = invoice.payments?.data[0].payment.payment_intent as string
if (!paymentID) throw new Error("Payment ID not found") if (!paymentID) throw new Error("Payment ID not found")
const workspaceID = await Database.use((tx) => // get payment method for the payment intent
tx const paymentIntent = await Billing.stripe().paymentIntents.retrieve(paymentID, {
.select({ workspaceID: BillingTable.workspaceID }) expand: ["payment_method"],
.from(BillingTable) })
.where(eq(BillingTable.customerID, customerID)) const paymentMethod = paymentIntent.payment_method
.then((rows) => rows[0]?.workspaceID), if (!paymentMethod || typeof paymentMethod === "string") throw new Error("Payment method not expanded")
)
if (!workspaceID) throw new Error("Workspace ID not found for customer")
await Database.use((tx) => // get coupon id from promotion code
tx.insert(PaymentTable).values({ const couponID = await (async () => {
workspaceID, if (!promoCode) return
id: Identifier.create("payment"), const coupon = await Billing.stripe().promotionCodes.retrieve(promoCode)
amount: centsToMicroCents(amountInCents), const couponID = coupon.coupon.id
paymentID, if (!couponID) throw new Error("Coupon not found for promotion code")
invoiceID, return couponID
customerID, })()
}),
) // get user
await Actor.provide("system", { workspaceID }, async () => {
// look up current billing
const billing = await Billing.get()
if (!billing) throw new Error(`Workspace with ID ${workspaceID} not found`)
// Temporarily skip this check because during Black drop, user can checkout
// as a new customer
//if (billing.customerID !== customerID) throw new Error("Customer ID mismatch")
// Temporarily check the user to apply to. After Black drop, we will allow
// look up the user to apply to
const users = await Database.use((tx) =>
tx
.select({ id: UserTable.id, email: AuthTable.subject })
.from(UserTable)
.innerJoin(AuthTable, and(eq(AuthTable.accountID, UserTable.accountID), eq(AuthTable.provider, "email")))
.where(and(eq(UserTable.workspaceID, workspaceID), isNull(UserTable.timeDeleted))),
)
const user = users.find((u) => u.email === customerEmail) ?? users[0]
if (!user) {
console.error(`Error: User with email ${customerEmail} not found in workspace ${workspaceID}`)
process.exit(1)
}
// set customer metadata
if (!billing?.customerID) {
await Billing.stripe().customers.update(customerID, {
metadata: {
workspaceID,
},
})
}
await Database.transaction(async (tx) => {
await tx
.update(BillingTable)
.set({
customerID,
subscriptionID,
subscriptionCouponID: couponID,
paymentMethodID: paymentMethod.id,
paymentMethodLast4: paymentMethod.card?.last4 ?? null,
paymentMethodType: paymentMethod.type,
})
.where(eq(BillingTable.workspaceID, workspaceID))
await tx.insert(SubscriptionTable).values({
workspaceID,
id: Identifier.create("subscription"),
userID: user.id,
})
await tx.insert(PaymentTable).values({
workspaceID,
id: Identifier.create("payment"),
amount: centsToMicroCents(amountInCents),
paymentID,
invoiceID,
customerID,
enrichment: {
type: "subscription",
couponID,
},
})
})
})
} }
if (body.type === "customer.subscription.created") { if (body.type === "customer.subscription.created") {
const data = { const data = {
@@ -378,9 +406,111 @@ export async function POST(input: APIEvent) {
if (!workspaceID) throw new Error("Workspace ID not found for subscription") if (!workspaceID) throw new Error("Workspace ID not found for subscription")
await Database.transaction(async (tx) => { await Database.transaction(async (tx) => {
await tx.update(BillingTable).set({ subscriptionID: null }).where(eq(BillingTable.workspaceID, workspaceID)) await tx
.update(BillingTable)
.set({ subscriptionID: null, subscriptionCouponID: null })
.where(eq(BillingTable.workspaceID, workspaceID))
await tx.update(UserTable).set({ timeSubscribed: null }).where(eq(UserTable.workspaceID, workspaceID)) await tx.delete(SubscriptionTable).where(eq(SubscriptionTable.workspaceID, workspaceID))
})
}
if (body.type === "invoice.payment_succeeded") {
if (body.data.object.billing_reason === "subscription_cycle") {
const invoiceID = body.data.object.id as string
const amountInCents = body.data.object.amount_paid
const customerID = body.data.object.customer as string
const subscriptionID = body.data.object.parent?.subscription_details?.subscription as string
if (!customerID) throw new Error("Customer ID not found")
if (!invoiceID) throw new Error("Invoice ID not found")
if (!subscriptionID) throw new Error("Subscription ID not found")
// get coupon id from subscription
const subscriptionData = await Billing.stripe().subscriptions.retrieve(subscriptionID, {
expand: ["discounts"],
})
const couponID =
typeof subscriptionData.discounts[0] === "string"
? subscriptionData.discounts[0]
: subscriptionData.discounts[0]?.coupon?.id
// get payment id from invoice
const invoice = await Billing.stripe().invoices.retrieve(invoiceID, {
expand: ["payments"],
})
const paymentID = invoice.payments?.data[0].payment.payment_intent as string
if (!paymentID) {
// payment id can be undefined when using coupon
if (!couponID) throw new Error("Payment ID not found")
}
const workspaceID = await Database.use((tx) =>
tx
.select({ workspaceID: BillingTable.workspaceID })
.from(BillingTable)
.where(eq(BillingTable.customerID, customerID))
.then((rows) => rows[0]?.workspaceID),
)
if (!workspaceID) throw new Error("Workspace ID not found for customer")
await Database.use((tx) =>
tx.insert(PaymentTable).values({
workspaceID,
id: Identifier.create("payment"),
amount: centsToMicroCents(amountInCents),
paymentID,
invoiceID,
customerID,
enrichment: {
type: "subscription",
couponID,
},
}),
)
}
}
if (body.type === "charge.refunded") {
const customerID = body.data.object.customer as string
const paymentIntentID = body.data.object.payment_intent as string
if (!customerID) throw new Error("Customer ID not found")
if (!paymentIntentID) throw new Error("Payment ID not found")
const workspaceID = await Database.use((tx) =>
tx
.select({
workspaceID: BillingTable.workspaceID,
})
.from(BillingTable)
.where(eq(BillingTable.customerID, customerID))
.then((rows) => rows[0]?.workspaceID),
)
if (!workspaceID) throw new Error("Workspace ID not found")
const amount = await Database.use((tx) =>
tx
.select({
amount: PaymentTable.amount,
})
.from(PaymentTable)
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
.then((rows) => rows[0]?.amount),
)
if (!amount) throw new Error("Payment not found")
await Database.transaction(async (tx) => {
await tx
.update(PaymentTable)
.set({
timeRefunded: new Date(body.created * 1000),
})
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
await tx
.update(BillingTable)
.set({
balance: sql`${BillingTable.balance} - ${amount}`,
})
.where(eq(BillingTable.workspaceID, workspaceID))
}) })
} }
})() })()
+3
View File
@@ -91,6 +91,9 @@ export default function Home() {
<li> <li>
<strong>Claude Pro</strong> Log in with Anthropic to use your Claude Pro or Max account <strong>Claude Pro</strong> Log in with Anthropic to use your Claude Pro or Max account
</li> </li>
<li>
<strong>ChatGPT Plus/Pro</strong> Log in with OpenAI to use your ChatGPT Plus or Pro account
</li>
<li> <li>
<strong>Use any model</strong> Supports 75+ LLM providers through{" "} <strong>Use any model</strong> Supports 75+ LLM providers through{" "}
<a href="https://models.dev">Models.dev</a>, including local models <a href="https://models.dev">Models.dev</a>, including local models
@@ -1,6 +1,6 @@
import { Billing } from "@opencode-ai/console-core/billing.js" import { Billing } from "@opencode-ai/console-core/billing.js"
import { query, action, useParams, createAsync, useAction } from "@solidjs/router" import { query, action, useParams, createAsync, useAction } from "@solidjs/router"
import { For, Show } from "solid-js" import { For, Match, Show, Switch } from "solid-js"
import { withActor } from "~/context/auth.withActor" import { withActor } from "~/context/auth.withActor"
import { formatDateUTC, formatDateForTable } from "../../common" import { formatDateUTC, formatDateForTable } from "../../common"
import styles from "./payment-section.module.css" import styles from "./payment-section.module.css"
@@ -77,7 +77,8 @@ export function PaymentSection() {
<For each={payments()!}> <For each={payments()!}>
{(payment) => { {(payment) => {
const date = new Date(payment.timeCreated) const date = new Date(payment.timeCreated)
const isCredit = !payment.paymentID const amount =
payment.enrichment?.type === "subscription" && payment.enrichment.couponID ? 0 : payment.amount
return ( return (
<tr> <tr>
<td data-slot="payment-date" title={formatDateUTC(date)}> <td data-slot="payment-date" title={formatDateUTC(date)}>
@@ -85,13 +86,14 @@ export function PaymentSection() {
</td> </td>
<td data-slot="payment-id">{payment.id}</td> <td data-slot="payment-id">{payment.id}</td>
<td data-slot="payment-amount" data-refunded={!!payment.timeRefunded}> <td data-slot="payment-amount" data-refunded={!!payment.timeRefunded}>
${((payment.amount ?? 0) / 100000000).toFixed(2)} ${((amount ?? 0) / 100000000).toFixed(2)}
{isCredit ? " (credit)" : ""} <Switch>
<Match when={payment.enrichment?.type === "credit"}> (credit)</Match>
<Match when={payment.enrichment?.type === "subscription"}> (subscription)</Match>
</Switch>
</td> </td>
<td data-slot="payment-receipt"> <td data-slot="payment-receipt">
{isCredit ? ( {payment.paymentID ? (
<span>-</span>
) : (
<button <button
onClick={async () => { onClick={async () => {
const receiptUrl = await downloadReceiptAction(params.id!, payment.paymentID!) const receiptUrl = await downloadReceiptAction(params.id!, payment.paymentID!)
@@ -103,6 +105,8 @@ export function PaymentSection() {
> >
View View
</button> </button>
) : (
<span>-</span>
)} )}
</td> </td>
</tr> </tr>
@@ -44,6 +44,7 @@ async function getCosts(workspaceID: string, year: number, month: number) {
eq(UsageTable.workspaceID, workspaceID), eq(UsageTable.workspaceID, workspaceID),
gte(UsageTable.timeCreated, startDate), gte(UsageTable.timeCreated, startDate),
lte(UsageTable.timeCreated, endDate), lte(UsageTable.timeCreated, endDate),
or(isNull(UsageTable.enrichment), sql`JSON_EXTRACT(${UsageTable.enrichment}, '$.plan') != 'sub'`),
), ),
) )
.groupBy(sql`DATE(${UsageTable.timeCreated})`, UsageTable.model, UsageTable.keyID) .groupBy(sql`DATE(${UsageTable.timeCreated})`, UsageTable.model, UsageTable.keyID)
@@ -1,8 +1,9 @@
import type { APIEvent } from "@solidjs/start/server" import type { APIEvent } from "@solidjs/start/server"
import { and, Database, eq, isNull, lt, or, sql } from "@opencode-ai/console-core/drizzle/index.js" import { and, Database, eq, isNull, lt, or, sql } from "@opencode-ai/console-core/drizzle/index.js"
import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js" import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
import { BillingTable, UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js" import { BillingTable, SubscriptionTable, UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js" import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
import { getWeekBounds } from "@opencode-ai/console-core/util/date.js"
import { Identifier } from "@opencode-ai/console-core/identifier.js" import { Identifier } from "@opencode-ai/console-core/identifier.js"
import { Billing } from "@opencode-ai/console-core/billing.js" import { Billing } from "@opencode-ai/console-core/billing.js"
import { Actor } from "@opencode-ai/console-core/actor.js" import { Actor } from "@opencode-ai/console-core/actor.js"
@@ -415,11 +416,11 @@ export async function handler(
timeMonthlyUsageUpdated: UserTable.timeMonthlyUsageUpdated, timeMonthlyUsageUpdated: UserTable.timeMonthlyUsageUpdated,
}, },
subscription: { subscription: {
timeSubscribed: UserTable.timeSubscribed, id: SubscriptionTable.id,
subIntervalUsage: UserTable.subIntervalUsage, rollingUsage: SubscriptionTable.rollingUsage,
subMonthlyUsage: UserTable.subMonthlyUsage, fixedUsage: SubscriptionTable.fixedUsage,
timeSubIntervalUsageUpdated: UserTable.timeSubIntervalUsageUpdated, timeRollingUpdated: SubscriptionTable.timeRollingUpdated,
timeSubMonthlyUsageUpdated: UserTable.timeSubMonthlyUsageUpdated, timeFixedUpdated: SubscriptionTable.timeFixedUpdated,
}, },
provider: { provider: {
credentials: ProviderTable.credentials, credentials: ProviderTable.credentials,
@@ -440,6 +441,14 @@ export async function handler(
) )
: sql`false`, : sql`false`,
) )
.leftJoin(
SubscriptionTable,
and(
eq(SubscriptionTable.workspaceID, KeyTable.workspaceID),
eq(SubscriptionTable.userID, KeyTable.userID),
isNull(SubscriptionTable.timeDeleted),
),
)
.where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted))) .where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted)))
.then((rows) => rows[0]), .then((rows) => rows[0]),
) )
@@ -448,7 +457,7 @@ export async function handler(
logger.metric({ logger.metric({
api_key: data.apiKey, api_key: data.apiKey,
workspace: data.workspaceID, workspace: data.workspaceID,
isSubscription: data.subscription.timeSubscribed ? true : false, isSubscription: data.subscription ? true : false,
}) })
return { return {
@@ -456,7 +465,7 @@ export async function handler(
workspaceID: data.workspaceID, workspaceID: data.workspaceID,
billing: data.billing, billing: data.billing,
user: data.user, user: data.user,
subscription: data.subscription.timeSubscribed ? data.subscription : undefined, subscription: data.subscription,
provider: data.provider, provider: data.provider,
isFree: FREE_WORKSPACES.includes(data.workspaceID), isFree: FREE_WORKSPACES.includes(data.workspaceID),
isDisabled: !!data.timeDisabled, isDisabled: !!data.timeDisabled,
@@ -484,23 +493,11 @@ export async function handler(
return `${minutes}min` return `${minutes}min`
} }
// Check monthly limit (based on subscription billing cycle) // Check weekly limit
if ( if (sub.fixedUsage && sub.timeFixedUpdated) {
sub.subMonthlyUsage && const week = getWeekBounds(now)
sub.timeSubMonthlyUsageUpdated && if (sub.timeFixedUpdated >= week.start && sub.fixedUsage >= centsToMicroCents(black.fixedLimit * 100)) {
sub.subMonthlyUsage >= centsToMicroCents(black.monthlyLimit * 100) const retryAfter = Math.ceil((week.end.getTime() - now.getTime()) / 1000)
) {
const subscribeDay = sub.timeSubscribed!.getUTCDate()
const cycleStart = new Date(
Date.UTC(
now.getUTCFullYear(),
now.getUTCDate() >= subscribeDay ? now.getUTCMonth() : now.getUTCMonth() - 1,
subscribeDay,
),
)
const cycleEnd = new Date(Date.UTC(cycleStart.getUTCFullYear(), cycleStart.getUTCMonth() + 1, subscribeDay))
if (sub.timeSubMonthlyUsageUpdated >= cycleStart && sub.timeSubMonthlyUsageUpdated < cycleEnd) {
const retryAfter = Math.ceil((cycleEnd.getTime() - now.getTime()) / 1000)
throw new SubscriptionError( throw new SubscriptionError(
`Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`, `Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`,
retryAfter, retryAfter,
@@ -508,14 +505,12 @@ export async function handler(
} }
} }
// Check interval limit // Check rolling limit
const intervalMs = black.intervalLength * 3600 * 1000 if (sub.rollingUsage && sub.timeRollingUpdated) {
if (sub.subIntervalUsage && sub.timeSubIntervalUsageUpdated) { const rollingWindowMs = black.rollingWindow * 3600 * 1000
const currentInterval = Math.floor(now.getTime() / intervalMs) const windowStart = new Date(now.getTime() - rollingWindowMs)
const usageInterval = Math.floor(sub.timeSubIntervalUsageUpdated.getTime() / intervalMs) if (sub.timeRollingUpdated >= windowStart && sub.rollingUsage >= centsToMicroCents(black.rollingLimit * 100)) {
if (currentInterval === usageInterval && sub.subIntervalUsage >= centsToMicroCents(black.intervalLimit * 100)) { const retryAfter = Math.ceil((sub.timeRollingUpdated.getTime() + rollingWindowMs - now.getTime()) / 1000)
const nextInterval = (currentInterval + 1) * intervalMs
const retryAfter = Math.ceil((nextInterval - now.getTime()) / 1000)
throw new SubscriptionError( throw new SubscriptionError(
`Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`, `Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`,
retryAfter, retryAfter,
@@ -661,38 +656,39 @@ export async function handler(
.where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))), .where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))),
...(authInfo.subscription ...(authInfo.subscription
? (() => { ? (() => {
const now = new Date() const black = BlackData.get()
const subscribeDay = authInfo.subscription.timeSubscribed!.getUTCDate() const week = getWeekBounds(new Date())
const cycleStart = new Date( const rollingWindowSeconds = black.rollingWindow * 3600
Date.UTC(
now.getUTCFullYear(),
now.getUTCDate() >= subscribeDay ? now.getUTCMonth() : now.getUTCMonth() - 1,
subscribeDay,
),
)
const cycleEnd = new Date(
Date.UTC(cycleStart.getUTCFullYear(), cycleStart.getUTCMonth() + 1, subscribeDay),
)
return [ return [
db db
.update(UserTable) .update(SubscriptionTable)
.set({ .set({
subMonthlyUsage: sql` fixedUsage: sql`
CASE CASE
WHEN ${UserTable.timeSubMonthlyUsageUpdated} >= ${cycleStart} AND ${UserTable.timeSubMonthlyUsageUpdated} < ${cycleEnd} THEN ${UserTable.subMonthlyUsage} + ${cost} WHEN ${SubscriptionTable.timeFixedUpdated} >= ${week.start} THEN ${SubscriptionTable.fixedUsage} + ${cost}
ELSE ${cost} ELSE ${cost}
END END
`, `,
timeSubMonthlyUsageUpdated: sql`now()`, timeFixedUpdated: sql`now()`,
subIntervalUsage: sql` rollingUsage: sql`
CASE CASE
WHEN FLOOR(UNIX_TIMESTAMP(${UserTable.timeSubIntervalUsageUpdated}) / (${BlackData.get().intervalLength} * 3600)) = FLOOR(UNIX_TIMESTAMP(now()) / (${BlackData.get().intervalLength} * 3600)) THEN ${UserTable.subIntervalUsage} + ${cost} WHEN UNIX_TIMESTAMP(${SubscriptionTable.timeRollingUpdated}) >= UNIX_TIMESTAMP(now()) - ${rollingWindowSeconds} THEN ${SubscriptionTable.rollingUsage} + ${cost}
ELSE ${cost} ELSE ${cost}
END END
`, `,
timeSubIntervalUsageUpdated: sql`now()`, timeRollingUpdated: sql`
CASE
WHEN UNIX_TIMESTAMP(${SubscriptionTable.timeRollingUpdated}) >= UNIX_TIMESTAMP(now()) - ${rollingWindowSeconds} THEN ${SubscriptionTable.timeRollingUpdated}
ELSE now()
END
`,
}) })
.where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id))), .where(
and(
eq(SubscriptionTable.workspaceID, authInfo.workspaceID),
eq(SubscriptionTable.userID, authInfo.user.id),
),
),
] ]
})() })()
: [ : [
@@ -0,0 +1,13 @@
CREATE TABLE `subscription` (
`id` varchar(30) NOT NULL,
`workspace_id` varchar(30) NOT NULL,
`time_created` timestamp(3) NOT NULL DEFAULT (now()),
`time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
`time_deleted` timestamp(3),
`user_id` varchar(30) NOT NULL,
`rolling_usage` bigint,
`fixed_usage` bigint,
`time_rolling_updated` timestamp(3),
`time_fixed_updated` timestamp(3),
CONSTRAINT `subscription_workspace_id_id_pk` PRIMARY KEY(`workspace_id`,`id`)
);
@@ -0,0 +1,6 @@
CREATE INDEX `workspace_user_id` ON `subscription` (`workspace_id`,`user_id`);--> statement-breakpoint
ALTER TABLE `user` DROP COLUMN `time_subscribed`;--> statement-breakpoint
ALTER TABLE `user` DROP COLUMN `sub_interval_usage`;--> statement-breakpoint
ALTER TABLE `user` DROP COLUMN `sub_monthly_usage`;--> statement-breakpoint
ALTER TABLE `user` DROP COLUMN `sub_time_interval_usage_updated`;--> statement-breakpoint
ALTER TABLE `user` DROP COLUMN `sub_time_monthly_usage_updated`;
@@ -0,0 +1,2 @@
DROP INDEX `workspace_user_id` ON `subscription`;--> statement-breakpoint
ALTER TABLE `subscription` ADD CONSTRAINT `workspace_user_id` UNIQUE(`workspace_id`,`user_id`);
@@ -0,0 +1 @@
ALTER TABLE `billing` ADD `subscription_coupon_id` varchar(28);
@@ -0,0 +1 @@
ALTER TABLE `payment` ADD `enrichment` json;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -323,6 +323,41 @@
"when": 1767765497502, "when": 1767765497502,
"tag": "0045_cuddly_diamondback", "tag": "0045_cuddly_diamondback",
"breakpoints": true "breakpoints": true
},
{
"idx": 46,
"version": "5",
"when": 1767912262458,
"tag": "0046_charming_black_bolt",
"breakpoints": true
},
{
"idx": 47,
"version": "5",
"when": 1767916965243,
"tag": "0047_huge_omega_red",
"breakpoints": true
},
{
"idx": 48,
"version": "5",
"when": 1767917785224,
"tag": "0048_mean_frank_castle",
"breakpoints": true
},
{
"idx": 49,
"version": "5",
"when": 1767922954153,
"tag": "0049_noisy_domino",
"breakpoints": true
},
{
"idx": 50,
"version": "5",
"when": 1767931290031,
"tag": "0050_bumpy_mephistopheles",
"breakpoints": true
} }
] ]
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.1.7", "version": "1.1.14",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+134 -4
View File
@@ -1,8 +1,11 @@
import { Database, eq, sql, inArray } from "../src/drizzle/index.js" import { Database, and, eq, sql } from "../src/drizzle/index.js"
import { AuthTable } from "../src/schema/auth.sql.js" import { AuthTable } from "../src/schema/auth.sql.js"
import { UserTable } from "../src/schema/user.sql.js" import { UserTable } from "../src/schema/user.sql.js"
import { BillingTable, PaymentTable, UsageTable } from "../src/schema/billing.sql.js" import { BillingTable, PaymentTable, SubscriptionTable, UsageTable } from "../src/schema/billing.sql.js"
import { WorkspaceTable } from "../src/schema/workspace.sql.js" import { WorkspaceTable } from "../src/schema/workspace.sql.js"
import { BlackData } from "../src/black.js"
import { centsToMicroCents } from "../src/util/price.js"
import { getWeekBounds } from "../src/util/date.js"
// get input from command line // get input from command line
const identifier = process.argv[2] const identifier = process.argv[2]
@@ -35,10 +38,21 @@ if (identifier.startsWith("wrk_")) {
workspaceID: UserTable.workspaceID, workspaceID: UserTable.workspaceID,
workspaceName: WorkspaceTable.name, workspaceName: WorkspaceTable.name,
role: UserTable.role, role: UserTable.role,
subscribed: SubscriptionTable.timeCreated,
}) })
.from(UserTable) .from(UserTable)
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, UserTable.workspaceID)) .rightJoin(WorkspaceTable, eq(WorkspaceTable.id, UserTable.workspaceID))
.where(eq(UserTable.accountID, accountID)), .leftJoin(SubscriptionTable, eq(SubscriptionTable.userID, UserTable.id))
.where(eq(UserTable.accountID, accountID))
.then((rows) =>
rows.map((row) => ({
userID: row.userID,
workspaceID: row.workspaceID,
workspaceName: row.workspaceName,
role: row.role,
subscribed: formatDate(row.subscribed),
})),
),
) )
// Get all payments for these workspaces // Get all payments for these workspaces
@@ -56,11 +70,51 @@ async function printWorkspace(workspaceID: string) {
printHeader(`Workspace "${workspace.name}" (${workspace.id})`) printHeader(`Workspace "${workspace.name}" (${workspace.id})`)
await printTable("Users", (tx) =>
tx
.select({
authEmail: AuthTable.subject,
inviteEmail: UserTable.email,
role: UserTable.role,
timeSeen: UserTable.timeSeen,
monthlyLimit: UserTable.monthlyLimit,
monthlyUsage: UserTable.monthlyUsage,
timeDeleted: UserTable.timeDeleted,
fixedUsage: SubscriptionTable.fixedUsage,
rollingUsage: SubscriptionTable.rollingUsage,
timeFixedUpdated: SubscriptionTable.timeFixedUpdated,
timeRollingUpdated: SubscriptionTable.timeRollingUpdated,
timeSubscriptionCreated: SubscriptionTable.timeCreated,
})
.from(UserTable)
.leftJoin(AuthTable, and(eq(UserTable.accountID, AuthTable.accountID), eq(AuthTable.provider, "email")))
.leftJoin(SubscriptionTable, eq(SubscriptionTable.userID, UserTable.id))
.where(eq(UserTable.workspaceID, workspace.id))
.then((rows) =>
rows.map((row) => {
const subStatus = getSubscriptionStatus(row)
return {
email: (row.timeDeleted ? "❌ " : "") + (row.authEmail ?? row.inviteEmail),
role: row.role,
timeSeen: formatDate(row.timeSeen),
monthly: formatMonthlyUsage(row.monthlyUsage, row.monthlyLimit),
subscribed: formatDate(row.timeSubscriptionCreated),
subWeekly: subStatus.weekly,
subRolling: subStatus.rolling,
rateLimited: subStatus.rateLimited,
retryIn: subStatus.retryIn,
}
}),
),
)
await printTable("Billing", (tx) => await printTable("Billing", (tx) =>
tx tx
.select({ .select({
balance: BillingTable.balance, balance: BillingTable.balance,
customerID: BillingTable.customerID, customerID: BillingTable.customerID,
subscriptionID: BillingTable.subscriptionID,
subscriptionCouponID: BillingTable.subscriptionCouponID,
}) })
.from(BillingTable) .from(BillingTable)
.where(eq(BillingTable.workspaceID, workspace.id)) .where(eq(BillingTable.workspaceID, workspace.id))
@@ -97,6 +151,7 @@ async function printWorkspace(workspaceID: string) {
), ),
) )
/*
await printTable("Usage", (tx) => await printTable("Usage", (tx) =>
tx tx
.select({ .select({
@@ -122,6 +177,81 @@ async function printWorkspace(workspaceID: string) {
})), })),
), ),
) )
*/
}
function formatMicroCents(value: number | null | undefined) {
if (value === null || value === undefined) return null
return `$${(value / 100000000).toFixed(2)}`
}
function formatDate(value: Date | null | undefined) {
if (!value) return null
return value.toISOString().split("T")[0]
}
function formatMonthlyUsage(usage: number | null | undefined, limit: number | null | undefined) {
const usageText = formatMicroCents(usage) ?? "$0.00"
if (limit === null || limit === undefined) return `${usageText} / no limit`
return `${usageText} / $${limit.toFixed(2)}`
}
function formatRetryTime(seconds: number) {
const days = Math.floor(seconds / 86400)
if (days >= 1) return `${days} day${days > 1 ? "s" : ""}`
const hours = Math.floor(seconds / 3600)
const minutes = Math.ceil((seconds % 3600) / 60)
if (hours >= 1) return `${hours}hr ${minutes}min`
return `${minutes}min`
}
function getSubscriptionStatus(row: {
timeSubscriptionCreated: Date | null
fixedUsage: number | null
rollingUsage: number | null
timeFixedUpdated: Date | null
timeRollingUpdated: Date | null
}) {
if (!row.timeSubscriptionCreated) {
return { weekly: null, rolling: null, rateLimited: null, retryIn: null }
}
const black = BlackData.get()
const now = new Date()
const week = getWeekBounds(now)
const fixedLimit = black.fixedLimit ? centsToMicroCents(black.fixedLimit * 100) : null
const rollingLimit = black.rollingLimit ? centsToMicroCents(black.rollingLimit * 100) : null
const rollingWindowMs = (black.rollingWindow ?? 5) * 3600 * 1000
// Calculate current weekly usage (reset if outside current week)
const currentWeekly =
row.fixedUsage && row.timeFixedUpdated && row.timeFixedUpdated >= week.start ? row.fixedUsage : 0
// Calculate current rolling usage
const windowStart = new Date(now.getTime() - rollingWindowMs)
const currentRolling =
row.rollingUsage && row.timeRollingUpdated && row.timeRollingUpdated >= windowStart ? row.rollingUsage : 0
// Check rate limiting
const isWeeklyLimited = fixedLimit !== null && currentWeekly >= fixedLimit
const isRollingLimited = rollingLimit !== null && currentRolling >= rollingLimit
let retryIn: string | null = null
if (isWeeklyLimited) {
const retryAfter = Math.ceil((week.end.getTime() - now.getTime()) / 1000)
retryIn = formatRetryTime(retryAfter)
} else if (isRollingLimited && row.timeRollingUpdated) {
const retryAfter = Math.ceil((row.timeRollingUpdated.getTime() + rollingWindowMs - now.getTime()) / 1000)
retryIn = formatRetryTime(retryAfter)
}
return {
weekly: fixedLimit !== null ? `${formatMicroCents(currentWeekly)} / $${black.fixedLimit}` : null,
rolling: rollingLimit !== null ? `${formatMicroCents(currentRolling)} / $${black.rollingLimit}` : null,
rateLimited: isWeeklyLimited || isRollingLimited ? "yes" : "no",
retryIn,
}
} }
function printHeader(title: string) { function printHeader(title: string) {
@@ -1,35 +1,35 @@
import { Billing } from "../src/billing.js" import { Billing } from "../src/billing.js"
import { Database, eq, and, sql } from "../src/drizzle/index.js" import { and, Database, eq, isNull, sql } from "../src/drizzle/index.js"
import { AuthTable } from "../src/schema/auth.sql.js"
import { UserTable } from "../src/schema/user.sql.js" import { UserTable } from "../src/schema/user.sql.js"
import { BillingTable, PaymentTable } from "../src/schema/billing.sql.js" import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/billing.sql.js"
import { Identifier } from "../src/identifier.js" import { Identifier } from "../src/identifier.js"
import { centsToMicroCents } from "../src/util/price.js" import { centsToMicroCents } from "../src/util/price.js"
import { AuthTable } from "../src/schema/auth.sql.js"
const workspaceID = process.argv[2] const workspaceID = process.argv[2]
const email = process.argv[3] const email = process.argv[3]
console.log(`Onboarding workspace ${workspaceID} for email ${email}`)
if (!workspaceID || !email) { if (!workspaceID || !email) {
console.error("Usage: bun onboard-zen-black.ts <workspaceID> <email>") console.error("Usage: bun onboard-zen-black.ts <workspaceID> <email>")
process.exit(1) process.exit(1)
} }
// Look up the Stripe customer by email // Look up the Stripe customer by email
const customers = await Billing.stripe().customers.list({ email, limit: 1 }) const customers = await Billing.stripe().customers.list({ email, limit: 10, expand: ["data.subscriptions"] })
const customer = customers.data[0] if (!customers.data) {
if (!customer) {
console.error(`Error: No Stripe customer found for email ${email}`) console.error(`Error: No Stripe customer found for email ${email}`)
process.exit(1) process.exit(1)
} }
const customerID = customer.id const customer = customers.data.find((c) => c.subscriptions?.data[0]?.items.data[0]?.price.unit_amount === 20000)
if (!customer) {
// Get the subscription id console.error(`Error: No Stripe customer found for email ${email} with $200 subscription`)
const subscriptions = await Billing.stripe().subscriptions.list({ customer: customerID, limit: 1 })
const subscription = subscriptions.data[0]
if (!subscription) {
console.error(`Error: Customer ${customerID} does not have a subscription`)
process.exit(1) process.exit(1)
} }
const customerID = customer.id
const subscription = customer.subscriptions!.data[0]
const subscriptionID = subscription.id const subscriptionID = subscription.id
// Validate the subscription is $200 // Validate the subscription is $200
@@ -39,6 +39,12 @@ if (amountInCents !== 20000) {
process.exit(1) process.exit(1)
} }
const subscriptionData = await Billing.stripe().subscriptions.retrieve(subscription.id, { expand: ["discounts"] })
const couponID =
typeof subscriptionData.discounts[0] === "string"
? subscriptionData.discounts[0]
: subscriptionData.discounts[0]?.coupon?.id
// Check if subscription is already tied to another workspace // Check if subscription is already tied to another workspace
const existingSubscription = await Database.use((tx) => const existingSubscription = await Database.use((tx) =>
tx tx
@@ -90,29 +96,21 @@ const paymentMethod = paymentMethodID ? await Billing.stripe().paymentMethods.re
const paymentMethodLast4 = paymentMethod?.card?.last4 ?? null const paymentMethodLast4 = paymentMethod?.card?.last4 ?? null
const paymentMethodType = paymentMethod?.type ?? null const paymentMethodType = paymentMethod?.type ?? null
// Look up the user by email via AuthTable // Look up the user in the workspace
const auth = await Database.use((tx) => const users = await Database.use((tx) =>
tx tx
.select({ accountID: AuthTable.accountID }) .select({ id: UserTable.id, email: AuthTable.subject })
.from(AuthTable) .from(UserTable)
.where(and(eq(AuthTable.provider, "email"), eq(AuthTable.subject, email))) .innerJoin(AuthTable, and(eq(AuthTable.accountID, UserTable.accountID), eq(AuthTable.provider, "email")))
.then((rows) => rows[0]), .where(and(eq(UserTable.workspaceID, workspaceID), isNull(UserTable.timeDeleted))),
) )
if (!auth) { if (users.length === 0) {
console.error(`Error: No user found with email ${email}`) console.error(`Error: No users found in workspace ${workspaceID}`)
process.exit(1) process.exit(1)
} }
const user = users.length === 1 ? users[0] : users.find((u) => u.email === email)
// Look up the user in the workspace
const user = await Database.use((tx) =>
tx
.select({ id: UserTable.id })
.from(UserTable)
.where(and(eq(UserTable.workspaceID, workspaceID), eq(UserTable.accountID, auth.accountID)))
.then((rows) => rows[0]),
)
if (!user) { if (!user) {
console.error(`Error: User with email ${email} is not a member of workspace ${workspaceID}`) console.error(`Error: User with email ${email} not found in workspace ${workspaceID}`)
process.exit(1) process.exit(1)
} }
@@ -130,19 +128,19 @@ await Database.transaction(async (tx) => {
.set({ .set({
customerID, customerID,
subscriptionID, subscriptionID,
subscriptionCouponID: couponID,
paymentMethodID, paymentMethodID,
paymentMethodLast4, paymentMethodLast4,
paymentMethodType, paymentMethodType,
}) })
.where(eq(BillingTable.workspaceID, workspaceID)) .where(eq(BillingTable.workspaceID, workspaceID))
// Set current time as timeSubscribed on user // Create a row in subscription table
await tx await tx.insert(SubscriptionTable).values({
.update(UserTable) workspaceID,
.set({ id: Identifier.create("subscription"),
timeSubscribed: sql`now()`, userID: user.id,
}) })
.where(eq(UserTable.id, user.id))
// Create a row in payments table // Create a row in payments table
await tx.insert(PaymentTable).values({ await tx.insert(PaymentTable).values({
@@ -152,6 +150,10 @@ await Database.transaction(async (tx) => {
customerID, customerID,
invoiceID, invoiceID,
paymentID, paymentID,
enrichment: {
type: "subscription",
couponID,
},
}) })
}) })
@@ -0,0 +1,78 @@
import { Billing } from "../src/billing.js"
import { and, Database, eq } from "../src/drizzle/index.js"
import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/billing.sql.js"
const workspaceID = process.argv[2]
if (!workspaceID) {
console.error("Usage: bun remove-black.ts <workspaceID>")
process.exit(1)
}
console.log(`Removing subscription from workspace ${workspaceID}`)
// Look up the workspace billing
const billing = await Database.use((tx) =>
tx
.select({
customerID: BillingTable.customerID,
subscriptionID: BillingTable.subscriptionID,
})
.from(BillingTable)
.where(eq(BillingTable.workspaceID, workspaceID))
.then((rows) => rows[0]),
)
if (!billing) {
console.error(`Error: No billing record found for workspace ${workspaceID}`)
process.exit(1)
}
if (!billing.subscriptionID) {
console.error(`Error: Workspace ${workspaceID} does not have a subscription`)
process.exit(1)
}
console.log(` Customer ID: ${billing.customerID}`)
console.log(` Subscription ID: ${billing.subscriptionID}`)
// Clear workspaceID from Stripe customer metadata
if (billing.customerID) {
//await Billing.stripe().customers.update(billing.customerID, {
// metadata: {
// workspaceID: "",
// },
//})
//console.log(`Cleared workspaceID from Stripe customer metadata`)
}
await Database.transaction(async (tx) => {
// Clear subscription-related fields from billing table
await tx
.update(BillingTable)
.set({
// customerID: null,
subscriptionID: null,
subscriptionCouponID: null,
// paymentMethodID: null,
// paymentMethodLast4: null,
// paymentMethodType: null,
})
.where(eq(BillingTable.workspaceID, workspaceID))
// Delete from subscription table
await tx.delete(SubscriptionTable).where(eq(SubscriptionTable.workspaceID, workspaceID))
// Delete from payments table
await tx
.delete(PaymentTable)
.where(
and(
eq(PaymentTable.workspaceID, workspaceID),
eq(PaymentTable.enrichment, { type: "subscription" }),
eq(PaymentTable.amount, 20000000000),
),
)
})
console.log(`Successfully removed subscription from workspace ${workspaceID}`)
+3
View File
@@ -171,6 +171,9 @@ export namespace Billing {
workspaceID, workspaceID,
id: Identifier.create("payment"), id: Identifier.create("payment"),
amount: amountInMicroCents, amount: amountInMicroCents,
enrichment: {
type: "credit",
},
}) })
}) })
return amountInMicroCents return amountInMicroCents
+3 -3
View File
@@ -4,9 +4,9 @@ import { Resource } from "@opencode-ai/console-resource"
export namespace BlackData { export namespace BlackData {
const Schema = z.object({ const Schema = z.object({
monthlyLimit: z.number().int(), fixedLimit: z.number().int(),
intervalLimit: z.number().int(), rollingLimit: z.number().int(),
intervalLength: z.number().int(), rollingWindow: z.number().int(),
}) })
export const validate = fn(Schema, (input) => { export const validate = fn(Schema, (input) => {
+1
View File
@@ -11,6 +11,7 @@ export namespace Identifier {
model: "mod", model: "mod",
payment: "pay", payment: "pay",
provider: "prv", provider: "prv",
subscription: "sub",
usage: "usg", usage: "usg",
user: "usr", user: "usr",
workspace: "wrk", workspace: "wrk",
@@ -22,6 +22,7 @@ export const BillingTable = mysqlTable(
timeReloadError: utc("time_reload_error"), timeReloadError: utc("time_reload_error"),
timeReloadLockedTill: utc("time_reload_locked_till"), timeReloadLockedTill: utc("time_reload_locked_till"),
subscriptionID: varchar("subscription_id", { length: 28 }), subscriptionID: varchar("subscription_id", { length: 28 }),
subscriptionCouponID: varchar("subscription_coupon_id", { length: 28 }),
}, },
(table) => [ (table) => [
...workspaceIndexes(table), ...workspaceIndexes(table),
@@ -30,6 +31,20 @@ export const BillingTable = mysqlTable(
], ],
) )
export const SubscriptionTable = mysqlTable(
"subscription",
{
...workspaceColumns,
...timestamps,
userID: ulid("user_id").notNull(),
rollingUsage: bigint("rolling_usage", { mode: "number" }),
fixedUsage: bigint("fixed_usage", { mode: "number" }),
timeRollingUpdated: utc("time_rolling_updated"),
timeFixedUpdated: utc("time_fixed_updated"),
},
(table) => [...workspaceIndexes(table), uniqueIndex("workspace_user_id").on(table.workspaceID, table.userID)],
)
export const PaymentTable = mysqlTable( export const PaymentTable = mysqlTable(
"payment", "payment",
{ {
@@ -40,6 +55,15 @@ export const PaymentTable = mysqlTable(
paymentID: varchar("payment_id", { length: 255 }), paymentID: varchar("payment_id", { length: 255 }),
amount: bigint("amount", { mode: "number" }).notNull(), amount: bigint("amount", { mode: "number" }).notNull(),
timeRefunded: utc("time_refunded"), timeRefunded: utc("time_refunded"),
enrichment: json("enrichment").$type<
| {
type: "subscription"
couponID?: string
}
| {
type: "credit"
}
>(),
}, },
(table) => [...workspaceIndexes(table)], (table) => [...workspaceIndexes(table)],
) )
@@ -18,12 +18,6 @@ export const UserTable = mysqlTable(
monthlyLimit: int("monthly_limit"), monthlyLimit: int("monthly_limit"),
monthlyUsage: bigint("monthly_usage", { mode: "number" }), monthlyUsage: bigint("monthly_usage", { mode: "number" }),
timeMonthlyUsageUpdated: utc("time_monthly_usage_updated"), timeMonthlyUsageUpdated: utc("time_monthly_usage_updated"),
// subscription
timeSubscribed: utc("time_subscribed"),
subIntervalUsage: bigint("sub_interval_usage", { mode: "number" }),
subMonthlyUsage: bigint("sub_monthly_usage", { mode: "number" }),
timeSubIntervalUsageUpdated: utc("sub_time_interval_usage_updated"),
timeSubMonthlyUsageUpdated: utc("sub_time_monthly_usage_updated"),
}, },
(table) => [ (table) => [
...workspaceIndexes(table), ...workspaceIndexes(table),
+9
View File
@@ -0,0 +1,9 @@
export function getWeekBounds(date: Date) {
const dayOfWeek = date.getUTCDay()
const start = new Date(date)
start.setUTCDate(date.getUTCDate() - dayOfWeek + 1)
start.setUTCHours(0, 0, 0, 0)
const end = new Date(start)
end.setUTCDate(start.getUTCDate() + 7)
return { start, end }
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.1.7", "version": "1.1.14",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.1.7", "version": "1.1.14",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
+1 -30
View File
@@ -13,36 +13,7 @@
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" /> <meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
<meta property="og:image" content="/social-share.png" /> <meta property="og:image" content="/social-share.png" />
<meta property="twitter:image" content="/social-share.png" /> <meta property="twitter:image" content="/social-share.png" />
<script id="oc-theme-preload-script"> <script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
;(function () {
var themeId = localStorage.getItem("opencode-theme-id")
if (!themeId) return
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
var mode = isDark ? "dark" : "light"
document.documentElement.dataset.theme = themeId
document.documentElement.dataset.colorScheme = mode
if (themeId === "oc-1") return
var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
if (css) {
var style = document.createElement("style")
style.id = "oc-theme-preload"
style.textContent =
":root{color-scheme:" +
mode +
";--text-mix-blend-mode:" +
(isDark ? "plus-lighter" : "multiply") +
";" +
css +
"}"
document.head.appendChild(style)
}
})()
</script>
</head> </head>
<body class="antialiased overscroll-none text-12-regular overflow-hidden"> <body class="antialiased overscroll-none text-12-regular overflow-hidden">
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"private": true, "private": true,
"version": "1.1.7", "version": "1.1.14",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+1
View File
@@ -2795,6 +2795,7 @@ dependencies = [
"futures", "futures",
"gtk", "gtk",
"listeners", "listeners",
"reqwest",
"semver", "semver",
"serde", "serde",
"serde_json", "serde_json",
+2 -1
View File
@@ -3,7 +3,7 @@ name = "opencode-desktop"
version = "0.0.0" version = "0.0.0"
description = "The open source AI coding agent" description = "The open source AI coding agent"
authors = ["Anomaly Innovations"] authors = ["Anomaly Innovations"]
edition = "2021" edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -38,6 +38,7 @@ listeners = "0.3"
tauri-plugin-os = "2" tauri-plugin-os = "2"
futures = "0.3.31" futures = "0.3.31"
semver = "1.0.27" semver = "1.0.27"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18.2" gtk = "0.18.2"
+63 -6
View File
@@ -1,6 +1,30 @@
use tauri::{path::BaseDirectory, AppHandle, Manager};
use tauri_plugin_shell::{process::Command, ShellExt};
const CLI_INSTALL_DIR: &str = ".opencode/bin"; const CLI_INSTALL_DIR: &str = ".opencode/bin";
const CLI_BINARY_NAME: &str = "opencode"; const CLI_BINARY_NAME: &str = "opencode";
#[derive(serde::Deserialize)]
pub struct ServerConfig {
pub hostname: Option<String>,
pub port: Option<u32>,
}
#[derive(serde::Deserialize)]
pub struct Config {
pub server: Option<ServerConfig>,
}
pub async fn get_config(app: &AppHandle) -> Option<Config> {
create_command(app, "debug config")
.output()
.await
.inspect_err(|e| eprintln!("Failed to read OC config: {e}"))
.ok()
.and_then(|out| String::from_utf8(out.stdout.to_vec()).ok())
.and_then(|s| serde_json::from_str::<Config>(&s).ok())
}
fn get_cli_install_path() -> Option<std::path::PathBuf> { fn get_cli_install_path() -> Option<std::path::PathBuf> {
std::env::var("HOME").ok().map(|home| { std::env::var("HOME").ok().map(|home| {
std::path::PathBuf::from(home) std::path::PathBuf::from(home)
@@ -9,9 +33,10 @@ fn get_cli_install_path() -> Option<std::path::PathBuf> {
}) })
} }
pub fn get_sidecar_path() -> std::path::PathBuf { pub fn get_sidecar_path(app: &tauri::AppHandle) -> std::path::PathBuf {
tauri::utils::platform::current_exe() // Get binary with symlinks support
.expect("Failed to get current exe") tauri::process::current_binary(&app.env())
.expect("Failed to get current binary")
.parent() .parent()
.expect("Failed to get parent dir") .expect("Failed to get parent dir")
.join("opencode-cli") .join("opencode-cli")
@@ -26,12 +51,12 @@ fn is_cli_installed() -> bool {
const INSTALL_SCRIPT: &str = include_str!("../../../../install"); const INSTALL_SCRIPT: &str = include_str!("../../../../install");
#[tauri::command] #[tauri::command]
pub fn install_cli() -> Result<String, String> { pub fn install_cli(app: tauri::AppHandle) -> Result<String, String> {
if cfg!(not(unix)) { if cfg!(not(unix)) {
return Err("CLI installation is only supported on macOS & Linux".to_string()); return Err("CLI installation is only supported on macOS & Linux".to_string());
} }
let sidecar = get_sidecar_path(); let sidecar = get_sidecar_path(&app);
if !sidecar.exists() { if !sidecar.exists() {
return Err("Sidecar binary not found".to_string()); return Err("Sidecar binary not found".to_string());
} }
@@ -108,9 +133,41 @@ pub fn sync_cli(app: tauri::AppHandle) -> Result<(), String> {
cli_version, app_version cli_version, app_version
); );
install_cli()?; install_cli(app)?;
println!("Synced installed CLI"); println!("Synced installed CLI");
Ok(()) Ok(())
} }
fn get_user_shell() -> String {
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
}
pub fn create_command(app: &tauri::AppHandle, args: &str) -> Command {
let state_dir = app
.path()
.resolve("", BaseDirectory::AppLocalData)
.expect("Failed to resolve app local data dir");
#[cfg(target_os = "windows")]
return app
.shell()
.sidecar("opencode-cli")
.unwrap()
.env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
.env("OPENCODE_CLIENT", "desktop")
.env("XDG_STATE_HOME", &state_dir);
#[cfg(not(target_os = "windows"))]
return {
let sidecar = get_sidecar_path(app);
let shell = get_user_shell();
app.shell()
.command(&shell)
.env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
.env("OPENCODE_CLIENT", "desktop")
.env("XDG_STATE_HOME", &state_dir)
.args(["-il", "-c", &format!("\"{}\" {}", sidecar.display(), args)])
};
}
+166 -171
View File
@@ -1,35 +1,34 @@
mod cli; mod cli;
mod window_customizer; mod window_customizer;
use cli::{get_sidecar_path, install_cli, sync_cli}; use cli::{install_cli, sync_cli};
use futures::FutureExt; use futures::FutureExt;
use std::{ use std::{
collections::VecDeque, collections::VecDeque,
net::{SocketAddr, TcpListener}, net::TcpListener,
sync::{Arc, Mutex}, sync::{Arc, Mutex},
time::{Duration, Instant}, time::{Duration, Instant},
}; };
use tauri::{ use tauri::{AppHandle, LogicalSize, Manager, RunEvent, State, WebviewUrl, WebviewWindow};
path::BaseDirectory, AppHandle, LogicalSize, Manager, RunEvent, State, WebviewUrl, use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
WebviewWindow,
};
use tauri_plugin_shell::process::{CommandChild, CommandEvent}; use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use tauri_plugin_shell::ShellExt;
use tauri_plugin_store::StoreExt; use tauri_plugin_store::StoreExt;
use tokio::net::TcpSocket;
use crate::window_customizer::PinchZoomDisablePlugin; use crate::window_customizer::PinchZoomDisablePlugin;
const SETTINGS_STORE: &str = "opencode.settings.dat";
const DEFAULT_SERVER_URL_KEY: &str = "defaultServerUrl";
#[derive(Clone)] #[derive(Clone)]
struct ServerState { struct ServerState {
child: Arc<Mutex<Option<CommandChild>>>, child: Arc<Mutex<Option<CommandChild>>>,
status: futures::future::Shared<tokio::sync::oneshot::Receiver<Result<(), String>>>, status: futures::future::Shared<tokio::sync::oneshot::Receiver<Result<String, String>>>,
} }
impl ServerState { impl ServerState {
pub fn new( pub fn new(
child: Option<CommandChild>, child: Option<CommandChild>,
status: tokio::sync::oneshot::Receiver<Result<(), String>>, status: tokio::sync::oneshot::Receiver<Result<String, String>>,
) -> Self { ) -> Self {
Self { Self {
child: Arc::new(Mutex::new(child)), child: Arc::new(Mutex::new(child)),
@@ -46,65 +45,6 @@ impl ServerState {
struct LogState(Arc<Mutex<VecDeque<String>>>); struct LogState(Arc<Mutex<VecDeque<String>>>);
const MAX_LOG_ENTRIES: usize = 200; const MAX_LOG_ENTRIES: usize = 200;
const GLOBAL_STORAGE: &str = "opencode.global.dat";
/// Check if a URL's origin matches any configured server in the store.
/// Returns true if the URL should be allowed for internal navigation.
fn is_allowed_server(app: &AppHandle, url: &tauri::Url) -> bool {
// Always allow localhost and 127.0.0.1
if let Some(host) = url.host_str() {
if host == "localhost" || host == "127.0.0.1" {
return true;
}
}
// Try to read the server list from the store
let Ok(store) = app.store(GLOBAL_STORAGE) else {
return false;
};
let Some(server_data) = store.get("server") else {
return false;
};
// Parse the server list from the stored JSON
let Some(list) = server_data.get("list").and_then(|v| v.as_array()) else {
return false;
};
// Get the origin of the navigation URL (scheme + host + port)
let url_origin = format!(
"{}://{}{}",
url.scheme(),
url.host_str().unwrap_or(""),
url.port().map(|p| format!(":{}", p)).unwrap_or_default()
);
// Check if any configured server matches the URL's origin
for server in list {
let Some(server_url) = server.as_str() else {
continue;
};
// Parse the server URL to extract its origin
let Ok(parsed) = tauri::Url::parse(server_url) else {
continue;
};
let server_origin = format!(
"{}://{}{}",
parsed.scheme(),
parsed.host_str().unwrap_or(""),
parsed.port().map(|p| format!(":{}", p)).unwrap_or_default()
);
if url_origin == server_origin {
return true;
}
}
false
}
#[tauri::command] #[tauri::command]
fn kill_sidecar(app: AppHandle) { fn kill_sidecar(app: AppHandle) {
@@ -140,7 +80,7 @@ async fn get_logs(app: AppHandle) -> Result<String, String> {
} }
#[tauri::command] #[tauri::command]
async fn ensure_server_started(state: State<'_, ServerState>) -> Result<(), String> { async fn ensure_server_ready(state: State<'_, ServerState>) -> Result<String, String> {
state state
.status .status
.clone() .clone()
@@ -148,6 +88,41 @@ async fn ensure_server_started(state: State<'_, ServerState>) -> Result<(), Stri
.map_err(|_| "Failed to get server status".to_string())? .map_err(|_| "Failed to get server status".to_string())?
} }
#[tauri::command]
fn get_default_server_url(app: AppHandle) -> Result<Option<String>, String> {
let store = app
.store(SETTINGS_STORE)
.map_err(|e| format!("Failed to open settings store: {}", e))?;
let value = store.get(DEFAULT_SERVER_URL_KEY);
match value {
Some(v) => Ok(v.as_str().map(String::from)),
None => Ok(None),
}
}
#[tauri::command]
async fn set_default_server_url(app: AppHandle, url: Option<String>) -> Result<(), String> {
let store = app
.store(SETTINGS_STORE)
.map_err(|e| format!("Failed to open settings store: {}", e))?;
match url {
Some(u) => {
store.set(DEFAULT_SERVER_URL_KEY, serde_json::Value::String(u));
}
None => {
store.delete(DEFAULT_SERVER_URL_KEY);
}
}
store
.save()
.map_err(|e| format!("Failed to save settings: {}", e))?;
Ok(())
}
fn get_sidecar_port() -> u32 { fn get_sidecar_port() -> u32 {
option_env!("OPENCODE_PORT") option_env!("OPENCODE_PORT")
.map(|s| s.to_string()) .map(|s| s.to_string())
@@ -162,49 +137,16 @@ fn get_sidecar_port() -> u32 {
}) as u32 }) as u32
} }
fn get_user_shell() -> String {
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
}
fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild { fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
let log_state = app.state::<LogState>(); let log_state = app.state::<LogState>();
let log_state_clone = log_state.inner().clone(); let log_state_clone = log_state.inner().clone();
let state_dir = app println!("spawning sidecar on port {port}");
.path()
.resolve("", BaseDirectory::AppLocalData)
.expect("Failed to resolve app local data dir");
#[cfg(target_os = "windows")] let (mut rx, child) = cli::create_command(app, format!("serve --port {port}").as_str())
let (mut rx, child) = app
.shell()
.sidecar("opencode-cli")
.unwrap()
.env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
.env("OPENCODE_CLIENT", "desktop")
.env("XDG_STATE_HOME", &state_dir)
.args(["serve", &format!("--port={port}")])
.spawn() .spawn()
.expect("Failed to spawn opencode"); .expect("Failed to spawn opencode");
#[cfg(not(target_os = "windows"))]
let (mut rx, child) = {
let sidecar = get_sidecar_path();
let shell = get_user_shell();
app.shell()
.command(&shell)
.env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
.env("OPENCODE_CLIENT", "desktop")
.env("XDG_STATE_HOME", &state_dir)
.args([
"-il",
"-c",
&format!("\"{}\" serve --port={}", sidecar.display(), port),
])
.spawn()
.expect("Failed to spawn opencode")
};
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
while let Some(event) = rx.recv().await { while let Some(event) = rx.recv().await {
match event { match event {
@@ -242,15 +184,22 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
child child
} }
async fn is_server_running(port: u32) -> bool { async fn check_server_health(url: &str) -> bool {
TcpSocket::new_v4() let health_url = format!("{}/health", url.trim_end_matches('/'));
.unwrap() let client = reqwest::Client::builder()
.connect(SocketAddr::new( .timeout(Duration::from_secs(3))
"127.0.0.1".parse().expect("Failed to parse IP"), .build();
port as u16,
)) let Ok(client) = client else {
return false;
};
client
.get(&health_url)
.send()
.await .await
.is_ok() .map(|r| r.status().is_success())
.unwrap_or(false)
} }
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
@@ -279,7 +228,9 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
kill_sidecar, kill_sidecar,
install_cli, install_cli,
ensure_server_started ensure_server_ready,
get_default_server_url,
set_default_server_url
]) ])
.setup(move |app| { .setup(move |app| {
let app = app.handle().clone(); let app = app.handle().clone();
@@ -287,16 +238,12 @@ pub fn run() {
// Initialize log state // Initialize log state
app.manage(LogState(Arc::new(Mutex::new(VecDeque::new())))); app.manage(LogState(Arc::new(Mutex::new(VecDeque::new()))));
// Get port and create window immediately for faster perceived startup
let port = get_sidecar_port();
let primary_monitor = app.primary_monitor().ok().flatten(); let primary_monitor = app.primary_monitor().ok().flatten();
let size = primary_monitor let size = primary_monitor
.map(|m| m.size().to_logical(m.scale_factor())) .map(|m| m.size().to_logical(m.scale_factor()))
.unwrap_or(LogicalSize::new(1920, 1080)); .unwrap_or(LogicalSize::new(1920, 1080));
// Create window immediately with serverReady = false #[allow(unused_mut)]
let app_for_nav = app.clone();
let mut window_builder = let mut window_builder =
WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into())) WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
.title("OpenCode") .title("OpenCode")
@@ -304,27 +251,10 @@ pub fn run() {
.decorations(true) .decorations(true)
.zoom_hotkeys_enabled(true) .zoom_hotkeys_enabled(true)
.disable_drag_drop_handler() .disable_drag_drop_handler()
.on_navigation(move |url| {
// Allow internal navigation (tauri:// scheme)
if url.scheme() == "tauri" {
return true;
}
// Allow navigation to configured servers (localhost, 127.0.0.1, or remote)
if is_allowed_server(&app_for_nav, url) {
return true;
}
// Open external http/https URLs in default browser
if url.scheme() == "http" || url.scheme() == "https" {
let _ = app_for_nav.shell().open(url.as_str(), None);
return false; // Cancel internal navigation
}
true
})
.initialization_script(format!( .initialization_script(format!(
r#" r#"
window.__OPENCODE__ ??= {{}}; window.__OPENCODE__ ??= {{}};
window.__OPENCODE__.updaterEnabled = {updater_enabled}; window.__OPENCODE__.updaterEnabled = {updater_enabled};
window.__OPENCODE__.port = {port};
"# "#
)); ));
@@ -335,7 +265,7 @@ pub fn run() {
.hidden_title(true); .hidden_title(true);
} }
let window = window_builder.build().expect("Failed to create window"); window_builder.build().expect("Failed to create window");
let (tx, rx) = tokio::sync::oneshot::channel(); let (tx, rx) = tokio::sync::oneshot::channel();
app.manage(ServerState::new(None, rx)); app.manage(ServerState::new(None, rx));
@@ -343,43 +273,29 @@ pub fn run() {
{ {
let app = app.clone(); let app = app.clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
let should_spawn_sidecar = !is_server_running(port).await; let mut custom_url = None;
let (child, res) = if should_spawn_sidecar { if let Some(url) = get_default_server_url(app.clone()).ok().flatten() {
let child = spawn_sidecar(&app, port); println!("Using desktop-specific custom URL: {url}");
custom_url = Some(url);
let timestamp = Instant::now();
let res = loop {
if timestamp.elapsed() > Duration::from_secs(7) {
break Err(format!(
"Failed to spawn OpenCode Server. Logs:\n{}",
get_logs(app.clone()).await.unwrap()
));
}
tokio::time::sleep(Duration::from_millis(10)).await;
if is_server_running(port).await {
// give the server a little bit more time to warm up
tokio::time::sleep(Duration::from_millis(10)).await;
break Ok(());
}
};
println!("Server ready after {:?}", timestamp.elapsed());
(Some(child), res)
} else {
(None, Ok(()))
};
app.state::<ServerState>().set_child(child);
if res.is_ok() {
let _ = window.eval("window.__OPENCODE__.serverReady = true;");
} }
if custom_url.is_none()
&& let Some(cli_config) = cli::get_config(&app).await
&& let Some(url) = get_server_url_from_config(&cli_config)
{
println!("Using custom server URL from config: {url}");
custom_url = Some(url);
}
let res = match setup_server_connection(&app, custom_url).await {
Ok((child, url)) => {
app.state::<ServerState>().set_child(child);
Ok(url)
}
Err(e) => Err(e),
};
let _ = tx.send(res); let _ = tx.send(res);
}); });
} }
@@ -411,3 +327,82 @@ pub fn run() {
} }
}); });
} }
fn get_server_url_from_config(config: &cli::Config) -> Option<String> {
let server = config.server.as_ref()?;
let port = server.port?;
println!("server.port found in OC config: {port}");
let hostname = server.hostname.as_ref();
Some(format!(
"http://{}:{}",
hostname.map(|v| v.as_str()).unwrap_or("127.0.0.1"),
port
))
}
async fn setup_server_connection(
app: &AppHandle,
custom_url: Option<String>,
) -> Result<(Option<CommandChild>, String), String> {
if let Some(url) = custom_url {
loop {
if check_server_health(&url).await {
println!("Connected to custom server: {}", url);
return Ok((None, url.clone()));
}
const RETRY: &str = "Retry";
let res = app.dialog()
.message(format!("Could not connect to configured server:\n{}\n\nWould you like to retry or start a local server instead?", url))
.title("Connection Failed")
.buttons(MessageDialogButtons::OkCancelCustom(RETRY.to_string(), "Start Local".to_string()))
.blocking_show_with_result();
match res {
MessageDialogResult::Custom(name) if name == RETRY => {
continue;
}
_ => {
break;
}
}
}
}
let local_port = get_sidecar_port();
let local_url = format!("http://127.0.0.1:{local_port}");
if !check_server_health(&local_url).await {
match spawn_local_server(app, local_port).await {
Ok(child) => Ok(Some(child)),
Err(err) => Err(err),
}
} else {
Ok(None)
}
.map(|child| (child, local_url))
}
async fn spawn_local_server(app: &AppHandle, port: u32) -> Result<CommandChild, String> {
let child = spawn_sidecar(app, port);
let url = format!("http://127.0.0.1:{port}");
let timestamp = Instant::now();
loop {
if timestamp.elapsed() > Duration::from_secs(7) {
break Err(format!(
"Failed to spawn OpenCode Server. Logs:\n{}",
get_logs(app.clone()).await.unwrap()
));
}
tokio::time::sleep(Duration::from_millis(10)).await;
if check_server_health(&url).await {
println!("Server ready after {:?}", timestamp.elapsed());
break Ok(child);
}
}
}
+1 -1
View File
@@ -26,7 +26,7 @@
"icons/dev/icon.ico" "icons/dev/icon.ico"
], ],
"active": true, "active": true,
"targets": ["deb", "rpm", "dmg", "nsis", "app", "appimage"], "targets": ["deb", "rpm", "dmg", "nsis", "app"],
"externalBin": ["sidecars/opencode-cli"], "externalBin": ["sidecars/opencode-cli"],
"macOS": { "macOS": {
"entitlements": "./entitlements.plist" "entitlements": "./entitlements.plist"
+16 -14
View File
@@ -13,7 +13,7 @@ import { AsyncStorage } from "@solid-primitives/storage"
import { fetch as tauriFetch } from "@tauri-apps/plugin-http" import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
import { Store } from "@tauri-apps/plugin-store" import { Store } from "@tauri-apps/plugin-store"
import { Logo } from "@opencode-ai/ui/logo" import { Logo } from "@opencode-ai/ui/logo"
import { Suspense, createResource, ParentProps } from "solid-js" import { Accessor, JSX, createResource } from "solid-js"
import { UPDATER_ENABLED } from "./updater" import { UPDATER_ENABLED } from "./updater"
import { createMenu } from "./menu" import { createMenu } from "./menu"
@@ -257,6 +257,15 @@ const platform: Platform = {
// @ts-expect-error // @ts-expect-error
fetch: tauriFetch, fetch: tauriFetch,
getDefaultServerUrl: async () => {
const result = await invoke<string | null>("get_default_server_url").catch(() => null)
return result
},
setDefaultServerUrl: async (url: string | null) => {
await invoke("set_default_server_url", { url })
},
} }
createMenu() createMenu()
@@ -273,35 +282,28 @@ render(() => {
<div class="mx-px bg-background-base border-b border-border-weak-base h-8" data-tauri-drag-region /> <div class="mx-px bg-background-base border-b border-border-weak-base h-8" data-tauri-drag-region />
)} )}
<AppBaseProviders> <AppBaseProviders>
<ServerGate> <ServerGate>{(serverUrl) => <AppInterface defaultUrl={serverUrl()} />}</ServerGate>
<AppInterface />
</ServerGate>
</AppBaseProviders> </AppBaseProviders>
</PlatformProvider> </PlatformProvider>
) )
}, root!) }, root!)
// Gate component that waits for the server to be ready // Gate component that waits for the server to be ready
function ServerGate(props: ParentProps) { function ServerGate(props: { children: (url: Accessor<string>) => JSX.Element }) {
const [status] = createResource(async () => { const [serverUrl] = createResource<string>(() => invoke("ensure_server_ready"))
if (window.__OPENCODE__?.serverReady) return
return await invoke("ensure_server_started")
})
return ( return (
// Not using suspense as not all components are compatible with it (undefined refs) // Not using suspense as not all components are compatible with it (undefined refs)
<Show <Show
when={status.state !== "pending"} when={serverUrl.state !== "pending" && serverUrl()}
fallback={ fallback={
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base"> <div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
<Logo class="w-xl opacity-12 animate-pulse" /> <Logo class="w-xl opacity-12 animate-pulse" />
<div class="mt-8 text-14-regular text-text-weak">Starting server...</div> <div class="mt-8 text-14-regular text-text-weak">Initializing...</div>
</div> </div>
} }
> >
{/* Trigger error boundary without rendering the returned value */} {(serverUrl) => props.children(serverUrl)}
{(status(), null)}
{props.children}
</Show> </Show>
) )
} }
+1
View File
@@ -6,6 +6,7 @@ const host = process.env.TAURI_DEV_HOST
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [appPlugin], plugins: [appPlugin],
publicDir: "../app/public",
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
// //
// 1. prevent Vite from obscuring rust errors // 1. prevent Vite from obscuring rust errors
+2 -3
View File
@@ -57,11 +57,10 @@ Example: `mintlify`
Hex color codes for your global theme Hex color codes for your global theme
<Expandable title="Colors"> <Expandable title="Colors">
<ResponseField name="primary" type="string" required> <ResponseField name="primary" type="string" required>
The primary color. Used for most often for highlighted content, section headers, accents, in light mode The primary color. Used most often for highlighted content, section headers, accents, in light mode
</ResponseField> </ResponseField>
<ResponseField name="light" type="string"> <ResponseField name="light" type="string">
The primary color for dark mode. Used for most often for highlighted content, section headers, accents, in dark The primary color for dark mode. Used most often for highlighted content, section headers, accents, in dark mode
mode
</ResponseField> </ResponseField>
<ResponseField name="dark" type="string"> <ResponseField name="dark" type="string">
The primary color for important buttons The primary color for important buttons
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.1.7", "version": "1.1.14",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode" id = "opencode"
name = "OpenCode" name = "OpenCode"
description = "The open source coding agent." description = "The open source coding agent."
version = "1.1.7" version = "1.1.14"
schema_version = 1 schema_version = 1
authors = ["Anomaly"] authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode" repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg" icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64] [agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.7/opencode-darwin-arm64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.14/opencode-darwin-arm64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64] [agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.7/opencode-darwin-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.14/opencode-darwin-x64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64] [agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.7/opencode-linux-arm64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.14/opencode-linux-arm64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64] [agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.7/opencode-linux-x64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.14/opencode-linux-x64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64] [agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.7/opencode-windows-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.14/opencode-windows-x64.zip"
cmd = "./opencode.exe" cmd = "./opencode.exe"
args = ["acp"] args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.1.7", "version": "1.1.14",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.1.7", "version": "1.1.14",
"name": "opencode", "name": "opencode",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
@@ -81,8 +81,8 @@
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.2", "@openrouter/ai-sdk-provider": "1.5.2",
"@opentui/core": "0.1.70", "@opentui/core": "0.1.72",
"@opentui/solid": "0.1.70", "@opentui/solid": "0.1.72",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-bus": "1.1.2",
+26
View File
@@ -30,6 +30,7 @@ import { Todo } from "@/session/todo"
import { z } from "zod" import { z } from "zod"
import { LoadAPIKeyError } from "ai" import { LoadAPIKeyError } from "ai"
import type { OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2" import type { OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2"
import { applyPatch } from "diff"
export namespace ACP { export namespace ACP {
const log = Log.create({ service: "acp-agent" }) const log = Log.create({ service: "acp-agent" })
@@ -105,6 +106,22 @@ export namespace ACP {
}) })
return return
} }
if (res.outcome.optionId !== "reject" && permission.permission == "edit") {
const metadata = permission.metadata || {}
const filepath = typeof metadata["filepath"] === "string" ? metadata["filepath"] : ""
const diff = typeof metadata["diff"] === "string" ? metadata["diff"] : ""
const content = await Bun.file(filepath).text()
const newContent = getNewContent(content, diff)
if (newContent) {
this.connection.writeTextFile({
sessionId: sessionId,
path: filepath,
content: newContent,
})
}
}
await this.config.sdk.permission.reply({ await this.config.sdk.permission.reply({
requestID: permission.id, requestID: permission.id,
reply: res.outcome.optionId as "once" | "always" | "reject", reply: res.outcome.optionId as "once" | "always" | "reject",
@@ -1095,4 +1112,13 @@ export namespace ACP {
} }
} }
} }
function getNewContent(fileOriginal: string, unifiedDiff: string): string | undefined {
const result = applyPatch(fileOriginal, unifiedDiff)
if (result === false) {
log.error("Failed to apply unified diff (context mismatch)")
return undefined
}
return result
}
} }
+3
View File
@@ -3,6 +3,8 @@ import { Global } from "../global"
import fs from "fs/promises" import fs from "fs/promises"
import z from "zod" import z from "zod"
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
export namespace Auth { export namespace Auth {
export const Oauth = z export const Oauth = z
.object({ .object({
@@ -10,6 +12,7 @@ export namespace Auth {
refresh: z.string(), refresh: z.string(),
access: z.string(), access: z.string(),
expires: z.number(), expires: z.number(),
accountId: z.string().optional(),
enterpriseUrl: z.string().optional(), enterpriseUrl: z.string().optional(),
}) })
.meta({ ref: "OAuth" }) .meta({ ref: "OAuth" })
+1 -2
View File
@@ -294,6 +294,7 @@ export const AuthLoginCommand = cmd({
hint: { hint: {
opencode: "recommended", opencode: "recommended",
anthropic: "Claude Max or API key", anthropic: "Claude Max or API key",
openai: "ChatGPT Plus/Pro or API key",
}[x.id], }[x.id],
})), })),
), ),
@@ -341,8 +342,6 @@ export const AuthLoginCommand = cmd({
"Configure via opencode.json options (profile, region, endpoint) or\n" + "Configure via opencode.json options (profile, region, endpoint) or\n" +
"AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID).", "AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID).",
) )
prompts.outro("Done")
return
} }
if (provider === "opencode") { if (provider === "opencode") {
+186 -37
View File
@@ -7,29 +7,36 @@ import { bootstrap } from "../bootstrap"
import { Command } from "../../command" import { Command } from "../../command"
import { EOL } from "os" import { EOL } from "os"
import { select } from "@clack/prompts" import { select } from "@clack/prompts"
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2" import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
import { Server } from "../../server/server" import { Server } from "../../server/server"
import { Provider } from "../../provider/provider" import { Provider } from "../../provider/provider"
import { Agent } from "../../agent/agent" import { Agent } from "../../agent/agent"
import { resolveNetworkOptions, withNetworkOptions } from "../network"
import { Locale } from "@/util/locale"
const TOOL: Record<string, [string, string]> = { const TOOL_ICON: Record<string, string> = {
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD], bash: "$",
todoread: ["Todo", UI.Style.TEXT_WARNING_BOLD], codesearch: "◇",
bash: ["Bash", UI.Style.TEXT_DANGER_BOLD], edit: "←",
edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD], glob: "✱",
glob: ["Glob", UI.Style.TEXT_INFO_BOLD], grep: "✱",
grep: ["Grep", UI.Style.TEXT_INFO_BOLD], list: "→",
list: ["List", UI.Style.TEXT_INFO_BOLD], patch: "%",
read: ["Read", UI.Style.TEXT_HIGHLIGHT_BOLD], question: "→",
write: ["Write", UI.Style.TEXT_SUCCESS_BOLD], read: "→",
websearch: ["Search", UI.Style.TEXT_DIM_BOLD], task: "◉",
todoread: "⚙",
todowrite: "⚙",
webfetch: "%",
websearch: "◈",
write: "←",
} }
export const RunCommand = cmd({ export const RunCommand = cmd({
command: "run [message..]", command: "run [message..]",
describe: "run opencode with a message", describe: "run opencode with a message",
builder: (yargs: Argv) => { builder: (yargs: Argv) => {
return yargs return withNetworkOptions(yargs)
.positional("message", { .positional("message", {
describe: "message to send", describe: "message to send",
type: "string", type: "string",
@@ -83,10 +90,6 @@ export const RunCommand = cmd({
type: "string", type: "string",
describe: "attach to a running opencode server (e.g., http://localhost:4096)", describe: "attach to a running opencode server (e.g., http://localhost:4096)",
}) })
.option("port", {
type: "number",
describe: "port for the local server (defaults to random port if no value provided)",
})
.option("variant", { .option("variant", {
type: "string", type: "string",
describe: "model variant (provider-specific reasoning effort, e.g., high, max, minimal)", describe: "model variant (provider-specific reasoning effort, e.g., high, max, minimal)",
@@ -134,16 +137,144 @@ export const RunCommand = cmd({
} }
const execute = async (sdk: OpencodeClient, sessionID: string) => { const execute = async (sdk: OpencodeClient, sessionID: string) => {
const printEvent = (color: string, type: string, title: string) => { const normalizePath = (input?: string) => {
UI.println( if (!input) return ""
color + `|`, if (path.isAbsolute(input)) return path.relative(process.cwd(), input) || "."
UI.Style.TEXT_NORMAL + UI.Style.TEXT_DIM + ` ${type.padEnd(7, " ")}`, return input
"",
UI.Style.TEXT_NORMAL + title,
)
} }
const outputJsonEvent = (type: string, data: any) => { const formatInput = (input: Record<string, unknown>, omit?: string[]) => {
const entries = Object.entries(input).filter(([key, value]) => {
if (omit?.includes(key)) return false
if (typeof value === "string") return true
if (typeof value === "number") return true
if (typeof value === "boolean") return true
return false
})
if (entries.length === 0) return ""
return `[${entries.map(([key, value]) => `${key}=${value}`).join(", ")}]`
}
const toolLine = (part: ToolPart) => {
const state = part.state.status === "completed" ? part.state : undefined
const input = (state?.input ?? {}) as Record<string, unknown>
const meta = (state?.metadata ?? {}) as Record<string, unknown>
if (part.tool === "read") {
const filePath = typeof input.filePath === "string" ? input.filePath : ""
const detail = formatInput(input, ["filePath"])
if (!detail) return `Read ${normalizePath(filePath)}`
return `Read ${normalizePath(filePath)} ${detail}`
}
if (part.tool === "write") {
const filePath = typeof input.filePath === "string" ? input.filePath : ""
return `Write ${normalizePath(filePath)}`
}
if (part.tool === "edit") {
const filePath = typeof input.filePath === "string" ? input.filePath : ""
const detail = formatInput({ replaceAll: input.replaceAll })
if (!detail) return `Edit ${normalizePath(filePath)}`
return `Edit ${normalizePath(filePath)} ${detail}`
}
if (part.tool === "glob") {
const pattern = typeof input.pattern === "string" ? input.pattern : ""
const dir = typeof input.path === "string" ? normalizePath(input.path) : ""
const count = typeof meta.count === "number" ? meta.count : undefined
const parts = [`Glob "${pattern}"`]
if (dir) parts.push(`in ${dir}`)
if (count !== undefined) parts.push(`(${count} matches)`)
return parts.join(" ")
}
if (part.tool === "grep") {
const pattern = typeof input.pattern === "string" ? input.pattern : ""
const dir = typeof input.path === "string" ? normalizePath(input.path) : ""
const matches = typeof meta.matches === "number" ? meta.matches : undefined
const parts = [`Grep "${pattern}"`]
if (dir) parts.push(`in ${dir}`)
if (matches !== undefined) parts.push(`(${matches} matches)`)
return parts.join(" ")
}
if (part.tool === "list") {
const dir = typeof input.path === "string" ? normalizePath(input.path) : ""
if (!dir) return "List"
return `List ${dir}`
}
if (part.tool === "webfetch") {
const url = typeof input.url === "string" ? input.url : ""
if (!url) return "WebFetch"
return `WebFetch ${url}`
}
if (part.tool === "codesearch") {
const query = typeof input.query === "string" ? input.query : ""
const results = typeof meta.results === "number" ? meta.results : undefined
const parts = [`Exa Code Search "${query}"`]
if (results !== undefined) parts.push(`(${results} results)`)
return parts.join(" ")
}
if (part.tool === "websearch") {
const query = typeof input.query === "string" ? input.query : ""
const results = typeof meta.numResults === "number" ? meta.numResults : undefined
const parts = [`Exa Web Search "${query}"`]
if (results !== undefined) parts.push(`(${results} results)`)
return parts.join(" ")
}
if (part.tool === "task") {
const desc = typeof input.description === "string" ? input.description : "Task"
const agent = typeof input.subagent_type === "string" ? input.subagent_type : "Task"
return `${agent} Task "${desc}"`
}
if (part.tool === "todowrite" || part.tool === "todoread") {
const count = Array.isArray(input.todos) ? input.todos.length : 0
if (count) return `Todos (${count})`
return "Todos"
}
if (part.tool === "question") {
const count = Array.isArray(input.questions) ? input.questions.length : 0
return `Asked ${count} question${count === 1 ? "" : "s"}`
}
if (part.tool === "patch") {
return "Patch"
}
const detail = formatInput(input)
if (!detail) return part.tool
return `${part.tool} ${detail}`
}
const printTool = (part: ToolPart) => {
if (part.tool === "bash") {
const state = part.state.status === "completed" ? part.state : undefined
if (!state) return
UI.empty()
const input = (state.input ?? {}) as Record<string, unknown>
const meta = (state.metadata ?? {}) as Record<string, unknown>
const desc = typeof input.description === "string" ? input.description : undefined
const title = desc ?? state.title ?? "Shell"
UI.println(UI.Style.TEXT_DIM + "# " + title)
const command = typeof input.command === "string" ? input.command : ""
if (command) UI.println(UI.Style.TEXT_NORMAL + "$ " + command)
const output = typeof state.output === "string" ? state.output.trimEnd() : undefined
const metaOutput = typeof meta.output === "string" ? meta.output.trimEnd() : undefined
const result = output ?? metaOutput
if (result) UI.println(UI.Style.TEXT_NORMAL + result)
UI.empty()
return
}
const icon = TOOL_ICON[part.tool] ?? "⚙"
const line = toolLine(part)
UI.println(UI.Style.TEXT_NORMAL + icon, UI.Style.TEXT_NORMAL + line)
}
const printUserMessage = () => {
if (args.format === "json") return
const trimmed = message.trim()
if (!trimmed) return
const single = trimmed.replace(/\s+/g, " ")
UI.println(UI.Style.TEXT_NORMAL_BOLD + "▌", UI.Style.TEXT_NORMAL + single)
UI.empty()
userPrinted = true
printHeader()
}
const outputJsonEvent = (type: string, data: Record<string, unknown>) => {
if (args.format === "json") { if (args.format === "json") {
process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL) process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL)
return true return true
@@ -152,6 +283,19 @@ export const RunCommand = cmd({
} }
const events = await sdk.event.subscribe() const events = await sdk.event.subscribe()
let header: { agent: string; modelID: string } | undefined
let headerPrinted = false
let userPrinted = false
const printHeader = () => {
if (!process.stdout.isTTY) return
if (!header || headerPrinted) return
UI.empty()
UI.println(
UI.Style.TEXT_NORMAL + "▣ " + Locale.titlecase(header.agent) + UI.Style.TEXT_DIM + " · " + header.modelID,
)
UI.empty()
headerPrinted = true
}
let errorMsg: string | undefined let errorMsg: string | undefined
const eventProcessor = (async () => { const eventProcessor = (async () => {
@@ -162,15 +306,7 @@ export const RunCommand = cmd({
if (part.type === "tool" && part.state.status === "completed") { if (part.type === "tool" && part.state.status === "completed") {
if (outputJsonEvent("tool_use", { part })) continue if (outputJsonEvent("tool_use", { part })) continue
const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD] printTool(part as ToolPart)
const title =
part.state.title ||
(Object.keys(part.state.input).length > 0 ? JSON.stringify(part.state.input) : "Unknown")
printEvent(color, tool, title)
if (part.tool === "bash" && part.state.output?.trim()) {
UI.println()
UI.println(part.state.output)
}
} }
if (part.type === "step-start") { if (part.type === "step-start") {
@@ -184,9 +320,19 @@ export const RunCommand = cmd({
if (part.type === "text" && part.time?.end) { if (part.type === "text" && part.time?.end) {
if (outputJsonEvent("text", { part })) continue if (outputJsonEvent("text", { part })) continue
const isPiped = !process.stdout.isTTY const isPiped = !process.stdout.isTTY
if (!isPiped) UI.println() if (!isPiped) UI.empty()
if (!isPiped) UI.empty()
process.stdout.write((isPiped ? part.text : UI.markdown(part.text)) + EOL) process.stdout.write((isPiped ? part.text : UI.markdown(part.text)) + EOL)
if (!isPiped) UI.println() if (!isPiped) UI.empty()
if (!isPiped) UI.empty()
}
}
if (event.type === "message.updated") {
const info = event.properties.info
if (info.sessionID === sessionID && info.role === "assistant") {
header = { agent: info.agent, modelID: info.modelID }
if (userPrinted) printHeader()
} }
} }
@@ -251,6 +397,8 @@ export const RunCommand = cmd({
return args.agent return args.agent
})() })()
printUserMessage()
if (args.command) { if (args.command) {
await sdk.session.command({ await sdk.session.command({
sessionID, sessionID,
@@ -339,7 +487,8 @@ export const RunCommand = cmd({
} }
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
const server = Server.listen({ port: args.port ?? 0, hostname: "127.0.0.1" }) const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts)
const sdk = createOpencodeClient({ baseUrl: `http://${server.hostname}:${server.port}` }) const sdk = createOpencodeClient({ baseUrl: `http://${server.hostname}:${server.port}` })
if (args.command) { if (args.command) {
+16 -11
View File
@@ -97,7 +97,16 @@ async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
}) })
} }
export function tui(input: { url: string; args: Args; directory?: string; onExit?: () => Promise<void> }) { import type { EventSource } from "./context/sdk"
export function tui(input: {
url: string
args: Args
directory?: string
fetch?: typeof fetch
events?: EventSource
onExit?: () => Promise<void>
}) {
// promise to prevent immediate exit // promise to prevent immediate exit
return new Promise<void>(async (resolve) => { return new Promise<void>(async (resolve) => {
const mode = await getTerminalBackgroundColor() const mode = await getTerminalBackgroundColor()
@@ -117,7 +126,12 @@ export function tui(input: { url: string; args: Args; directory?: string; onExit
<KVProvider> <KVProvider>
<ToastProvider> <ToastProvider>
<RouteProvider> <RouteProvider>
<SDKProvider url={input.url} directory={input.directory}> <SDKProvider
url={input.url}
directory={input.directory}
fetch={input.fetch}
events={input.events}
>
<SyncProvider> <SyncProvider>
<ThemeProvider mode={mode}> <ThemeProvider mode={mode}>
<LocalProvider> <LocalProvider>
@@ -592,15 +606,6 @@ function App() {
}) })
}) })
sdk.event.on(Installation.Event.Updated.type, (evt) => {
toast.show({
variant: "success",
title: "Update Complete",
message: `OpenCode updated to v${evt.properties.version}`,
duration: 5000,
})
})
sdk.event.on(Installation.Event.UpdateAvailable.type, (evt) => { sdk.event.on(Installation.Event.UpdateAvailable.type, (evt) => {
toast.show({ toast.show({
variant: "info", variant: "info",
@@ -33,76 +33,82 @@ export function DialogModel(props: { providerID?: string }) {
const options = createMemo(() => { const options = createMemo(() => {
const q = query() const q = query()
const favorites = showExtra() ? local.model.favorite() : [] const needle = q.trim()
const showSections = showExtra() && needle.length === 0
const favorites = connected() ? local.model.favorite() : []
const recents = local.model.recent() const recents = local.model.recent()
const recentList = showExtra() const recentList = showSections
? recents.filter( ? recents.filter(
(item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID), (item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID),
) )
: [] : []
const favoriteOptions = favorites.flatMap((item) => { const favoriteOptions = showSections
const provider = sync.data.provider.find((x) => x.id === item.providerID) ? favorites.flatMap((item) => {
if (!provider) return [] const provider = sync.data.provider.find((x) => x.id === item.providerID)
const model = provider.models[item.modelID] if (!provider) return []
if (!model) return [] const model = provider.models[item.modelID]
return [ if (!model) return []
{ return [
key: item, {
value: { key: item,
providerID: provider.id, value: {
modelID: model.id,
},
title: model.name ?? item.modelID,
description: provider.name,
category: "Favorites",
disabled: provider.id === "opencode" && model.id.includes("-nano"),
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect: () => {
dialog.clear()
local.model.set(
{
providerID: provider.id, providerID: provider.id,
modelID: model.id, modelID: model.id,
}, },
{ recent: true }, title: model.name ?? item.modelID,
) description: provider.name,
}, category: "Favorites",
}, disabled: provider.id === "opencode" && model.id.includes("-nano"),
] footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
}) onSelect: () => {
dialog.clear()
local.model.set(
{
providerID: provider.id,
modelID: model.id,
},
{ recent: true },
)
},
},
]
})
: []
const recentOptions = recentList.flatMap((item) => { const recentOptions = showSections
const provider = sync.data.provider.find((x) => x.id === item.providerID) ? recentList.flatMap((item) => {
if (!provider) return [] const provider = sync.data.provider.find((x) => x.id === item.providerID)
const model = provider.models[item.modelID] if (!provider) return []
if (!model) return [] const model = provider.models[item.modelID]
return [ if (!model) return []
{ return [
key: item, {
value: { key: item,
providerID: provider.id, value: {
modelID: model.id,
},
title: model.name ?? item.modelID,
description: provider.name,
category: "Recent",
disabled: provider.id === "opencode" && model.id.includes("-nano"),
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect: () => {
dialog.clear()
local.model.set(
{
providerID: provider.id, providerID: provider.id,
modelID: model.id, modelID: model.id,
}, },
{ recent: true }, title: model.name ?? item.modelID,
) description: provider.name,
}, category: "Recent",
}, disabled: provider.id === "opencode" && model.id.includes("-nano"),
] footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
}) onSelect: () => {
dialog.clear()
local.model.set(
{
providerID: provider.id,
modelID: model.id,
},
{ recent: true },
)
},
},
]
})
: []
const providerOptions = pipe( const providerOptions = pipe(
sync.data.provider, sync.data.provider,
@@ -145,6 +151,7 @@ export function DialogModel(props: { providerID?: string }) {
} }
}), }),
filter((x) => { filter((x) => {
if (!showSections) return true
const value = x.value const value = x.value
const inFavorites = favorites.some( const inFavorites = favorites.some(
(item) => item.providerID === value.providerID && item.modelID === value.modelID, (item) => item.providerID === value.providerID && item.modelID === value.modelID,
@@ -177,16 +184,11 @@ export function DialogModel(props: { providerID?: string }) {
) )
: [] : []
// Apply fuzzy filtering to each section separately, maintaining section order // Search shows a single merged list (favorites inline)
if (q) { if (needle) {
const filteredFavorites = fuzzysort.go(q, favoriteOptions, { keys: ["title"] }).map((x) => x.obj) const filteredProviders = fuzzysort.go(needle, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj)
const filteredRecents = fuzzysort const filteredPopular = fuzzysort.go(needle, popularProviders, { keys: ["title"] }).map((x) => x.obj)
.go(q, recentOptions, { keys: ["title"] }) return [...filteredProviders, ...filteredPopular]
.map((x) => x.obj)
.slice(0, 5)
const filteredProviders = fuzzysort.go(q, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj)
const filteredPopular = fuzzysort.go(q, popularProviders, { keys: ["title"] }).map((x) => x.obj)
return [...filteredFavorites, ...filteredRecents, ...filteredProviders, ...filteredPopular]
} }
return [...favoriteOptions, ...recentOptions, ...providerOptions, ...popularProviders] return [...favoriteOptions, ...recentOptions, ...providerOptions, ...popularProviders]
@@ -36,6 +36,7 @@ export function createDialogProviderOptions() {
description: { description: {
opencode: "(Recommended)", opencode: "(Recommended)",
anthropic: "(Claude Max or API key)", anthropic: "(Claude Max or API key)",
openai: "(ChatGPT Plus/Pro or API key)",
}[provider.id], }[provider.id],
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other", category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other",
async onSelect() { async onSelect() {
@@ -111,8 +112,7 @@ function AutoMethod(props: AutoMethodProps) {
useKeyboard((evt) => { useKeyboard((evt) => {
if (evt.name === "c" && !evt.ctrl && !evt.meta) { if (evt.name === "c" && !evt.ctrl && !evt.meta) {
const code = const code = props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4}/)?.[0] ?? props.authorization.url
props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4}/)?.[0] ?? props.authorization.instructions
Clipboard.copy(code) Clipboard.copy(code)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) .then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error) .catch(toast.error)
@@ -1,85 +0,0 @@
import { createMemo, createSignal, For } from "solid-js"
import { useTheme } from "@tui/context/theme"
import { useKeybind } from "@tui/context/keybind"
import { TIPS } from "./tips"
import { EmptyBorder } from "./border"
type TipPart = { text: string; highlight: boolean }
function parseTip(tip: string): TipPart[] {
const parts: TipPart[] = []
const regex = /\{highlight\}(.*?)\{\/highlight\}/g
let lastIndex = 0
let match
while ((match = regex.exec(tip)) !== null) {
if (match.index > lastIndex) {
parts.push({ text: tip.slice(lastIndex, match.index), highlight: false })
}
parts.push({ text: match[1], highlight: true })
lastIndex = regex.lastIndex
}
if (lastIndex < tip.length) {
parts.push({ text: tip.slice(lastIndex), highlight: false })
}
return parts
}
const [tipIndex, setTipIndex] = createSignal(Math.floor(Math.random() * TIPS.length))
export function randomizeTip() {
setTipIndex(Math.floor(Math.random() * TIPS.length))
}
const BOX_WIDTH = 42
const TITLE = " 🅘 Did you know? "
export function DidYouKnow() {
const { theme } = useTheme()
const keybind = useKeybind()
const tipParts = createMemo(() => parseTip(TIPS[tipIndex()]))
const dashes = createMemo(() => {
// ╭─ + title + ─...─ + ╮ = BOX_WIDTH
// 1 + 1 + title.length + dashes + 1 = BOX_WIDTH
return Math.max(0, BOX_WIDTH - 2 - TITLE.length - 1)
})
return (
<box position="absolute" bottom={3} right={2} width={BOX_WIDTH}>
<text>
<span style={{ fg: theme.border }}></span>
<span style={{ fg: theme.text }}>{TITLE}</span>
<span style={{ fg: theme.border }}>{"─".repeat(dashes())}</span>
</text>
<box
border={["left", "right", "bottom"]}
borderColor={theme.border}
customBorderChars={{
...EmptyBorder,
bottomLeft: "╰",
bottomRight: "╯",
horizontal: "─",
vertical: "│",
}}
>
<box paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
<text>
<For each={tipParts()}>
{(part) => <span style={{ fg: part.highlight ? theme.text : theme.textMuted }}>{part.text}</span>}
</For>
</text>
</box>
</box>
<box flexDirection="row" justifyContent="flex-end">
<text>
<span style={{ fg: theme.text }}>{keybind.print("tips_toggle")}</span>
<span style={{ fg: theme.textMuted }}> hide tips</span>
</text>
</box>
</box>
)
}
@@ -1,7 +1,7 @@
import type { BoxRenderable, TextareaRenderable, KeyEvent, ScrollBoxRenderable } from "@opentui/core" import type { BoxRenderable, TextareaRenderable, KeyEvent, ScrollBoxRenderable } from "@opentui/core"
import fuzzysort from "fuzzysort" import fuzzysort from "fuzzysort"
import { firstBy } from "remeda" import { firstBy } from "remeda"
import { createMemo, createResource, createEffect, onMount, onCleanup, For, Show, createSignal } from "solid-js" import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useSDK } from "@tui/context/sdk" import { useSDK } from "@tui/context/sdk"
import { useSync } from "@tui/context/sync" import { useSync } from "@tui/context/sync"
@@ -686,7 +686,7 @@ export function Autocomplete(props: {
height={height()} height={height()}
scrollbarOptions={{ visible: false }} scrollbarOptions={{ visible: false }}
> >
<For <Index
each={options()} each={options()}
fallback={ fallback={
<box paddingLeft={1} paddingRight={1}> <box paddingLeft={1} paddingRight={1}>
@@ -698,20 +698,22 @@ export function Autocomplete(props: {
<box <box
paddingLeft={1} paddingLeft={1}
paddingRight={1} paddingRight={1}
backgroundColor={index() === store.selected ? theme.primary : undefined} backgroundColor={index === store.selected ? theme.primary : undefined}
flexDirection="row" flexDirection="row"
onMouseOver={() => moveTo(index)}
onMouseUp={() => select()}
> >
<text fg={index() === store.selected ? selectedForeground(theme) : theme.text} flexShrink={0}> <text fg={index === store.selected ? selectedForeground(theme) : theme.text} flexShrink={0}>
{option.display} {option().display}
</text> </text>
<Show when={option.description}> <Show when={option().description}>
<text fg={index() === store.selected ? selectedForeground(theme) : theme.textMuted} wrapMode="none"> <text fg={index === store.selected ? selectedForeground(theme) : theme.textMuted} wrapMode="none">
{option.description} {option().description}
</text> </text>
</Show> </Show>
</box> </box>
)} )}
</For> </Index>
</scrollbox> </scrollbox>
</box> </box>
) )
@@ -89,7 +89,7 @@ export function Prompt(props: PromptProps) {
const fileStyleId = syntax().getStyleId("extmark.file")! const fileStyleId = syntax().getStyleId("extmark.file")!
const agentStyleId = syntax().getStyleId("extmark.agent")! const agentStyleId = syntax().getStyleId("extmark.agent")!
const pasteStyleId = syntax().getStyleId("extmark.paste")! const pasteStyleId = syntax().getStyleId("extmark.paste")!
let promptPartTypeId: number let promptPartTypeId = 0
sdk.event.on(TuiEvent.PromptAppend.type, (evt) => { sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
input.insertText(evt.properties.text) input.insertText(evt.properties.text)
@@ -310,15 +310,44 @@ export function Prompt(props: PromptProps) {
] ]
}) })
const ref: PromptRef = {
get focused() {
return input.focused
},
get current() {
return store.prompt
},
focus() {
input.focus()
},
blur() {
input.blur()
},
set(prompt) {
input.setText(prompt.input)
setStore("prompt", prompt)
restoreExtmarksFromParts(prompt.parts)
input.gotoBufferEnd()
},
reset() {
input.clear()
input.extmarks.clear()
setStore("prompt", {
input: "",
parts: [],
})
setStore("extmarkToPartIndex", new Map())
},
submit() {
submit()
},
}
createEffect(() => { createEffect(() => {
if (props.visible !== false) input?.focus() if (props.visible !== false) input?.focus()
if (props.visible === false) input?.blur() if (props.visible === false) input?.blur()
}) })
onMount(() => {
promptPartTypeId = input.extmarks.registerType("prompt-part")
})
function restoreExtmarksFromParts(parts: PromptInfo["parts"]) { function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
input.extmarks.clear() input.extmarks.clear()
setStore("extmarkToPartIndex", new Map()) setStore("extmarkToPartIndex", new Map())
@@ -452,39 +481,6 @@ export function Prompt(props: PromptProps) {
}, },
]) ])
props.ref?.({
get focused() {
return input.focused
},
get current() {
return store.prompt
},
focus() {
input.focus()
},
blur() {
input.blur()
},
set(prompt) {
input.setText(prompt.input)
setStore("prompt", prompt)
restoreExtmarksFromParts(prompt.parts)
input.gotoBufferEnd()
},
reset() {
input.clear()
input.extmarks.clear()
setStore("prompt", {
input: "",
parts: [],
})
setStore("extmarkToPartIndex", new Map())
},
submit() {
submit()
},
})
async function submit() { async function submit() {
if (props.disabled) return if (props.disabled) return
if (autocomplete?.visible) return if (autocomplete?.visible) return
@@ -916,12 +912,15 @@ export function Prompt(props: PromptProps) {
// Force layout update and render for the pasted content // Force layout update and render for the pasted content
setTimeout(() => { setTimeout(() => {
input.getLayoutNode().markDirty() input.getLayoutNode().markDirty()
input.gotoBufferEnd()
renderer.requestRender() renderer.requestRender()
}, 0) }, 0)
}} }}
ref={(r: TextareaRenderable) => { ref={(r: TextareaRenderable) => {
input = r input = r
if (promptPartTypeId === 0) {
promptPartTypeId = input.extmarks.registerType("prompt-part")
}
props.ref?.(ref)
setTimeout(() => { setTimeout(() => {
input.cursorColor = theme.text input.cursorColor = theme.text
}, 0) }, 0)
@@ -1,103 +1,150 @@
export const TIPS = [ import { createMemo, createSignal, For } from "solid-js"
"Type {highlight}@{/highlight} followed by a filename to fuzzy search and attach files to your prompt.", import { useTheme } from "@tui/context/theme"
"Start a message with {highlight}!{/highlight} to run shell commands directly (e.g., {highlight}!ls -la{/highlight}).",
"Press {highlight}Tab{/highlight} to cycle between Build (full access) and Plan (read-only) agents.", type TipPart = { text: string; highlight: boolean }
"Use {highlight}/undo{/highlight} to revert the last message and any file changes made by OpenCode.",
"Use {highlight}/redo{/highlight} to restore previously undone messages and file changes.", function parse(tip: string): TipPart[] {
"Run {highlight}/share{/highlight} to create a public link to your conversation at opencode.ai.", const parts: TipPart[] = []
"Drag and drop images into the terminal to add them as context for your prompts.", const regex = /\{highlight\}(.*?)\{\/highlight\}/g
"Press {highlight}Ctrl+V{/highlight} to paste images from your clipboard directly into the prompt.", const found = Array.from(tip.matchAll(regex))
"Press {highlight}Ctrl+X E{/highlight} or {highlight}/editor{/highlight} to compose messages in your external editor.", const state = found.reduce(
"Run {highlight}/init{/highlight} to auto-generate project rules based on your codebase structure.", (acc, match) => {
"Run {highlight}/models{/highlight} or {highlight}Ctrl+X M{/highlight} to see and switch between available AI models.", const start = match.index ?? 0
"Use {highlight}/theme{/highlight} or {highlight}Ctrl+X T{/highlight} to preview and switch between 50+ built-in themes.", if (start > acc.index) {
"Press {highlight}Ctrl+X N{/highlight} or {highlight}/new{/highlight} to start a fresh conversation session.", acc.parts.push({ text: tip.slice(acc.index, start), highlight: false })
"Use {highlight}/sessions{/highlight} or {highlight}Ctrl+X L{/highlight} to list and continue previous conversations.", }
"Run {highlight}/compact{/highlight} to summarize long sessions when approaching context limits.", acc.parts.push({ text: match[1], highlight: true })
"Press {highlight}Ctrl+X X{/highlight} or {highlight}/export{/highlight} to save the conversation as Markdown.", acc.index = start + match[0].length
"Press {highlight}Ctrl+X Y{/highlight} to copy the assistant's last message to clipboard.", return acc
"Press {highlight}Ctrl+P{/highlight} to see all available actions and commands.", },
"Run {highlight}/connect{/highlight} to add API keys for 75+ supported LLM providers.", { parts, index: 0 },
"The default leader key is {highlight}Ctrl+X{/highlight}; combine with other keys for quick actions.", )
"Press {highlight}F2{/highlight} to quickly switch between recently used models.",
"Press {highlight}Ctrl+X B{/highlight} to show/hide the sidebar panel.", if (state.index < tip.length) {
"Use {highlight}PageUp{/highlight}/{highlight}PageDown{/highlight} to navigate through conversation history.", parts.push({ text: tip.slice(state.index), highlight: false })
"Press {highlight}Ctrl+G{/highlight} or {highlight}Home{/highlight} to jump to the beginning of the conversation.", }
"Press {highlight}Ctrl+Alt+G{/highlight} or {highlight}End{/highlight} to jump to the most recent message.",
"Press {highlight}Shift+Enter{/highlight} or {highlight}Ctrl+J{/highlight} to add newlines in your prompt.", return parts
"Press {highlight}Ctrl+C{/highlight} when typing to clear the input field.", }
"Press {highlight}Escape{/highlight} to stop the AI mid-response.",
"Switch to {highlight}Plan{/highlight} agent to get suggestions without making actual changes.", export function Tips() {
"Use {highlight}@<agent-name>{/highlight} in prompts to invoke specialized subagents.", const theme = useTheme().theme
"Press {highlight}Ctrl+X Right/Left{/highlight} to cycle through parent and child sessions.", const parts = parse(TIPS[Math.floor(Math.random() * TIPS.length)])
"Create {highlight}opencode.json{/highlight} in project root for project-specific settings.",
"Place settings in {highlight}~/.config/opencode/opencode.json{/highlight} for global config.", return (
"Add {highlight}$schema{/highlight} to your config for autocomplete in your editor.", <box flexDirection="row" maxWidth="100%">
"Configure {highlight}model{/highlight} in config to set your default model.", <text flexShrink={0} style={{ fg: theme.warning }}>
"Override any keybind in config via the {highlight}keybinds{/highlight} section.", Tip{" "}
"Set any keybind to {highlight}none{/highlight} to disable it completely.", </text>
"Configure local or remote MCP servers in the {highlight}mcp{/highlight} config section.", <text flexShrink={1}>
"OpenCode auto-handles OAuth for remote MCP servers requiring auth.", <For each={parts}>
"Add {highlight}.md{/highlight} files to {highlight}.opencode/command/{/highlight} to define reusable custom prompts.", {(part) => <span style={{ fg: part.highlight ? theme.text : theme.textMuted }}>{part.text}</span>}
"Use {highlight}$ARGUMENTS{/highlight}, {highlight}$1{/highlight}, {highlight}$2{/highlight} in custom commands for dynamic input.", </For>
"Use backticks in commands to inject shell output (e.g., {highlight}`git status`{/highlight}).", </text>
"Add {highlight}.md{/highlight} files to {highlight}.opencode/agent/{/highlight} for specialized AI personas.", </box>
"Configure per-agent permissions for {highlight}edit{/highlight}, {highlight}bash{/highlight}, and {highlight}webfetch{/highlight} tools.", )
'Use patterns like {highlight}"git *": "allow"{/highlight} for granular bash permissions.', }
'Set {highlight}"rm -rf *": "deny"{/highlight} to block destructive commands.',
'Configure {highlight}"git push": "ask"{/highlight} to require approval before pushing.', const TIPS = [
"OpenCode auto-formats files using prettier, gofmt, ruff, and more.", "Type {highlight}@{/highlight} followed by a filename to fuzzy search and attach files",
'Set {highlight}"formatter": false{/highlight} in config to disable all auto-formatting.', "Start a message with {highlight}!{/highlight} to run shell commands directly (e.g., {highlight}!ls -la{/highlight})",
"Define custom formatter commands with file extensions in config.", "Press {highlight}Tab{/highlight} to cycle between Build and Plan agents",
"OpenCode uses LSP servers for intelligent code analysis.", "Use {highlight}/undo{/highlight} to revert the last message and file changes",
"Create {highlight}.ts{/highlight} files in {highlight}.opencode/tool/{/highlight} to define new LLM tools.", "Use {highlight}/redo{/highlight} to restore previously undone messages and file changes",
"Tool definitions can invoke scripts written in Python, Go, etc.", "Run {highlight}/share{/highlight} to create a public link to your conversation at opencode.ai",
"Add {highlight}.ts{/highlight} files to {highlight}.opencode/plugin/{/highlight} for event hooks.", "Drag and drop images into the terminal to add them as context",
"Use plugins to send OS notifications when sessions complete.", "Press {highlight}Ctrl+V{/highlight} to paste images from your clipboard into the prompt",
"Create a plugin to prevent OpenCode from reading sensitive files.", "Press {highlight}Ctrl+X E{/highlight} or {highlight}/editor{/highlight} to compose messages in your external editor",
"Use {highlight}opencode run{/highlight} for non-interactive scripting.", "Run {highlight}/init{/highlight} to auto-generate project rules based on your codebase",
"Use {highlight}opencode run --continue{/highlight} to resume the last session.", "Run {highlight}/models{/highlight} or {highlight}Ctrl+X M{/highlight} to see and switch between available AI models",
"Use {highlight}opencode run -f file.ts{/highlight} to attach files via CLI.", "Use {highlight}/theme{/highlight} or {highlight}Ctrl+X T{/highlight} to switch between 50+ built-in themes",
"Use {highlight}--format json{/highlight} for machine-readable output in scripts.", "Press {highlight}Ctrl+X N{/highlight} or {highlight}/new{/highlight} to start a fresh conversation session",
"Run {highlight}opencode serve{/highlight} for headless API access to OpenCode.", "Use {highlight}/sessions{/highlight} or {highlight}Ctrl+X L{/highlight} to list and continue previous conversations",
"Use {highlight}opencode run --attach{/highlight} to connect to a running server for faster runs.", "Run {highlight}/compact{/highlight} to summarize long sessions near context limits",
"Run {highlight}opencode upgrade{/highlight} to update to the latest version.", "Press {highlight}Ctrl+X X{/highlight} or {highlight}/export{/highlight} to save the conversation as Markdown",
"Run {highlight}opencode auth list{/highlight} to see all configured providers.", "Press {highlight}Ctrl+X Y{/highlight} to copy the assistant's last message to clipboard",
"Run {highlight}opencode agent create{/highlight} for guided agent creation.", "Press {highlight}Ctrl+P{/highlight} to see all available actions and commands",
"Use {highlight}/opencode{/highlight} in GitHub issues/PRs to trigger AI actions.", "Run {highlight}/connect{/highlight} to add API keys for 75+ supported LLM providers",
"Run {highlight}opencode github install{/highlight} to set up the GitHub workflow.", "The leader key is {highlight}Ctrl+X{/highlight}; combine with other keys for quick actions",
"Comment {highlight}/opencode fix this{/highlight} on issues to auto-create PRs.", "Press {highlight}F2{/highlight} to quickly switch between recently used models",
"Comment {highlight}/oc{/highlight} on PR code lines for targeted code reviews.", "Press {highlight}Ctrl+X B{/highlight} to show/hide the sidebar panel",
'Use {highlight}"theme": "system"{/highlight} to match your terminal\'s colors.', "Use {highlight}PageUp{/highlight}/{highlight}PageDown{/highlight} to navigate through conversation history",
"Create JSON theme files in {highlight}.opencode/themes/{/highlight} directory.", "Press {highlight}Ctrl+G{/highlight} or {highlight}Home{/highlight} to jump to the beginning of the conversation",
"Themes support dark/light variants for both modes.", "Press {highlight}Ctrl+Alt+G{/highlight} or {highlight}End{/highlight} to jump to the most recent message",
"Reference ANSI colors 0-255 in custom themes.", "Press {highlight}Shift+Enter{/highlight} or {highlight}Ctrl+J{/highlight} to add newlines in your prompt",
"Use {highlight}{env:VAR_NAME}{/highlight} syntax to reference environment variables in config.", "Press {highlight}Ctrl+C{/highlight} when typing to clear the input field",
"Use {highlight}{file:path}{/highlight} to include file contents in config values.", "Press {highlight}Escape{/highlight} to stop the AI mid-response",
"Use {highlight}instructions{/highlight} in config to load additional rules files.", "Switch to {highlight}Plan{/highlight} agent to get suggestions without making actual changes",
"Set agent {highlight}temperature{/highlight} from 0.0 (focused) to 1.0 (creative).", "Use {highlight}@agent-name{/highlight} in prompts to invoke specialized subagents",
"Configure {highlight}maxSteps{/highlight} to limit agentic iterations per request.", "Press {highlight}Ctrl+X Right/Left{/highlight} to cycle through parent and child sessions",
'Set {highlight}"tools": {"bash": false}{/highlight} to disable specific tools.', "Create {highlight}opencode.json{/highlight} in project root for project-specific settings",
'Use {highlight}"mcp_*": false{/highlight} to disable all tools from an MCP server.', "Place settings in {highlight}~/.config/opencode/opencode.json{/highlight} for global config",
"Override global tool settings per agent configuration.", "Add {highlight}$schema{/highlight} to your config for autocomplete in your editor",
'Set {highlight}"share": "auto"{/highlight} to automatically share all sessions.', "Configure {highlight}model{/highlight} in config to set your default model",
'Set {highlight}"share": "disabled"{/highlight} to prevent any session sharing.', "Override any keybind in config via the {highlight}keybinds{/highlight} section",
"Run {highlight}/unshare{/highlight} to remove a session from public access.", "Set any keybind to {highlight}none{/highlight} to disable it completely",
"Permission {highlight}doom_loop{/highlight} prevents infinite tool call loops.", "Configure local or remote MCP servers in the {highlight}mcp{/highlight} config section",
"Permission {highlight}external_directory{/highlight} protects files outside project.", "OpenCode auto-handles OAuth for remote MCP servers requiring auth",
"Run {highlight}opencode debug config{/highlight} to troubleshoot configuration.", "Add {highlight}.md{/highlight} files to {highlight}.opencode/command/{/highlight} to define reusable custom prompts",
"Use {highlight}--print-logs{/highlight} flag to see detailed logs in stderr.", "Use {highlight}$ARGUMENTS{/highlight}, {highlight}$1{/highlight}, {highlight}$2{/highlight} in custom commands for dynamic input",
"Press {highlight}Ctrl+X G{/highlight} or {highlight}/timeline{/highlight} to jump to specific messages.", "Use backticks in commands to inject shell output (e.g., {highlight}`git status`{/highlight})",
"Press {highlight}Ctrl+X H{/highlight} to toggle code block visibility in messages.", "Add {highlight}.md{/highlight} files to {highlight}.opencode/agent/{/highlight} for specialized AI personas",
"Press {highlight}Ctrl+X S{/highlight} or {highlight}/status{/highlight} to see system status info.", "Configure per-agent permissions for {highlight}edit{/highlight}, {highlight}bash{/highlight}, and {highlight}webfetch{/highlight} tools",
"Enable {highlight}tui.scroll_acceleration{/highlight} for smooth macOS-style scrolling.", 'Use patterns like {highlight}"git *": "allow"{/highlight} for granular bash permissions',
"Toggle username display in chat via command palette ({highlight}Ctrl+P{/highlight}).", 'Set {highlight}"rm -rf *": "deny"{/highlight} to block destructive commands',
"Run {highlight}docker run -it --rm ghcr.io/anomalyco/opencode{/highlight} for containerized use.", 'Configure {highlight}"git push": "ask"{/highlight} to require approval before pushing',
"Use {highlight}/connect{/highlight} with OpenCode Zen for curated, tested models.", "OpenCode auto-formats files using prettier, gofmt, ruff, and more",
"Commit your project's {highlight}AGENTS.md{/highlight} file to Git for team sharing.", 'Set {highlight}"formatter": false{/highlight} in config to disable all auto-formatting',
"Use {highlight}/review{/highlight} to review uncommitted changes, branches, or PRs.", "Define custom formatter commands with file extensions in config",
"Run {highlight}/help{/highlight} or {highlight}Ctrl+X H{/highlight} to show the help dialog.", "OpenCode uses LSP servers for intelligent code analysis",
"Use {highlight}/details{/highlight} to toggle tool execution details visibility.", "Create {highlight}.ts{/highlight} files in {highlight}.opencode/tool/{/highlight} to define new LLM tools",
"Use {highlight}/rename{/highlight} to rename the current session.", "Tool definitions can invoke scripts written in Python, Go, etc",
"Press {highlight}Ctrl+Z{/highlight} to suspend the terminal and return to your shell.", "Add {highlight}.ts{/highlight} files to {highlight}.opencode/plugin/{/highlight} for event hooks",
"Use plugins to send OS notifications when sessions complete",
"Create a plugin to prevent OpenCode from reading sensitive files",
"Use {highlight}opencode run{/highlight} for non-interactive scripting",
"Use {highlight}opencode run --continue{/highlight} to resume the last session",
"Use {highlight}opencode run -f file.ts{/highlight} to attach files via CLI",
"Use {highlight}--format json{/highlight} for machine-readable output in scripts",
"Run {highlight}opencode serve{/highlight} for headless API access to OpenCode",
"Use {highlight}opencode run --attach{/highlight} to connect to a running server",
"Run {highlight}opencode upgrade{/highlight} to update to the latest version",
"Run {highlight}opencode auth list{/highlight} to see all configured providers",
"Run {highlight}opencode agent create{/highlight} for guided agent creation",
"Use {highlight}/opencode{/highlight} in GitHub issues/PRs to trigger AI actions",
"Run {highlight}opencode github install{/highlight} to set up the GitHub workflow",
"Comment {highlight}/opencode fix this{/highlight} on issues to auto-create PRs",
"Comment {highlight}/oc{/highlight} on PR code lines for targeted code reviews",
'Use {highlight}"theme": "system"{/highlight} to match your terminal\'s colors',
"Create JSON theme files in {highlight}.opencode/themes/{/highlight} directory",
"Themes support dark/light variants for both modes",
"Reference ANSI colors 0-255 in custom themes",
"Use {highlight}{env:VAR_NAME}{/highlight} syntax to reference environment variables in config",
"Use {highlight}{file:path}{/highlight} to include file contents in config values",
"Use {highlight}instructions{/highlight} in config to load additional rules files",
"Set agent {highlight}temperature{/highlight} from 0.0 (focused) to 1.0 (creative)",
"Configure {highlight}maxSteps{/highlight} to limit agentic iterations per request",
'Set {highlight}"tools": {"bash": false}{/highlight} to disable specific tools',
'Set {highlight}"mcp_*": false{/highlight} to disable all tools from an MCP server',
"Override global tool settings per agent configuration",
'Set {highlight}"share": "auto"{/highlight} to automatically share all sessions',
'Set {highlight}"share": "disabled"{/highlight} to prevent any session sharing',
"Run {highlight}/unshare{/highlight} to remove a session from public access",
"Permission {highlight}doom_loop{/highlight} prevents infinite tool call loops",
"Permission {highlight}external_directory{/highlight} protects files outside project",
"Run {highlight}opencode debug config{/highlight} to troubleshoot configuration",
"Use {highlight}--print-logs{/highlight} flag to see detailed logs in stderr",
"Press {highlight}Ctrl+X G{/highlight} or {highlight}/timeline{/highlight} to jump to specific messages",
"Press {highlight}Ctrl+X H{/highlight} to toggle code block visibility in messages",
"Press {highlight}Ctrl+X S{/highlight} or {highlight}/status{/highlight} to see system status info",
"Enable {highlight}tui.scroll_acceleration{/highlight} for smooth macOS-style scrolling",
"Toggle username display in chat via command palette ({highlight}Ctrl+P{/highlight})",
"Run {highlight}docker run -it --rm ghcr.io/sst/opencode{/highlight} for containerized use",
"Use {highlight}/connect{/highlight} with OpenCode Zen for curated, tested models",
"Commit your project's {highlight}AGENTS.md{/highlight} file to Git for team sharing",
"Use {highlight}/review{/highlight} to review uncommitted changes, branches, or PRs",
"Run {highlight}/help{/highlight} or {highlight}Ctrl+X H{/highlight} to show the help dialog",
"Use {highlight}/details{/highlight} to toggle tool execution details visibility",
"Use {highlight}/rename{/highlight} to rename the current session",
"Press {highlight}Ctrl+Z{/highlight} to suspend the terminal and return to your shell",
] ]
@@ -8,13 +8,13 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
name: "KV", name: "KV",
init: () => { init: () => {
const [ready, setReady] = createSignal(false) const [ready, setReady] = createSignal(false)
const [kvStore, setKvStore] = createStore<Record<string, any>>() const [store, setStore] = createStore<Record<string, any>>()
const file = Bun.file(path.join(Global.Path.state, "kv.json")) const file = Bun.file(path.join(Global.Path.state, "kv.json"))
file file
.json() .json()
.then((x) => { .then((x) => {
setKvStore(x) setStore(x)
}) })
.catch(() => {}) .catch(() => {})
.finally(() => { .finally(() => {
@@ -25,8 +25,11 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
get ready() { get ready() {
return ready() return ready()
}, },
get store() {
return store
},
signal<T>(name: string, defaultValue: T) { signal<T>(name: string, defaultValue: T) {
if (kvStore[name] === undefined) setKvStore(name, defaultValue) if (store[name] === undefined) setStore(name, defaultValue)
return [ return [
function () { function () {
return result.get(name) return result.get(name)
@@ -37,11 +40,11 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
] as const ] as const
}, },
get(key: string, defaultValue?: any) { get(key: string, defaultValue?: any) {
return kvStore[key] ?? defaultValue return store[key] ?? defaultValue
}, },
set(key: string, value: any) { set(key: string, value: any) {
setKvStore(key, value) setStore(key, value)
Bun.write(file, JSON.stringify(kvStore, null, 2)) Bun.write(file, JSON.stringify(store, null, 2))
}, },
} }
return result return result
@@ -3,21 +3,66 @@ import { createSimpleContext } from "./helper"
import { createGlobalEmitter } from "@solid-primitives/event-bus" import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { batch, onCleanup, onMount } from "solid-js" import { batch, onCleanup, onMount } from "solid-js"
export type EventSource = {
on: (handler: (event: Event) => void) => () => void
}
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK", name: "SDK",
init: (props: { url: string; directory?: string }) => { init: (props: { url: string; directory?: string; fetch?: typeof fetch; events?: EventSource }) => {
const abort = new AbortController() const abort = new AbortController()
const sdk = createOpencodeClient({ const sdk = createOpencodeClient({
baseUrl: props.url, baseUrl: props.url,
signal: abort.signal, signal: abort.signal,
directory: props.directory, directory: props.directory,
fetch: props.fetch,
}) })
const emitter = createGlobalEmitter<{ const emitter = createGlobalEmitter<{
[key in Event["type"]]: Extract<Event, { type: key }> [key in Event["type"]]: Extract<Event, { type: key }>
}>() }>()
let queue: Event[] = []
let timer: Timer | undefined
let last = 0
const flush = () => {
if (queue.length === 0) return
const events = queue
queue = []
timer = undefined
last = Date.now()
// Batch all event emissions so all store updates result in a single render
batch(() => {
for (const event of events) {
emitter.emit(event.type, event)
}
})
}
const handleEvent = (event: Event) => {
queue.push(event)
const elapsed = Date.now() - last
if (timer) return
// If we just flushed recently (within 16ms), batch this with future events
// Otherwise, process immediately to avoid latency
if (elapsed < 16) {
timer = setTimeout(flush, 16)
return
}
flush()
}
onMount(async () => { onMount(async () => {
// If an event source is provided, use it instead of SSE
if (props.events) {
const unsub = props.events.on(handleEvent)
onCleanup(unsub)
return
}
// Fall back to SSE
while (true) { while (true) {
if (abort.signal.aborted) break if (abort.signal.aborted) break
const events = await sdk.event.subscribe( const events = await sdk.event.subscribe(
@@ -26,36 +71,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
signal: abort.signal, signal: abort.signal,
}, },
) )
let queue: Event[] = []
let timer: Timer | undefined
let last = 0
const flush = () => {
if (queue.length === 0) return
const events = queue
queue = []
timer = undefined
last = Date.now()
// Batch all event emissions so all store updates result in a single render
batch(() => {
for (const event of events) {
emitter.emit(event.type, event)
}
})
}
for await (const event of events.stream) { for await (const event of events.stream) {
queue.push(event) handleEvent(event)
const elapsed = Date.now() - last
if (timer) continue
// If we just flushed recently (within 16ms), batch this with future events
// Otherwise, process immediately to avoid latency
if (elapsed < 16) {
timer = setTimeout(flush, 16)
continue
}
flush()
} }
// Flush any remaining events // Flush any remaining events
@@ -68,6 +86,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
onCleanup(() => { onCleanup(() => {
abort.abort() abort.abort()
if (timer) clearTimeout(timer)
}) })
return { client: sdk, event: emitter, url: props.url } return { client: sdk, event: emitter, url: props.url }
@@ -1,8 +1,9 @@
import { Prompt, type PromptRef } from "@tui/component/prompt" import { Prompt, type PromptRef } from "@tui/component/prompt"
import { createMemo, Match, onMount, Show, Switch } from "solid-js" import { createMemo, Match, onMount, Show, Switch } from "solid-js"
import { useTheme } from "@tui/context/theme" import { useTheme } from "@tui/context/theme"
import { useKeybind } from "@tui/context/keybind"
import { Logo } from "../component/logo" import { Logo } from "../component/logo"
import { DidYouKnow, randomizeTip } from "../component/did-you-know" import { Tips } from "../component/tips"
import { Locale } from "@/util/locale" import { Locale } from "@/util/locale"
import { useSync } from "../context/sync" import { useSync } from "../context/sync"
import { Toast } from "../ui/toast" import { Toast } from "../ui/toast"
@@ -36,7 +37,6 @@ export function Home() {
const isFirstTimeUser = createMemo(() => sync.data.session.length === 0) const isFirstTimeUser = createMemo(() => sync.data.session.length === 0)
const tipsHidden = createMemo(() => kv.get("tips_hidden", false)) const tipsHidden = createMemo(() => kv.get("tips_hidden", false))
const showTips = createMemo(() => { const showTips = createMemo(() => {
return false
// Don't show tips for first-time users // Don't show tips for first-time users
if (isFirstTimeUser()) return false if (isFirstTimeUser()) return false
return !tipsHidden() return !tipsHidden()
@@ -77,7 +77,6 @@ export function Home() {
let prompt: PromptRef let prompt: PromptRef
const args = useArgs() const args = useArgs()
onMount(() => { onMount(() => {
randomizeTip()
if (once) return if (once) return
if (route.initialPrompt) { if (route.initialPrompt) {
prompt.set(route.initialPrompt) prompt.set(route.initialPrompt)
@@ -90,6 +89,8 @@ export function Home() {
}) })
const directory = useDirectory() const directory = useDirectory()
const keybind = useKeybind()
return ( return (
<> <>
<box flexGrow={1} justifyContent="center" alignItems="center" paddingLeft={2} paddingRight={2} gap={1}> <box flexGrow={1} justifyContent="center" alignItems="center" paddingLeft={2} paddingRight={2} gap={1}>
@@ -103,13 +104,13 @@ export function Home() {
hint={Hint} hint={Hint}
/> />
</box> </box>
<Show when={showTips()}>
<box width="100%" maxWidth={75} paddingTop={2} alignItems="center">
<Tips />
</box>
</Show>
<Toast /> <Toast />
</box> </box>
<Show when={!isFirstTimeUser()}>
<Show when={showTips()}>
<DidYouKnow />
</Show>
</Show>
<box paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={2} flexDirection="row" flexShrink={0} gap={2}> <box paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={2} flexDirection="row" flexShrink={0} gap={2}>
<text fg={theme.textMuted}>{directory()}</text> <text fg={theme.textMuted}>{directory()}</text>
<box gap={1} flexDirection="row" flexShrink={0}> <box gap={1} flexDirection="row" flexShrink={0}>
@@ -1,10 +1,11 @@
import { type Accessor, createMemo, Match, Show, Switch } from "solid-js" import { type Accessor, createMemo, createSignal, Match, Show, Switch } from "solid-js"
import { useRouteData } from "@tui/context/route" import { useRouteData } from "@tui/context/route"
import { useSync } from "@tui/context/sync" import { useSync } from "@tui/context/sync"
import { pipe, sumBy } from "remeda" import { pipe, sumBy } from "remeda"
import { useTheme } from "@tui/context/theme" import { useTheme } from "@tui/context/theme"
import { SplitBorder } from "@tui/component/border" import { SplitBorder } from "@tui/component/border"
import type { AssistantMessage, Session } from "@opencode-ai/sdk/v2" import type { AssistantMessage, Session } from "@opencode-ai/sdk/v2"
import { useCommandDialog } from "@tui/component/dialog-command"
import { useKeybind } from "../../context/keybind" import { useKeybind } from "../../context/keybind"
const Title = (props: { session: Accessor<Session> }) => { const Title = (props: { session: Accessor<Session> }) => {
@@ -59,6 +60,8 @@ export function Header() {
const { theme } = useTheme() const { theme } = useTheme()
const keybind = useKeybind() const keybind = useKeybind()
const command = useCommandDialog()
const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null)
return ( return (
<box flexShrink={0}> <box flexShrink={0}>
@@ -79,15 +82,36 @@ export function Header() {
<text fg={theme.text}> <text fg={theme.text}>
<b>Subagent session</b> <b>Subagent session</b>
</text> </text>
<text fg={theme.text}> <box
Parent <span style={{ fg: theme.textMuted }}>{keybind.print("session_parent")}</span> onMouseOver={() => setHover("parent")}
</text> onMouseOut={() => setHover(null)}
<text fg={theme.text}> onMouseUp={() => command.trigger("session.parent")}
Prev <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle_reverse")}</span> backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel}
</text> >
<text fg={theme.text}> <text fg={theme.text}>
Next <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle")}</span> Parent <span style={{ fg: theme.textMuted }}>{keybind.print("session_parent")}</span>
</text> </text>
</box>
<box
onMouseOver={() => setHover("prev")}
onMouseOut={() => setHover(null)}
onMouseUp={() => command.trigger("session.child.previous")}
backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel}
>
<text fg={theme.text}>
Prev <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle_reverse")}</span>
</text>
</box>
<box
onMouseOver={() => setHover("next")}
onMouseOut={() => setHover(null)}
onMouseUp={() => command.trigger("session.child.next")}
backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel}
>
<text fg={theme.text}>
Next <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle")}</span>
</text>
</box>
<box flexGrow={1} flexShrink={1} /> <box flexGrow={1} flexShrink={1} />
<ContextInfo context={context} cost={cost} /> <ContextInfo context={context} cost={cost} />
</box> </box>

Some files were not shown because too many files have changed in this diff Show More