Compare commits

..
Author SHA1 Message Date
opencode f9af9fc221 release: v1.0.21 2025-11-04 17:47:34 +00:00
Sebastian Herrlinger 1bf1b93404 Revert "upgrade opentui to address disappearing content issues #3776, #3697"
This reverts commit 90fc3ddb02.
2025-11-04 18:38:44 +01:00
Dax Raad bc6f4aed2b local web 2025-11-04 12:33:14 -05:00
Aiden Cline 2af3f19397 respect: disable_paste_summary 2025-11-04 11:29:12 -06:00
Aiden Cline 9275665868 fix: /undo command 2025-11-04 10:59:34 -06:00
09bb819064 fix(tui): worker path resolution in dev mode (#3778)
Signed-off-by: Christian Stewart <christian@cjs.zip>
Co-authored-by: Sebastian Herrlinger <hasta84@gmail.com>
2025-11-04 17:38:11 +01:00
6f0028644e fix: support scoped npm plugins (#3785)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-11-04 09:15:01 -06:00
Pranshu RajandGitHub aec44abcf6 [FIX]: Refocus prompt after session delete (#3882) 2025-11-04 08:57:59 -06:00
frankdierolfandGitHub b41e573886 fix: correct history_next keybinding description (#3891) 2025-11-04 08:52:49 -06:00
arc-source-coderandGitHub 737ddab300 tui: make /mcp an alias for /status (#3894) 2025-11-04 08:52:28 -06:00
Sebastian Herrlinger 90fc3ddb02 upgrade opentui to address disappearing content issues #3776, #3697 2025-11-04 14:57:15 +01:00
Adam 15d7eebb92 fix: lander specs 2025-11-04 06:15:12 -06:00
GitHub Action 33301c94df ignore: update download stats 2025-11-04 2025-11-04 12:05:02 +00:00
Aiden Cline d341d26e37 update brew handling 2025-11-04 00:52:26 -06:00
opencode d49b1b25d1 release: v1.0.20 2025-11-04 05:56:24 +00:00
Dax Raad 25eb100210 tui: fix tool permission lookup to use correct session ID 2025-11-04 00:50:12 -05:00
Dax Raad 9886353715 fix: persist -m model when switching agents
Add initial model from command line to fallback chain so it persists
when switching agents with tab, matching behavior of config model.

Resolves #3863
2025-11-03 23:51:52 -05:00
Aiden Cline f501501791 fix: piping 2025-11-03 22:36:10 -06:00
Dax Raad c103052f93 fix: handle parsePatch errors in TUI to prevent crashes
Wrap parsePatch calls in try-catch blocks to gracefully handle malformed
diffs that can occur when undoing after tool_use/tool_result errors or
cancelled prompts. Prevents TUI from crashing with 'Added line count did not
match for hunk' error.

Fixes #3700
2025-11-03 23:36:04 -05:00
Greg PstruchaandGitHub 68039d4c71 Fix file tagging in multi line inputs (#3865) 2025-11-03 23:22:38 -05:00
Aiden Clineandopencode d3566d3b1a ignore: delete unused code 2025-11-04 04:01:22 +00:00
b275e18d28 fix: Provide OPENCODE & AGENT env vars (#3843)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-11-04 04:01:22 +00:00
Dax Raadandopencode af9a1797b5 tui: use keybind helper for history navigation to respect custom keybindings 2025-11-04 04:01:22 +00:00
opencode 29b3e40ddb release: v1.0.19 2025-11-04 04:01:22 +00:00
Dax Raad c49f5939a2 tui: fix model selection for models with nested paths
Users can now select models with multiple slashes like 'openrouter/google/gemini-2.5-pro'
in the TUI. Previously the TUI would only parse the first two parts of the model
path, showing 'Invalid model openrouter/google' for nested models.
2025-11-03 19:52:43 -05:00
kaanmertkocandGitHub 63862b1609 feat: implement stats command (#3832) 2025-11-03 18:41:30 -06:00
Mikhail WahibandGitHub 1cf1e88b52 fix: print the modified keybind for command_list (#3859) 2025-11-03 18:29:23 -06:00
Jay V d06afd87e5 ignore: lander 2025-11-03 18:17:32 -05:00
Frank 9fb6e81007 wip: zen 2025-11-03 17:30:18 -05:00
Adi YeroslavandGitHub 3ac82227f1 fix: update logo (#3833) 2025-11-03 16:18:38 -06:00
Aiden Cline c1f9249c84 ci: auto label web 2025-11-03 16:17:53 -06:00
Dax Raad 9bb66946db fix: correct dirs parameter type in file search 2025-11-03 17:10:31 -05:00
Dax Raad adcdbbddc7 tui: remove duplicate copy message command entry 2025-11-03 17:10:20 -05:00
Dax Raadandopencode 662435c5bb ci: stuff 2025-11-03 22:01:30 +00:00
opencode 36c1a05eaa release: v1.0.18 2025-11-03 22:01:30 +00:00
Dax Raad 5708e3bf1e ci: tweak 2025-11-03 16:56:41 -05:00
Dax Raad 0da1ed3fc8 tui: add copy last assistant message to session menu 2025-11-03 16:47:18 -05:00
Adam d5179c8b63 wip: desktop work 2025-11-03 15:42:39 -06:00
Adam bd0a4f7bbe wip: desktop work 2025-11-03 15:42:10 -06:00
Adam 3d43214075 wip: desktop work 2025-11-03 15:42:10 -06:00
Dax Raad 178a14ce3e fix dirs query param 2025-11-03 16:35:55 -05:00
Timo ClasenandGitHub 8e1010dc3f feat(TUI): don't show /share hint if sharing is disabled (#3835) 2025-11-03 15:30:55 -06:00
Adi YeroslavandGitHub 9c82f1f5e9 fix: session rename functionality (#3840) 2025-11-03 15:26:30 -06:00
e5a651eef7 fix: better mcp sanitization (#3842)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-11-03 15:19:49 -06:00
Dax Raad d26605aa56 tui: add support for Ctrl+_ key combination in keybind parser 2025-11-03 16:16:09 -05:00
opencode 5cc0d337b1 release: v1.0.17 2025-11-03 21:14:52 +00:00
Dax Raad 902763b47d web command 2025-11-03 16:10:23 -05:00
Aiden ClineandGitHub 55d07a139c fix: mcp error (#3847) 2025-11-03 15:04:53 -06:00
Frank 05232ead93 zen: wip 2025-11-03 15:44:06 -05:00
Tyler GannonandGitHub 7652a96064 fix: wait for stdout to flush in generate command (#3821) 2025-11-03 14:05:48 -06:00
Frank 901aae09f7 zen: filter out alpha models 2025-11-03 15:04:59 -05:00
opencode f95799f17c release: v1.0.16 2025-11-03 17:08:32 +00:00
Dax Raad 99a6c5e44d regen sdk 2025-11-03 11:55:19 -05:00
Dax Raad 07bb75f086 core: add optional dirs parameter to file search API
Allow users to exclude directories from file search results by setting dirs=false parameter in /find/file endpoint
2025-11-03 11:53:41 -05:00
Frank 66eb846e6f zen: wip 2025-11-03 11:30:53 -05:00
Adam 34f11c699e wip: desktop work 2025-11-03 08:29:13 -06:00
Adam 7a32fec008 wip: desktop work 2025-11-03 08:29:13 -06:00
James AlexanderandGitHub 37a6b5177e Add unit tests for util functions: iife, lazy, timeout (#3791) 2025-11-03 09:24:45 -05:00
86 changed files with 6082 additions and 1456 deletions
+24 -2
View File
@@ -11,7 +11,7 @@ jobs:
contents: read
issues: write
steps:
- name: Add opentui label
- name: Auto-label and assign issues
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -20,10 +20,32 @@ jobs:
const title = issue.title;
const description = issue.body || '';
// Check for web/desktop keywords
const webPattern = /\b(web|desktop)\b/i;
const isWebRelated = webPattern.test(title) || webPattern.test(description);
// Check for version patterns like v1.0.x or 1.0.x
const versionPattern = /[v]?1\.0\./i;
const isVersionRelated = versionPattern.test(title) || versionPattern.test(description);
if (versionPattern.test(title) || versionPattern.test(description)) {
if (isWebRelated) {
// Add web label
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: ['web']
});
// Assign to adamdotdevin
await github.rest.issues.addAssignees({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
assignees: ['adamdotdevin']
});
} else if (isVersionRelated) {
// Only add opentui if NOT web-related
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
+2 -6
View File
@@ -11,9 +11,5 @@ playground
tmp
dist
.turbo
# Test suite artifacts
opencode/.bun/
opencode/.local/
opencode/.cache/
opencode/node_modules
**/.serena
.serena/
+1 -9
View File
@@ -129,12 +129,4 @@
| 2025-11-01 | 636,100 (+9,488) | 581,806 (+17,227) | 1,217,906 (+26,715) |
| 2025-11-02 | 644,067 (+7,967) | 590,004 (+8,198) | 1,234,071 (+16,165) |
| 2025-11-03 | 653,130 (+9,063) | 597,139 (+7,135) | 1,250,269 (+16,198) |
| 2025-11-04 | 663,907 (+10,777) | 608,056 (+10,917) | 1,271,963 (+21,694) |
| 2025-11-05 | 675,059 (+11,152) | 619,690 (+11,634) | 1,294,749 (+22,786) |
| 2025-11-06 | 686,249 (+11,190) | 630,885 (+11,195) | 1,317,134 (+22,385) |
| 2025-11-07 | 696,626 (+10,377) | 642,146 (+11,261) | 1,338,772 (+21,638) |
| 2025-11-08 | 706,032 (+9,406) | 653,489 (+11,343) | 1,359,521 (+20,749) |
| 2025-11-09 | 713,462 (+7,430) | 660,459 (+6,970) | 1,373,921 (+14,400) |
| 2025-11-10 | 722,280 (+8,818) | 668,225 (+7,766) | 1,390,505 (+16,584) |
| 2025-11-11 | 729,769 (+7,489) | 677,501 (+9,276) | 1,407,270 (+16,765) |
| 2025-11-12 | 740,168 (+10,399) | 686,454 (+8,953) | 1,426,622 (+19,352) |
| 2025-11-04 | 663,912 (+10,782) | 608,056 (+10,917) | 1,271,968 (+21,699) |
+11 -11
View File
@@ -39,7 +39,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.0.15",
"version": "1.0.21",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -66,7 +66,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.0.15",
"version": "1.0.21",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -90,7 +90,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.0.15",
"version": "1.0.21",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -111,7 +111,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.0.15",
"version": "1.0.21",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -150,7 +150,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.0.15",
"version": "1.0.21",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "22.0.0",
@@ -166,7 +166,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.0.15",
"version": "1.0.21",
"bin": {
"opencode": "./bin/opencode",
},
@@ -243,7 +243,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.0.15",
"version": "1.0.21",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -263,7 +263,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.0.15",
"version": "1.0.21",
"devDependencies": {
"@hey-api/openapi-ts": "0.81.0",
"@tsconfig/node22": "catalog:",
@@ -274,7 +274,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.0.15",
"version": "1.0.21",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -287,7 +287,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.0.15",
"version": "1.0.21",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -317,7 +317,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.0.15",
"version": "1.0.21",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
+11 -3
View File
@@ -61,7 +61,13 @@ export const auth = new sst.cloudflare.Worker("AuthApi", {
domain: `auth.${domain}`,
handler: "packages/console/function/src/auth.ts",
url: true,
link: [database, authStorage, GITHUB_CLIENT_ID_CONSOLE, GITHUB_CLIENT_SECRET_CONSOLE, GOOGLE_CLIENT_ID],
link: [
database,
authStorage,
GITHUB_CLIENT_ID_CONSOLE,
GITHUB_CLIENT_SECRET_CONSOLE,
GOOGLE_CLIENT_ID,
],
})
////////////////
@@ -97,7 +103,8 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint",
],
})
const ZEN_MODELS = new sst.Secret("ZEN_MODELS")
const ZEN_MODELS1 = new sst.Secret("ZEN_MODELS1")
const ZEN_MODELS2 = new sst.Secret("ZEN_MODELS2")
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
properties: { value: auth.url.apply((url) => url!) },
@@ -130,7 +137,8 @@ new sst.cloudflare.x.SolidStart("Console", {
AUTH_API_URL,
STRIPE_WEBHOOK_SECRET,
STRIPE_SECRET_KEY,
ZEN_MODELS,
ZEN_MODELS1,
ZEN_MODELS2,
EMAILOCTOPUS_API_KEY,
AWS_SES_ACCESS_KEY_ID,
AWS_SES_SECRET_ACCESS_KEY,
+1 -1
View File
@@ -7,7 +7,7 @@
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
"build": "vinxi build && ../../opencode/script/schema.ts ./.output/public/config.json",
"start": "vinxi start",
"version": "1.0.15"
"version": "1.0.21"
},
"dependencies": {
"@ibm/plex": "6.4.1",
@@ -1,6 +1,7 @@
import { createAsync } from "@solidjs/router"
import { createMemo } from "solid-js"
import { github } from "~/lib/github"
import { config } from "~/config"
export function Footer() {
const githubData = createAsync(() => github())
@@ -10,13 +11,13 @@ export function Footer() {
notation: "compact",
compactDisplay: "short",
}).format(githubData()!.stars!)
: "25K",
: config.github.starsFormatted.compact,
)
return (
<footer data-component="footer">
<div data-slot="cell">
<a href="https://github.com/sst/opencode" target="_blank">
<a href={config.github.repoUrl} target="_blank">
GitHub <span>[{starCount()}]</span>
</a>
</div>
@@ -27,7 +28,7 @@ export function Footer() {
<a href="/discord">Discord</a>
</div>
<div data-slot="cell">
<a href="https://x.com/opencode">X</a>
<a href={config.social.twitter}>X</a>
</div>
</footer>
)
@@ -18,6 +18,7 @@ import { createMemo, Match, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { github } from "~/lib/github"
import { createEffect, onCleanup } from "solid-js"
import { config } from "~/config"
import "./header-context-menu.css"
const isDarkMode = () => window.matchMedia("(prefers-color-scheme: dark)").matches
@@ -42,7 +43,7 @@ export function Header(props: { zen?: boolean }) {
notation: "compact",
compactDisplay: "short",
}).format(githubData()?.stars!)
: "29K",
: config.github.starsFormatted.compact,
)
const [store, setStore] = createStore({
@@ -148,7 +149,7 @@ export function Header(props: { zen?: boolean }) {
<nav data-component="nav-desktop">
<ul>
<li>
<a href="https://github.com/sst/opencode" target="_blank">
<a href={config.github.repoUrl} target="_blank">
GitHub <span>[{starCount()}]</span>
</a>
</li>
@@ -222,7 +223,7 @@ export function Header(props: { zen?: boolean }) {
<A href="/">Home</A>
</li>
<li>
<a href="https://github.com/sst/opencode" target="_blank">
<a href={config.github.repoUrl} target="_blank">
GitHub <span>[{starCount()}]</span>
</a>
</li>
+26
View File
@@ -0,0 +1,26 @@
/**
* Application-wide constants and configuration
*/
export const config = {
// GitHub
github: {
repoUrl: "https://github.com/sst/opencode",
starsFormatted: {
compact: "30K",
full: "30,000",
},
},
// Social links
social: {
twitter: "https://x.com/opencode",
discord: "https://discord.gg/opencode",
},
// Static stats (used on landing page)
stats: {
contributors: "250",
commits: "3,500",
monthlyUsers: "300,000",
},
} as const
+8 -3
View File
@@ -1,4 +1,5 @@
import { query } from "@solidjs/router"
import { config } from "~/config"
export const github = query(async () => {
"use server"
@@ -6,11 +7,15 @@ export const github = query(async () => {
"User-Agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36",
}
const apiBaseUrl = config.github.repoUrl.replace(
"https://github.com/",
"https://api.github.com/repos/",
)
try {
const [meta, releases, contributors] = await Promise.all([
fetch("https://api.github.com/repos/sst/opencode", { headers }).then((res) => res.json()),
fetch("https://api.github.com/repos/sst/opencode/releases", { headers }).then((res) => res.json()),
fetch("https://api.github.com/repos/sst/opencode/contributors?per_page=1", { headers }),
fetch(apiBaseUrl, { headers }).then((res) => res.json()),
fetch(`${apiBaseUrl}/releases`, { headers }).then((res) => res.json()),
fetch(`${apiBaseUrl}/contributors?per_page=1`, { headers }),
])
const [release] = releases
const contributorCount = Number.parseInt(
File diff suppressed because it is too large Load Diff
+7 -5
View File
@@ -79,17 +79,19 @@ export default function Home() {
<strong>LSP enabled</strong> Automatically loads the right LSPs for the LLM
</li>
<li>
<strong>opencode zen</strong> A <a href="/docs/zen">curated list of models</a> provided by opencode{" "}
<label>New</label>
<strong>opencode zen</strong> A <a href="/docs/zen">curated list of models</a>{" "}
provided by opencode <label>New</label>
</li>
<li>
<strong>Multi-session</strong> Start multiple agents in parallel on the same project
</li>
<li>
<strong>Shareable links</strong> Share a link to any sessions for reference or to debug
<strong>Shareable links</strong> Share a link to any sessions for reference or to
debug
</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>
<strong>Use any model</strong> Supports 75+ LLM providers through{" "}
@@ -121,7 +123,7 @@ export default function Home() {
<h3 data-component="title">homebrew</h3>
<button data-copy data-slot="button">
<span>
brew install <strong>sst/tap/opencode</strong>
brew install <strong>opencode</strong>
</span>
<CopyStatus />
</button>
@@ -5,7 +5,15 @@ import { withActor } from "~/context/auth.withActor"
import { ZenData } from "@opencode-ai/console-core/model.js"
import styles from "./model-section.module.css"
import { querySessionInfo } from "../common"
import { IconAlibaba, IconAnthropic, IconMoonshotAI, IconOpenAI, IconStealth, IconXai, IconZai } from "~/component/icon"
import {
IconAlibaba,
IconAnthropic,
IconMoonshotAI,
IconOpenAI,
IconStealth,
IconXai,
IconZai,
} from "~/component/icon"
const getModelLab = (modelId: string) => {
if (modelId.startsWith("claude")) return "Anthropic"
@@ -22,8 +30,7 @@ const getModelsInfo = query(async (workspaceID: string) => {
return withActor(async () => {
return {
all: Object.entries(ZenData.list().models)
.filter(([id, _model]) => !["claude-3-5-haiku"].includes(id))
.filter(([id, _model]) => !id.startsWith("an-"))
.filter(([id, _model]) => !["claude-3-5-haiku", "minimax-m2"].includes(id))
.sort(([_idA, modelA], [_idB, modelB]) => modelA.name.localeCompare(modelB.name))
.map(([id, model]) => ({ id, name: model.name })),
disabled: await Model.listDisabled(),
@@ -68,7 +75,8 @@ export function ModelSection() {
<div data-slot="section-title">
<h2>Models</h2>
<p>
Manage which models workspace members can access. <a href="/docs/zen#pricing ">Learn more</a>.
Manage which models workspace members can access.{" "}
<a href="/docs/zen#pricing ">Learn more</a>.
</p>
</div>
<div data-slot="models-list">
@@ -1 +0,0 @@
export type Format = "anthropic" | "openai" | "oa-compat"
@@ -18,7 +18,6 @@ import {
createStreamPartConverter,
createResponseConverter,
} from "./provider/provider"
import { Format } from "./format"
import { anthropicHelper } from "./provider/anthropic"
import { openaiHelper } from "./provider/openai"
import { oaCompatHelper } from "./provider/openai-compatible"
@@ -29,7 +28,7 @@ type Model = ZenData["models"][string]
export async function handler(
input: APIEvent,
opts: {
format: Format
format: ZenData.Format
parseApiKey: (headers: Headers) => string | undefined
},
) {
@@ -239,21 +238,23 @@ export async function handler(
.filter((provider) => !provider.disabled)
.flatMap((provider) => Array<typeof provider>(provider.weight ?? 1).fill(provider))
// Use last character of IP address to select a provider
const lastChar = ip.charCodeAt(ip.length - 1) || 0
const index = lastChar % providers.length
const provider = providers[index]
// Use the last 2 characters of IP address to select a provider
const lastChars = ip.slice(-2)
const index = parseInt(lastChars, 16) % providers.length
const provider = providers[index || 0]
if (!(provider.id in zenData.providers)) {
throw new ModelError(`Provider ${provider.id} not supported`)
}
const format = zenData.providers[provider.id].format
return {
...provider,
...zenData.providers[provider.id],
...(provider.id === "anthropic"
...(format === "anthropic"
? anthropicHelper
: provider.id === "openai"
: format === "openai"
? openaiHelper
: oaCompatHelper),
}
@@ -1,5 +1,4 @@
import { Format } from "../format"
import { ZenData } from "@opencode-ai/console-core/model.js"
import {
fromAnthropicChunk,
fromAnthropicRequest,
@@ -26,7 +25,7 @@ import {
} from "./openai-compatible"
export type ProviderHelper = {
format: Format
format: ZenData.Format
modifyUrl: (providerApi: string) => string
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => void
modifyBody: (body: Record<string, any>) => Record<string, any>
@@ -158,7 +157,7 @@ export interface CommonChunk {
}
}
export function createBodyConverter(from: Format, to: Format) {
export function createBodyConverter(from: ZenData.Format, to: ZenData.Format) {
return (body: any): any => {
if (from === to) return body
@@ -173,7 +172,7 @@ export function createBodyConverter(from: Format, to: Format) {
}
}
export function createStreamPartConverter(from: Format, to: Format) {
export function createStreamPartConverter(from: ZenData.Format, to: ZenData.Format) {
return (part: any): any => {
if (from === to) return part
@@ -191,7 +190,7 @@ export function createStreamPartConverter(from: Format, to: Format) {
}
}
export function createResponseConverter(from: Format, to: Format) {
export function createResponseConverter(from: ZenData.Format, to: ZenData.Format) {
return (response: any): any => {
if (from === to) return response
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.0.15",
"version": "1.0.21",
"private": true,
"type": "module",
"dependencies": {
+11 -5
View File
@@ -11,14 +11,20 @@ const root = path.resolve(process.cwd(), "..", "..", "..")
// read the secret
const ret = await $`bun sst secret list`.cwd(root).text()
const value = ret
const value1 = ret
.split("\n")
.find((line) => line.startsWith("ZEN_MODELS"))
.find((line) => line.startsWith("ZEN_MODELS1"))
?.split("=")[1]
if (!value) throw new Error("ZEN_MODELS not found")
const value2 = ret
.split("\n")
.find((line) => line.startsWith("ZEN_MODELS2"))
?.split("=")[1]
if (!value1) throw new Error("ZEN_MODELS1 not found")
if (!value2) throw new Error("ZEN_MODELS2 not found")
// validate value
ZenData.validate(JSON.parse(value))
ZenData.validate(JSON.parse(value1 + value2))
// update the secret
await $`bun sst secret set ZEN_MODELS ${value} --stage ${stage}`
await $`bun sst secret set ZEN_MODELS1 ${value1} --stage ${stage}`
await $`bun sst secret set ZEN_MODELS2 ${value2} --stage ${stage}`
+14 -8
View File
@@ -10,23 +10,29 @@ const models = await $`bun sst secret list`.cwd(root).text()
console.log("models", models)
// read the line starting with "ZEN_MODELS"
const oldValue = models
const oldValue1 = models
.split("\n")
.find((line) => line.startsWith("ZEN_MODELS"))
.find((line) => line.startsWith("ZEN_MODELS1"))
?.split("=")[1]
if (!oldValue) throw new Error("ZEN_MODELS not found")
console.log("oldValue", oldValue)
const oldValue2 = models
.split("\n")
.find((line) => line.startsWith("ZEN_MODELS2"))
?.split("=")[1]
if (!oldValue1) throw new Error("ZEN_MODELS1 not found")
if (!oldValue2) throw new Error("ZEN_MODELS2 not found")
// store the prettified json to a temp file
const filename = `models-${Date.now()}.json`
const tempFile = Bun.file(path.join(os.tmpdir(), filename))
await tempFile.write(JSON.stringify(JSON.parse(oldValue), null, 2))
await tempFile.write(JSON.stringify(JSON.parse(oldValue1 + oldValue2), null, 2))
console.log("tempFile", tempFile.name)
// open temp file in vim and read the file on close
await $`vim ${tempFile.name}`
const newValue = JSON.parse(await tempFile.text())
ZenData.validate(newValue)
const newValue = JSON.stringify(JSON.parse(await tempFile.text()))
ZenData.validate(JSON.parse(newValue))
// update the secret
await $`bun sst secret set ZEN_MODELS ${JSON.stringify(newValue)}`
const mid = Math.floor(newValue.length / 2)
await $`bun sst secret set ZEN_MODELS1 ${newValue.slice(0, mid)}`
await $`bun sst secret set ZEN_MODELS2 ${newValue.slice(mid)}`
+8 -2
View File
@@ -8,6 +8,9 @@ import { Actor } from "./actor"
import { Resource } from "@opencode-ai/console-resource"
export namespace ZenData {
const FormatSchema = z.enum(["anthropic", "openai", "oa-compat"])
export type Format = z.infer<typeof FormatSchema>
const ModelCostSchema = z.object({
input: z.number(),
output: z.number(),
@@ -34,6 +37,7 @@ export namespace ZenData {
const ProviderSchema = z.object({
api: z.string(),
apiKey: z.string(),
format: FormatSchema,
headerMappings: z.record(z.string(), z.string()).optional(),
})
@@ -47,7 +51,7 @@ export namespace ZenData {
})
export const list = fn(z.void(), () => {
const json = JSON.parse(Resource.ZEN_MODELS.value)
const json = JSON.parse(Resource.ZEN_MODELS1.value + Resource.ZEN_MODELS2.value)
return ModelsSchema.parse(json)
})
}
@@ -56,7 +60,9 @@ export namespace Model {
export const enable = fn(z.object({ model: z.string() }), ({ model }) => {
Actor.assertAdmin()
return Database.use((db) =>
db.delete(ModelTable).where(and(eq(ModelTable.workspaceID, Actor.workspace()), eq(ModelTable.model, model))),
db
.delete(ModelTable)
.where(and(eq(ModelTable.workspaceID, Actor.workspace()), eq(ModelTable.model, model))),
)
})
+96 -1
View File
@@ -3,7 +3,102 @@
/* eslint-disable */
/* deno-fmt-ignore-file */
/// <reference path="../../../sst-env.d.ts" />
import "sst"
declare module "sst" {
export interface Resource {
"ADMIN_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
"AUTH_API_URL": {
"type": "sst.sst.Linkable"
"value": string
}
"AWS_SES_ACCESS_KEY_ID": {
"type": "sst.sst.Secret"
"value": string
}
"AWS_SES_SECRET_ACCESS_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"Console": {
"type": "sst.cloudflare.SolidStart"
"url": string
}
"Database": {
"database": string
"host": string
"password": string
"port": number
"type": "sst.sst.Linkable"
"username": string
}
"Desktop": {
"type": "sst.cloudflare.StaticSite"
"url": string
}
"EMAILOCTOPUS_API_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"GITHUB_APP_ID": {
"type": "sst.sst.Secret"
"value": string
}
"GITHUB_APP_PRIVATE_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"GITHUB_CLIENT_ID_CONSOLE": {
"type": "sst.sst.Secret"
"value": string
}
"GITHUB_CLIENT_SECRET_CONSOLE": {
"type": "sst.sst.Secret"
"value": string
}
"GOOGLE_CLIENT_ID": {
"type": "sst.sst.Secret"
"value": string
}
"HONEYCOMB_API_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_SECRET_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_WEBHOOK_SECRET": {
"type": "sst.sst.Linkable"
"value": string
}
"Web": {
"type": "sst.cloudflare.Astro"
"url": string
}
"ZEN_MODELS1": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS2": {
"type": "sst.sst.Secret"
"value": string
}
}
}
// cloudflare
import * as cloudflare from "@cloudflare/workers-types";
declare module "sst" {
export interface Resource {
"Api": cloudflare.Service
"AuthApi": cloudflare.Service
"AuthStorage": cloudflare.KVNamespace
"Bucket": cloudflare.R2Bucket
"LogProcessor": cloudflare.Service
}
}
import "sst"
export {}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.0.15",
"version": "1.0.21",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+9 -1
View File
@@ -6,6 +6,10 @@
import "sst"
declare module "sst" {
export interface Resource {
"ADMIN_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
"AUTH_API_URL": {
"type": "sst.sst.Linkable"
"value": string
@@ -74,7 +78,11 @@ declare module "sst" {
"type": "sst.cloudflare.Astro"
"url": string
}
"ZEN_MODELS": {
"ZEN_MODELS1": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS2": {
"type": "sst.sst.Secret"
"value": string
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.0.15",
"version": "1.0.21",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+9 -1
View File
@@ -6,6 +6,10 @@
import "sst"
declare module "sst" {
export interface Resource {
"ADMIN_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
"AUTH_API_URL": {
"type": "sst.sst.Linkable"
"value": string
@@ -74,7 +78,11 @@ declare module "sst" {
"type": "sst.cloudflare.Astro"
"url": string
}
"ZEN_MODELS": {
"ZEN_MODELS1": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS2": {
"type": "sst.sst.Secret"
"value": string
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/desktop",
"version": "1.0.15",
"version": "1.0.21",
"description": "",
"type": "module",
"scripts": {
+4 -4
View File
@@ -90,7 +90,7 @@ export function Code(props: Props) {
let ticking = false
const onScroll = () => {
if (!container) return
if (ctx.file.active()?.path !== local.path) return
// if (ctx.file.active()?.path !== local.path) return
if (ticking) return
ticking = true
requestAnimationFrame(() => {
@@ -102,7 +102,7 @@ export function Code(props: Props) {
const onSelectionChange = () => {
if (!container) return
if (isProgrammaticSelection) return
if (ctx.file.active()?.path !== local.path) return
// if (ctx.file.active()?.path !== local.path) return
const d = getSelectionInContainer(container)
if (!d) return
const p = ctx.file.node(local.path)?.selection
@@ -112,7 +112,7 @@ export function Code(props: Props) {
const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
const onKeyDown = (e: KeyboardEvent) => {
if (ctx.file.active()?.path !== local.path) return
// if (ctx.file.active()?.path !== local.path) return
const ae = document.activeElement as HTMLElement | undefined
const tag = (ae?.tagName || "").toLowerCase()
const inputFocused = !!ae && (tag === "input" || tag === "textarea" || ae.isContentEditable)
@@ -155,7 +155,7 @@ export function Code(props: Props) {
createEffect(() => {
const content = html()
if (!container || !content) return
if (ctx.file.active()?.path !== local.path) return
// if (ctx.file.active()?.path !== local.path) return
const codeEl = container.querySelector("code") as HTMLElement | undefined
if (!codeEl) return
const target = ctx.file.node(local.path)?.selection
@@ -19,7 +19,7 @@ export default function FileTree(props: {
component={p.as ?? "div"}
classList={{
"p-0.5 w-full flex items-center gap-x-2 hover:bg-background-element": true,
"bg-background-element": local.file.active()?.path === p.node.path,
// "bg-background-element": local.file.active()?.path === p.node.path,
[props.nodeClass ?? ""]: !!props.nodeClass,
}}
style={`padding-left: ${level * 10}px`}
@@ -55,7 +55,7 @@ export default function FileTree(props: {
"text-xs whitespace-nowrap truncate": true,
"text-text-muted/40": p.node.ignored,
"text-text-muted/80": !p.node.ignored,
"!text-text": local.file.active()?.path === p.node.path,
// "!text-text": local.file.active()?.path === p.node.path,
"!text-primary": local.file.changed(p.node.path),
}}
>
@@ -1,7 +1,7 @@
import { For, JSXElement, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { Markdown, Part } from "@opencode-ai/ui"
import { Part } from "@opencode-ai/ui"
import { useSync } from "@/context/sync"
import type { AssistantMessage as AssistantMessageType, Part as PartType, ToolPart } from "@opencode-ai/sdk"
import type { AssistantMessage as AssistantMessageType, ToolPart } from "@opencode-ai/sdk"
import { Spinner } from "./spinner"
export function MessageProgress(props: { assistantMessages: () => AssistantMessageType[]; done?: boolean }) {
@@ -22,7 +22,6 @@ export function MessageProgress(props: { assistantMessages: () => AssistantMessa
p.state.status === "running",
) as ToolPart,
)
const resolvedParts = createMemo(() => {
let resolved = parts()
const task = currentTask()
@@ -32,20 +31,18 @@ export function MessageProgress(props: { assistantMessages: () => AssistantMessa
}
return resolved
})
const currentText = createMemo(
() =>
resolvedParts().findLast((p) => p?.type === "text")?.text ||
resolvedParts().findLast((p) => p?.type === "reasoning")?.text,
)
// const currentText = createMemo(
// () =>
// resolvedParts().findLast((p) => p?.type === "text")?.text ||
// resolvedParts().findLast((p) => p?.type === "reasoning")?.text,
// )
const eligibleItems = createMemo(() => {
return resolvedParts().filter((p) => p?.type === "tool" && p.state.status === "completed")
return resolvedParts().filter((p) => p?.type === "tool" && p?.state.status === "completed") as ToolPart[]
})
const finishedItems = createMemo<(JSXElement | PartType)[]>(() => [
const finishedItems = createMemo<(JSXElement | ToolPart)[]>(() => [
<div class="h-8 w-full" />,
<div class="h-8 w-full" />,
<div class="h-8 w-full" />,
<div class="flex items-center gap-x-5 pl-3 text-text-base">
<Spinner /> <span class="text-12-medium">Thinking...</span>
</div>,
...eligibleItems(),
...(done() ? [<div class="h-8 w-full" />, <div class="h-8 w-full" />, <div class="h-8 w-full" />] : []),
])
@@ -71,57 +68,120 @@ export function MessageProgress(props: { assistantMessages: () => AssistantMessa
return `-${(total - 2) * 40 - 8}px`
})
const lastPart = createMemo(() => resolvedParts().slice(-1)?.at(0))
const rawStatus = createMemo(() => {
const defaultStatus = "Working..."
const last = lastPart()
if (!last) return defaultStatus
if (last.type === "tool") {
switch (last.tool) {
case "task":
return "Delegating work..."
case "todowrite":
case "todoread":
return "Planning next steps..."
case "read":
return "Gathering context..."
case "list":
case "grep":
case "glob":
return "Searching the codebase..."
case "webfetch":
return "Searching the web..."
case "edit":
case "write":
return "Making edits..."
case "bash":
return "Running commands..."
default:
break
}
} else if (last.type === "reasoning") {
return "Thinking..."
} else if (last.type === "text") {
return "Gathering thoughts..."
}
return defaultStatus
})
const [status, setStatus] = createSignal(rawStatus())
let lastStatusChange = Date.now()
let statusTimeout: number | undefined
createEffect(() => {
const newStatus = rawStatus()
if (newStatus === status()) return
const timeSinceLastChange = Date.now() - lastStatusChange
if (timeSinceLastChange >= 1000) {
setStatus(newStatus)
lastStatusChange = Date.now()
if (statusTimeout) {
clearTimeout(statusTimeout)
statusTimeout = undefined
}
} else {
if (statusTimeout) clearTimeout(statusTimeout)
statusTimeout = setTimeout(() => {
setStatus(rawStatus())
lastStatusChange = Date.now()
statusTimeout = undefined
}, 1000 - timeSinceLastChange) as unknown as number
}
})
return (
<div class="flex flex-col gap-3">
<div
class="h-30 overflow-hidden pointer-events-none pb-1
{/* <Show when={currentText()}> */}
{/* {(text) => ( */}
{/* <div */}
{/* class="h-20 flex flex-col justify-end overflow-hidden py-3 */}
{/* mask-alpha mask-t-from-80% mask-t-from-background-base mask-t-to-transparent" */}
{/* > */}
{/* <Markdown text={text()} class="w-full shrink-0 overflow-visible" /> */}
{/* </div> */}
{/* )} */}
{/* </Show> */}
<div class="flex items-center gap-x-5 pl-3 border border-transparent text-text-base">
<Spinner /> <span class="text-12-medium">{status()}</span>
</div>
<Show when={eligibleItems().length > 0}>
<div
class="h-30 overflow-hidden pointer-events-none pb-1
mask-alpha mask-t-from-33% mask-t-from-background-base mask-t-to-transparent
mask-b-from-95% mask-b-from-background-base mask-b-to-transparent"
>
<div
class="w-full flex flex-col items-start self-stretch gap-2 py-8
transform transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]"
style={{ transform: `translateY(${translateY()})` }}
>
<For each={finishedItems()}>
{(part) => {
if (part && typeof part === "object" && "type" in part) {
const message = createMemo(() => sync.data.message[part.sessionID].find((m) => m.id === part.messageID))
return (
<div class="h-8 flex items-center w-full">
<Switch>
<Match when={part.type === "text" && part}>
{(p) => (
<div
textContent={p().text}
class="text-12-regular text-text-base whitespace-nowrap truncate w-full"
/>
)}
</Match>
<Match when={part.type === "reasoning" && part}>
{(p) => <Part message={message()!} part={p()} />}
</Match>
<Match when={part.type === "tool" && part}>
{(p) => <Part message={message()!} part={p()} />}
</Match>
</Switch>
</div>
)
}
return <div class="h-8 flex items-center w-full">{part}</div>
}}
</For>
</div>
</div>
<Show when={currentText()}>
{(text) => (
<div
class="max-h-36 flex flex-col justify-end overflow-hidden py-3
mask-alpha mask-t-from-80% mask-t-from-background-base mask-t-to-transparent"
class="w-full flex flex-col items-start self-stretch gap-2 py-8
transform transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]"
style={{ transform: `translateY(${translateY()})` }}
>
<Markdown text={text()} class="w-full shrink-0 overflow-visible" />
<For each={finishedItems()}>
{(part) => (
<Switch>
<Match when={part && typeof part === "object" && "type" in part && part}>
{(p) => {
const part = p() as ToolPart
const message = createMemo(() =>
sync.data.message[part.sessionID].find((m) => m.id === part.messageID),
)
return (
<div class="h-8 flex items-center w-full">
<Part message={message()!} part={part} />
</div>
)
}}
</Match>
<Match when={true}>
<div class="h-8 flex items-center w-full">{part as JSXElement}</div>
</Match>
</Switch>
)}
</For>
</div>
)}
</div>
</Show>
</div>
)
@@ -72,7 +72,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})
const { flat, active, onInput, onKeyDown, refetch } = useFilteredList<string>({
items: local.file.search,
items: local.file.searchFilesAndDirectories,
key: (x) => x,
onSelect: (path) => {
if (!path) return
@@ -307,27 +307,29 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<div class="relative size-full _max-h-[320px] flex flex-col gap-3">
<Show when={store.popoverIsOpen}>
<div class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-[252px] min-h-10 overflow-y-auto flex flex-col p-2 pb-0 rounded-2xl border border-border-base bg-surface-raised-stronger-non-alpha shadow-md">
<For each={flat()}>
{(i) => (
<div
classList={{
"w-full flex items-center justify-between rounded-md": true,
"bg-surface-raised-base-hover": active() === i,
}}
>
<div class="flex items-center gap-x-2 grow min-w-0">
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular">
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
{getDirectory(i)}
</span>
<span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
<Show when={flat().length > 0} fallback={<div class="text-text-weak px-2">No matching files</div>}>
<For each={flat()}>
{(i) => (
<div
classList={{
"w-full flex items-center justify-between rounded-md": true,
"bg-surface-raised-base-hover": active() === i,
}}
>
<div class="flex items-center gap-x-2 grow min-w-0">
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular">
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
{getDirectory(i)}
</span>
<span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
</div>
</div>
<div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
</div>
<div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
</div>
)}
</For>
)}
</For>
</Show>
</div>
</Show>
<form
+114 -172
View File
@@ -1,16 +1,7 @@
import { createStore, produce, reconcile } from "solid-js/store"
import { batch, createEffect, createMemo } from "solid-js"
import { pipe, sumBy, uniqueBy } from "remeda"
import type {
FileContent,
FileNode,
Model,
Provider,
File as FileStatus,
Part,
Message,
AssistantMessage,
} from "@opencode-ai/sdk"
import { uniqueBy } from "remeda"
import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk"
import { createSimpleContext } from "./helper"
import { useSDK } from "./sdk"
import { useSync } from "./sync"
@@ -204,18 +195,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const file = (() => {
const [store, setStore] = createStore<{
node: Record<string, LocalFile>
opened: string[]
active?: string
// opened: string[]
// active?: string
}>({
node: Object.fromEntries(sync.data.node.map((x) => [x.path, x])),
opened: [],
// opened: [],
})
const active = createMemo(() => {
if (!store.active) return undefined
return store.node[store.active]
})
const opened = createMemo(() => store.opened.map((x) => store.node[x]))
// const active = createMemo(() => {
// if (!store.active) return undefined
// return store.node[store.active]
// })
// const opened = createMemo(() => store.opened.map((x) => store.node[x]))
const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b)))
@@ -303,16 +294,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const open = async (path: string, options?: { pinned?: boolean; view?: LocalFile["view"] }) => {
const relativePath = relative(path)
if (!store.node[relativePath]) await fetch(path)
setStore("opened", (x) => {
if (x.includes(relativePath)) return x
return [
...opened()
.filter((x) => x.pinned)
.map((x) => x.path),
relativePath,
]
})
setStore("active", relativePath)
// setStore("opened", (x) => {
// if (x.includes(relativePath)) return x
// return [
// ...opened()
// .filter((x) => x.pinned)
// .map((x) => x.path),
// relativePath,
// ]
// })
// setStore("active", relativePath)
context.addActive()
if (options?.pinned) setStore("node", path, "pinned", true)
if (options?.view && store.node[relativePath].view === undefined) setStore("node", path, "view", options.view)
@@ -334,7 +325,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
}
const search = (query: string) => sdk.client.find.files({ query: { query } }).then((x) => x.data!)
const searchFiles = (query: string) =>
sdk.client.find.files({ query: { query, dirs: "false" } }).then((x) => x.data!)
const searchFilesAndDirectories = (query: string) =>
sdk.client.find.files({ query: { query, dirs: "true" } }).then((x) => x.data!)
sdk.event.listen((e) => {
const event = e.details
@@ -363,22 +357,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
return {
active,
opened,
node: (path: string) => store.node[path],
update: (path: string, node: LocalFile) => setStore("node", path, reconcile(node)),
open,
load,
init,
close(path: string) {
setStore("opened", (opened) => opened.filter((x) => x !== path))
if (store.active === path) {
const index = store.opened.findIndex((f) => f === path)
const previous = store.opened[Math.max(0, index - 1)]
setStore("active", previous)
}
resetNode(path)
},
expand(path: string) {
setStore("node", path, "expanded", true)
if (store.node[path].loaded) return
@@ -394,17 +377,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
scroll(path: string, scrollTop: number) {
setStore("node", path, "scrollTop", scrollTop)
},
move(path: string, to: number) {
const index = store.opened.findIndex((f) => f === path)
if (index === -1) return
setStore(
"opened",
produce((opened) => {
opened.splice(to, 0, opened.splice(index, 1)[0])
}),
)
setStore("node", path, "pinned", true)
},
view(path: string): View {
const n = store.node[path]
return n && n.view ? n.view : "raw"
@@ -442,16 +414,51 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
!x.path.replace(new RegExp(`^${path + "/"}`), "").includes("/"),
)
},
search,
searchFiles,
searchFilesAndDirectories,
relative,
// active,
// opened,
// close(path: string) {
// setStore("opened", (opened) => opened.filter((x) => x !== path))
// if (store.active === path) {
// const index = store.opened.findIndex((f) => f === path)
// const previous = store.opened[Math.max(0, index - 1)]
// setStore("active", previous)
// }
// resetNode(path)
// },
// move(path: string, to: number) {
// const index = store.opened.findIndex((f) => f === path)
// if (index === -1) return
// setStore(
// "opened",
// produce((opened) => {
// opened.splice(to, 0, opened.splice(index, 1)[0])
// }),
// )
// setStore("node", path, "pinned", true)
// },
}
})()
const session = (() => {
const [store, setStore] = createStore<{
active?: string
activeMessage?: string
}>({})
tabs: Record<
string,
{
active?: string
opened: string[]
}
>
}>({
tabs: {
"": {
opened: [],
},
},
})
const active = createMemo(() => {
if (!store.active) return undefined
@@ -461,134 +468,69 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
createEffect(() => {
if (!store.active) return
sync.session.sync(store.active)
})
const valid = (part: Part) => {
if (!part) return false
switch (part.type) {
case "step-start":
case "step-finish":
case "file":
case "patch":
return false
case "text":
return !part.synthetic && part.text.trim()
case "reasoning":
return part.text.trim()
case "tool":
switch (part.tool) {
case "todoread":
case "todowrite":
case "list":
case "grep":
return false
}
return true
default:
return true
if (!store.tabs[store.active]) {
setStore("tabs", store.active, {
opened: [],
})
}
}
const hasValidParts = (message: Message) => {
return sync.data.part[message.id]?.filter(valid).length > 0
}
// const hasTextPart = (message: Message) => {
// return !!sync.data.part[message.id]?.filter(valid).find((p) => p.type === "text")
// }
const messages = createMemo(() => (store.active ? (sync.data.message[store.active] ?? []) : []))
const messagesWithValidParts = createMemo(() => messages().filter(hasValidParts) ?? [])
const userMessages = createMemo(() =>
messages()
.filter((m) => m.role === "user")
.sort((a, b) => b.id.localeCompare(a.id)),
)
const cost = createMemo(() => {
const total = pipe(
messages(),
sumBy((x) => (x.role === "assistant" ? x.cost : 0)),
)
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(total)
})
const last = createMemo(() => {
return messages().findLast((x) => x.role === "assistant") as AssistantMessage
})
const lastUserMessage = createMemo(() => {
return userMessages()?.at(0)
})
const activeMessage = createMemo(() => {
if (!store.active || !store.activeMessage) return lastUserMessage()
return sync.data.message[store.active]?.find((m) => m.id === store.activeMessage)
})
const model = createMemo(() => {
if (!last()) return
const model = sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID]
return model
})
const tokens = createMemo(() => {
if (!last()) return
const tokens = last().tokens
const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
return new Intl.NumberFormat("en-US", {
notation: "compact",
compactDisplay: "short",
}).format(total)
})
const context = createMemo(() => {
if (!last()) return
if (!model()?.limit.context) return 0
const tokens = last().tokens
const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
return Math.round((total / model()!.limit.context) * 100)
})
const getMessageText = (message: Message | Message[] | undefined): string => {
if (!message) return ""
if (Array.isArray(message)) return message.map((m) => getMessageText(m)).join(" ")
return sync.data.part[message.id]
?.filter((p) => p.type === "text")
?.filter((p) => !p.synthetic)
.map((p) => p.text)
.join(" ")
}
const tabs = createMemo(() => store.tabs[store.active ?? ""])
return {
active,
activeMessage,
lastUserMessage,
cost,
last,
model,
tokens,
context,
messages,
messagesWithValidParts,
userMessages,
// working,
getMessageText,
setActive(sessionId: string | undefined) {
setStore("active", sessionId)
setStore("activeMessage", undefined)
},
clearActive() {
setStore("active", undefined)
setStore("activeMessage", undefined)
},
setActiveMessage(messageId: string | undefined) {
setStore("activeMessage", messageId)
tabs,
copyTabs(from: string, to: string) {
setStore("tabs", to, {
opened: store.tabs[from]?.opened ?? [],
})
},
clearActiveMessage() {
setStore("activeMessage", undefined)
setActiveTab(tab: string | undefined) {
setStore("tabs", store.active ?? "", "active", tab)
},
async open(tab: string) {
if (tab !== "chat") {
await file.open(tab)
}
if (!tabs()?.opened?.includes(tab)) {
setStore("tabs", store.active ?? "", "opened", [...(tabs()?.opened ?? []), tab])
}
setStore("tabs", store.active ?? "", "active", tab)
},
close(tab: string) {
batch(() => {
if (!tabs()) return
setStore("tabs", store.active ?? "", {
active: tabs()!.active,
opened: tabs()!.opened.filter((x) => x !== tab),
})
if (tabs()!.active === tab) {
const index = tabs()!.opened.findIndex((f) => f === tab)
const previous = tabs()!.opened[Math.max(0, index - 1)]
setStore("tabs", store.active ?? "", "active", previous)
}
})
},
move(tab: string, to: number) {
if (!tabs()) return
const index = tabs()!.opened.findIndex((f) => f === tab)
if (index === -1) return
setStore(
"tabs",
store.active ?? "",
"opened",
produce((opened) => {
opened.splice(to, 0, opened.splice(index, 1)[0])
}),
)
// setStore("node", path, "pinned", true)
},
}
})()
@@ -611,9 +553,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
all() {
return store.items
},
active() {
return store.activeTab ? file.active() : undefined
},
// active() {
// return store.activeTab ? file.active() : undefined
// },
addActive() {
setStore("activeTab", true)
},
+3 -1
View File
@@ -12,7 +12,9 @@ import Home from "@/pages"
const host = import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "127.0.0.1"
const port = import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"
const url = new URLSearchParams(document.location.search).get("url") || `http://${host}:${port}`
const url =
new URLSearchParams(document.location.search).get("url") ||
(location.hostname.includes("opencode.ai") ? `http://${host}:${port}` : "/")
const root = document.getElementById("root")
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
+325 -396
View File
@@ -10,9 +10,10 @@ import {
Diff,
Collapsible,
DiffChanges,
ProgressCircle,
Message,
Typewriter,
Card,
Code,
} from "@opencode-ai/ui"
import { FileIcon } from "@/ui"
import FileTree from "@/components/file-tree"
@@ -34,7 +35,6 @@ import {
} from "@thisbeyond/solid-dnd"
import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
import type { JSX } from "solid-js"
import { Code } from "@/components/code"
import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk"
import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk"
@@ -53,14 +53,6 @@ export default function Page() {
let messageScrollElement!: HTMLDivElement
const [activeItem, setActiveItem] = createSignal<string | undefined>(undefined)
createEffect(() => {
// Set first message as active if none selected
const userMessages = local.session.userMessages()
if (userMessages.length > 0 && !local.session.activeMessage()) {
local.session.setActiveMessage(userMessages[0].id)
}
})
const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
onMount(() => {
@@ -90,26 +82,26 @@ export default function Page() {
return
}
if (local.file.active()) {
const active = local.file.active()!
if (event.key === "Enter" && active.selection) {
local.context.add({
type: "file",
path: active.path,
selection: { ...active.selection },
})
return
}
if (event.getModifierState(MOD)) {
if (event.key.toLowerCase() === "a") {
return
}
if (event.key.toLowerCase() === "c") {
return
}
}
}
// if (local.file.active()) {
// const active = local.file.active()!
// if (event.key === "Enter" && active.selection) {
// local.context.add({
// type: "file",
// path: active.path,
// selection: { ...active.selection },
// })
// return
// }
//
// if (event.getModifierState(MOD)) {
// if (event.key.toLowerCase() === "a") {
// return
// }
// if (event.key.toLowerCase() === "c") {
// return
// }
// }
// }
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
inputRef?.focus()
@@ -139,21 +131,22 @@ export default function Page() {
}
}
const navigateChange = (dir: 1 | -1) => {
const active = local.file.active()
if (!active) return
const current = local.file.changeIndex(active.path)
const next = current === undefined ? (dir === 1 ? 0 : -1) : current + dir
local.file.setChangeIndex(active.path, next)
}
// const navigateChange = (dir: 1 | -1) => {
// const active = local.file.active()
// if (!active) return
// const current = local.file.changeIndex(active.path)
// const next = current === undefined ? (dir === 1 ? 0 : -1) : current + dir
// local.file.setChangeIndex(active.path, next)
// }
const handleTabChange = (path: string) => {
if (path === "chat" || path === "review") return
local.file.open(path)
local.session.setActiveTab(path)
if (path === "chat") return
local.session.open(path)
}
const handleTabClose = (file: LocalFile) => {
local.file.close(file.path)
local.session.close(file.path)
}
const handleDragStart = (event: unknown) => {
@@ -165,11 +158,11 @@ export default function Page() {
const handleDragOver = (event: DragEvent) => {
const { draggable, droppable } = event
if (draggable && droppable) {
const currentFiles = local.file.opened().map((file) => file.path)
const fromIndex = currentFiles.indexOf(draggable.id.toString())
const toIndex = currentFiles.indexOf(droppable.id.toString())
if (fromIndex !== toIndex) {
local.file.move(draggable.id.toString(), toIndex)
const currentFiles = local.session.tabs()?.opened.map((file) => file)
const fromIndex = currentFiles?.indexOf(draggable.id.toString())
const toIndex = currentFiles?.indexOf(droppable.id.toString())
if (fromIndex !== toIndex && toIndex !== undefined) {
local.session.move(draggable.id.toString(), toIndex)
}
}
}
@@ -178,20 +171,20 @@ export default function Page() {
setActiveItem(undefined)
}
const scrollDiffItem = (element: HTMLElement) => {
element.scrollIntoView({ block: "start", behavior: "instant" })
}
// const scrollDiffItem = (element: HTMLElement) => {
// element.scrollIntoView({ block: "start", behavior: "instant" })
// }
const handleDiffTriggerClick = (event: MouseEvent) => {
// disabling scroll to diff for now
return
const target = event.currentTarget as HTMLElement
queueMicrotask(() => {
if (target.getAttribute("aria-expanded") !== "true") return
const item = target.closest('[data-slot="accordion-item"]') as HTMLElement | null
if (!item) return
scrollDiffItem(item)
})
// const target = event.currentTarget as HTMLElement
// queueMicrotask(() => {
// if (target.getAttribute("aria-expanded") !== "true") return
// const item = target.closest('[data-slot="accordion-item"]') as HTMLElement | null
// if (!item) return
// scrollDiffItem(item)
// })
}
const handlePromptSubmit = async (parts: ContentPart[]) => {
@@ -204,6 +197,10 @@ export default function Page() {
if (!session) return
local.session.setActive(session.id)
if (!existingSession) {
local.session.copyTabs("", session.id)
}
local.session.setActiveTab(undefined)
const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
const text = parts.map((part) => part.content).join("")
@@ -426,20 +423,26 @@ export default function Page() {
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs onChange={handleTabChange}>
<Tabs value={local.session.tabs()?.active ?? "chat"} onChange={handleTabChange}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List>
<Tabs.Trigger value="chat" class="flex gap-x-4 items-center">
<div>Chat</div>
<Tooltip value={`${local.session.tokens() ?? 0} Tokens`} class="flex items-center gap-1.5">
<ProgressCircle percentage={local.session.context() ?? 0} />
<div class="text-14-regular text-text-weak text-right">{local.session.context() ?? 0}%</div>
</Tooltip>
{/* <Tooltip value={`${local.session.tokens() ?? 0} Tokens`} class="flex items-center gap-1.5"> */}
{/* <ProgressCircle percentage={local.session.context() ?? 0} /> */}
{/* <div class="text-14-regular text-text-weak text-right">{local.session.context() ?? 0}%</div> */}
{/* </Tooltip> */}
</Tabs.Trigger>
{/* <Tabs.Trigger value="review">Review</Tabs.Trigger> */}
<SortableProvider ids={local.file.opened().map((file) => file.path)}>
<For each={local.file.opened()}>
{(file) => <SortableTab file={file} onTabClick={handleFileClick} onTabClose={handleTabClose} />}
<SortableProvider ids={local.session.tabs()?.opened ?? []}>
<For each={local.session.tabs()?.opened ?? []}>
{(file) => (
<SortableTab
file={local.file.node(file)}
onTabClick={handleFileClick}
onTabClose={handleTabClose}
/>
)}
</For>
</SortableProvider>
<div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
@@ -451,64 +454,6 @@ export default function Page() {
/>
</div>
</Tabs.List>
<div class="hidden shrink-0 h-full _flex items-center gap-1 px-2 border-b border-border-subtle/40">
<Show when={local.file.active() && local.file.active()!.content?.diff}>
{(() => {
const activeFile = local.file.active()!
const view = local.file.view(activeFile.path)
return (
<div class="flex items-center gap-1">
<Show when={view !== "raw"}>
<div class="mr-1 flex items-center gap-1">
<Tooltip value="Previous change" placement="bottom">
<IconButton icon="arrow-up" variant="ghost" onClick={() => navigateChange(-1)} />
</Tooltip>
<Tooltip value="Next change" placement="bottom">
<IconButton icon="arrow-down" variant="ghost" onClick={() => navigateChange(1)} />
</Tooltip>
</div>
</Show>
<Tooltip value="Raw" placement="bottom">
<IconButton
icon="file-text"
variant="ghost"
classList={{
"text-text": view === "raw",
"text-text-muted/70": view !== "raw",
"bg-background-element": view === "raw",
}}
onClick={() => local.file.setView(activeFile.path, "raw")}
/>
</Tooltip>
<Tooltip value="Unified diff" placement="bottom">
<IconButton
icon="checklist"
variant="ghost"
classList={{
"text-text": view === "diff-unified",
"text-text-muted/70": view !== "diff-unified",
"bg-background-element": view === "diff-unified",
}}
onClick={() => local.file.setView(activeFile.path, "diff-unified")}
/>
</Tooltip>
<Tooltip value="Split diff" placement="bottom">
<IconButton
icon="columns"
variant="ghost"
classList={{
"text-text": view === "diff-split",
"text-text-muted/70": view !== "diff-split",
"bg-background-element": view === "diff-split",
}}
onClick={() => local.file.setView(activeFile.path, "diff-split")}
/>
</Tooltip>
</div>
)
})()}
</Show>
</div>
</div>
<Tabs.Content value="chat" class="@container select-text flex flex-col flex-1 min-h-0 overflow-y-hidden">
<div class="relative px-6 pt-12 max-w-2xl w-full mx-auto flex flex-col flex-1 min-h-0">
@@ -536,305 +481,289 @@ export default function Page() {
</div>
}
>
{(activeSession) => (
<div class="pt-3 flex flex-col flex-1 min-h-0">
<div class="flex-1 min-h-0">
<Show when={local.session.userMessages().length > 1}>
<ul
role="list"
class="absolute right-full mr-8 hidden w-60 shrink-0 @7xl:flex flex-col items-start gap-1"
>
<For each={local.session.userMessages()}>
{(session) => {
const [store, setStore] = createStore<{
messageId?: string
}>()
const messages = createMemo(() => sync.data.message[session().id] ?? [])
const userMessages = createMemo(() =>
messages()
.filter((m) => m.role === "user")
.sort((a, b) => b.id.localeCompare(a.id)),
)
const lastUserMessage = createMemo(() => {
return userMessages()?.at(0)
})
const activeMessage = createMemo(() => {
if (!store.messageId) return lastUserMessage()
return userMessages()?.find((m) => m.id === store.messageId)
})
return (
<div class="pt-3 flex flex-col flex-1 min-h-0">
<div class="flex-1 min-h-0">
<Show when={userMessages().length > 1}>
<ul
role="list"
class="absolute right-full mr-8 hidden w-60 shrink-0 @7xl:flex flex-col items-start gap-1"
>
<For each={userMessages()}>
{(message) => {
const diffs = createMemo(() => message.summary?.diffs ?? [])
const assistantMessages = createMemo(() => {
return sync.data.message[session().id]?.filter(
(m) => m.role === "assistant" && m.parentID == message.id,
) as AssistantMessageType[]
})
const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
const working = createMemo(() => !message.summary?.body && !error())
return (
<li class="group/li flex items-center self-stretch">
<button
class="flex items-center self-stretch w-full gap-x-2 py-1 cursor-default"
onClick={() => setStore("messageId", message.id)}
>
<Switch>
<Match when={working()}>
<Spinner class="text-text-base shrink-0 w-[18px] aspect-square" />
</Match>
<Match when={true}>
<DiffChanges diff={diffs()} variant="bars" />
</Match>
</Switch>
<div
data-active={activeMessage()?.id === message.id}
classList={{
"text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true,
"text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
}}
>
<Show when={message.summary?.title} fallback="New message">
{message.summary?.title}
</Show>
</div>
</button>
</li>
)
}}
</For>
</ul>
</Show>
<div ref={messageScrollElement} class="grow min-w-0 h-full overflow-y-auto no-scrollbar">
<For each={userMessages()}>
{(message) => {
const diffs = createMemo(() => message.summary?.diffs ?? [])
const working = createMemo(() => !message.summary?.body)
const isActive = createMemo(() => activeMessage()?.id === message.id)
const [titled, setTitled] = createSignal(!!message.summary?.title)
const assistantMessages = createMemo(() => {
return sync.data.message[activeSession().id]?.filter(
return sync.data.message[session().id]?.filter(
(m) => m.role === "assistant" && m.parentID == message.id,
) as AssistantMessageType[]
})
const parts = createMemo(() =>
assistantMessages().flatMap((m) => sync.data.part[m.id]),
const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
const [completed, setCompleted] = createSignal(!!message.summary?.body || !!error())
const [expanded, setExpanded] = createSignal(false)
const parts = createMemo(() => sync.data.part[message.id])
const title = createMemo(() => message.summary?.title)
const summary = createMemo(() => message.summary?.body)
const diffs = createMemo(() => message.summary?.diffs ?? [])
const hasToolPart = createMemo(() =>
assistantMessages()
?.flatMap((m) => sync.data.part[m.id])
.some((p) => p?.type === "tool"),
)
const lastPart = createMemo(() => parts().slice(-1)?.at(0))
const rawStatus = createMemo(() => {
const defaultStatus = "Working..."
const last = lastPart()
if (!last) return defaultStatus
if (last.type === "tool") {
switch (last.tool) {
case "task":
return "Delegating work..."
case "todowrite":
case "todoread":
return "Planning next steps..."
case "read":
return "Gathering context..."
case "list":
case "grep":
case "glob":
return "Searching the codebase..."
case "webfetch":
return "Searching the web..."
case "edit":
case "write":
return "Making edits..."
case "bash":
return "Running commands..."
default:
break
}
} else if (last.type === "reasoning") {
return "Thinking..."
} else if (last.type === "text") {
return "Gathering thoughts..."
}
return defaultStatus
})
const [status, setStatus] = createSignal(rawStatus())
let lastStatusChange = Date.now()
let statusTimeout: number | undefined
const working = createMemo(() => !summary() && !error())
// allowing time for the animations to finish
createEffect(() => {
const newStatus = rawStatus()
if (newStatus === status()) return
const timeSinceLastChange = Date.now() - lastStatusChange
if (timeSinceLastChange >= 1000) {
setStatus(newStatus)
lastStatusChange = Date.now()
if (statusTimeout) {
clearTimeout(statusTimeout)
statusTimeout = undefined
}
} else {
if (statusTimeout) clearTimeout(statusTimeout)
statusTimeout = setTimeout(() => {
setStatus(rawStatus())
lastStatusChange = Date.now()
statusTimeout = undefined
}, 1000 - timeSinceLastChange) as unknown as number
}
title()
setTimeout(() => setTitled(!!title()), 10_000)
})
createEffect(() => {
const complete = !!summary() || !!error()
setTimeout(() => setCompleted(complete), 1200)
})
return (
<li class="group/li flex items-center self-stretch">
<button
class="flex items-center self-stretch w-full gap-x-2 py-1 cursor-default"
onClick={() => local.session.setActiveMessage(message.id)}
<Show when={isActive()}>
<div
data-message={message.id}
class="flex flex-col items-start self-stretch gap-8 pb-50"
>
<Switch>
<Match when={working()}>
<Spinner class="text-text-base shrink-0 w-[18px] aspect-square" />
</Match>
<Match when={true}>
<DiffChanges diff={diffs()} variant="bars" />
</Match>
</Switch>
<div
data-active={local.session.activeMessage()?.id === message.id}
classList={{
"text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true,
"text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
}}
>
{/* Title */}
<div class="py-2 flex flex-col items-start gap-2 self-stretch sticky top-0 bg-background-stronger z-10">
<div class="w-full text-14-medium text-text-strong">
<Show
when={titled()}
fallback={
<Typewriter
as="h1"
text={title()}
class="overflow-hidden text-ellipsis min-w-0 text-nowrap"
/>
}
>
<h1 class="overflow-hidden text-ellipsis min-w-0 text-nowrap">
{title()}
</h1>
</Show>
</div>
</div>
<div class="-mt-8">
<Message message={message} parts={parts()} />
</div>
{/* Summary */}
<Show when={completed()}>
<div class="w-full flex flex-col gap-6 items-start self-stretch">
<div class="flex flex-col items-start gap-1 self-stretch">
<h2 class="text-12-medium text-text-weak">
<Switch>
<Match when={diffs().length}>Summary</Match>
<Match when={true}>Response</Match>
</Switch>
</h2>
<Show when={summary()}>
{(summary) => (
<Markdown
classList={{ "[&>*]:fade-up-text": !diffs().length }}
text={summary()}
/>
)}
</Show>
</div>
<Accordion class="w-full" multiple>
<For each={diffs()}>
{(diff) => (
<Accordion.Item value={diff.file}>
<Accordion.Header>
<Accordion.Trigger onClick={handleDiffTriggerClick}>
<div class="flex items-center justify-between w-full gap-5">
<div class="grow flex items-center gap-5 min-w-0">
<FileIcon
node={{ path: diff.file, type: "file" }}
class="shrink-0 size-4"
/>
<div class="flex grow min-w-0">
<Show when={diff.file.includes("/")}>
<span class="text-text-base truncate-start">
{getDirectory(diff.file)}&lrm;
</span>
</Show>
<span class="text-text-strong shrink-0">
{getFilename(diff.file)}
</span>
</div>
</div>
<div class="shrink-0 flex gap-4 items-center justify-end">
<DiffChanges diff={diff} />
<Icon name="chevron-grabber-vertical" size="small" />
</div>
</div>
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>
<Diff
before={{
name: diff.file!,
contents: diff.before!,
}}
after={{
name: diff.file!,
contents: diff.after!,
}}
/>
</Accordion.Content>
</Accordion.Item>
)}
</For>
</Accordion>
</div>
</Show>
<Show when={error() && !expanded()}>
<Card variant="error" class="text-text-on-critical-base">
{error()?.data?.message as string}
</Card>
</Show>
{/* Response */}
<div class="w-full">
<Switch>
<Match when={working()}>{status()}</Match>
<Match when={true}>{message.summary?.title}</Match>
<Match when={!completed()}>
<MessageProgress
assistantMessages={assistantMessages}
done={!working()}
/>
</Match>
<Match when={completed() && hasToolPart()}>
<Collapsible variant="ghost" open={expanded()} onOpenChange={setExpanded}>
<Collapsible.Trigger class="text-text-weak hover:text-text-strong">
<div class="flex items-center gap-1 self-stretch">
<div class="text-12-medium">
<Switch>
<Match when={expanded()}>Hide details</Match>
<Match when={!expanded()}>Show details</Match>
</Switch>
</div>
<Collapsible.Arrow />
</div>
</Collapsible.Trigger>
<Collapsible.Content>
<div class="w-full flex flex-col items-start self-stretch gap-3">
<For each={assistantMessages()}>
{(assistantMessage) => {
const parts = createMemo(
() => sync.data.part[assistantMessage.id],
)
return <Message message={assistantMessage} parts={parts()} />
}}
</For>
<Show when={error()}>
<Card variant="error" class="text-text-on-critical-base">
{error()?.data?.message as string}
</Card>
</Show>
</div>
</Collapsible.Content>
</Collapsible>
</Match>
</Switch>
</div>
</button>
</li>
</div>
</Show>
)
}}
</For>
</ul>
</Show>
<div ref={messageScrollElement} class="grow min-w-0 h-full overflow-y-auto no-scrollbar">
<For each={local.session.userMessages()}>
{(message) => {
const isActive = createMemo(() => local.session.activeMessage()?.id === message.id)
const [titled, setTitled] = createSignal(!!message.summary?.title)
const [completed, setCompleted] = createSignal(!!message.summary?.body)
const [expanded, setExpanded] = createSignal(false)
const parts = createMemo(() => sync.data.part[message.id])
const title = createMemo(() => message.summary?.title)
const summary = createMemo(() => message.summary?.body)
const diffs = createMemo(() => message.summary?.diffs ?? [])
const assistantMessages = createMemo(() => {
return sync.data.message[activeSession().id]?.filter(
(m) => m.role === "assistant" && m.parentID == message.id,
) as AssistantMessageType[]
})
const hasToolPart = createMemo(() =>
assistantMessages()
?.flatMap((m) => sync.data.part[m.id])
.some((p) => p?.type === "tool"),
)
const working = createMemo(() => !summary())
// allowing time for the animations to finish
createEffect(() => {
title()
setTimeout(() => setTitled(!!title()), 10_000)
})
createEffect(() => {
summary()
setTimeout(() => setCompleted(!!summary()), 1200)
})
return (
<Show when={isActive()}>
<div
data-message={message.id}
class="flex flex-col items-start self-stretch gap-8 pb-50"
>
{/* Title */}
<div class="py-2 flex flex-col items-start gap-2 self-stretch sticky top-0 bg-background-stronger z-10">
<div class="w-full text-14-medium text-text-strong">
<Show
when={titled()}
fallback={
<Typewriter
as="h1"
text={title()}
class="overflow-hidden text-ellipsis min-w-0 text-nowrap"
/>
}
>
<h1 class="overflow-hidden text-ellipsis min-w-0 text-nowrap">{title()}</h1>
</Show>
</div>
</div>
<div class="-mt-8">
<Message message={message} parts={parts()} />
</div>
{/* Summary */}
<Show when={completed()}>
<div class="w-full flex flex-col gap-6 items-start self-stretch">
<div class="flex flex-col items-start gap-1 self-stretch">
<h2 class="text-12-medium text-text-weak">
<Switch>
<Match when={diffs().length}>Summary</Match>
<Match when={true}>Response</Match>
</Switch>
</h2>
<Show when={summary()}>
{(summary) => (
<Markdown
classList={{ "[&>*]:fade-up-text": !diffs().length }}
text={summary()}
/>
)}
</Show>
</div>
<Accordion class="w-full" multiple>
<For each={diffs()}>
{(diff) => (
<Accordion.Item value={diff.file}>
<Accordion.Header>
<Accordion.Trigger onClick={handleDiffTriggerClick}>
<div class="flex items-center justify-between w-full gap-5">
<div class="grow flex items-center gap-5 min-w-0">
<FileIcon
node={{ path: diff.file, type: "file" }}
class="shrink-0 size-4"
/>
<div class="flex grow min-w-0">
<Show when={diff.file.includes("/")}>
<span class="text-text-base truncate-start">
{getDirectory(diff.file)}&lrm;
</span>
</Show>
<span class="text-text-strong shrink-0">
{getFilename(diff.file)}
</span>
</div>
</div>
<div class="shrink-0 flex gap-4 items-center justify-end">
<DiffChanges diff={diff} />
<Icon name="chevron-grabber-vertical" size="small" />
</div>
</div>
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>
<Diff
before={{
name: diff.file!,
contents: diff.before!,
}}
after={{
name: diff.file!,
contents: diff.after!,
}}
/>
</Accordion.Content>
</Accordion.Item>
)}
</For>
</Accordion>
</div>
</Show>
{/* Response */}
<div class="w-full">
<Switch>
<Match when={!completed()}>
<MessageProgress assistantMessages={assistantMessages} done={!working()} />
</Match>
<Match when={completed() && hasToolPart()}>
<Collapsible variant="ghost" open={expanded()} onOpenChange={setExpanded}>
<Collapsible.Trigger class="text-text-weak hover:text-text-strong">
<div class="flex items-center gap-1 self-stretch">
<div class="text-12-medium">
<Switch>
<Match when={expanded()}>Hide details</Match>
<Match when={!expanded()}>Show details</Match>
</Switch>
</div>
<Collapsible.Arrow />
</div>
</Collapsible.Trigger>
<Collapsible.Content>
<div class="w-full flex flex-col items-start self-stretch gap-3">
<For each={assistantMessages()}>
{(assistantMessage) => {
const parts = createMemo(
() => sync.data.part[assistantMessage.id],
)
return <Message message={assistantMessage} parts={parts()} />
}}
</For>
</div>
</Collapsible.Content>
</Collapsible>
</Match>
</Switch>
</div>
</div>
</Show>
)
}}
</For>
</div>
</div>
</div>
</div>
)}
)
}}
</Show>
</div>
</Tabs.Content>
{/* <Tabs.Content value="review" class="select-text"></Tabs.Content> */}
<For each={local.file.opened()}>
<For each={local.session.tabs()?.opened}>
{(file) => (
<Tabs.Content value={file.path} class="select-text">
<Tabs.Content value={file} class="select-text">
{(() => {
const view = local.file.view(file.path)
const showRaw = view === "raw" || !file.content?.diff
const code = showRaw ? (file.content?.content ?? "") : (file.content?.diff ?? "")
return <Code path={file.path} code={code} class="[&_code]:pb-60" />
{
/* const view = local.file.view(file) */
}
{
/* const showRaw = view === "raw" || !file.content?.diff */
}
{
/* const code = showRaw ? (file.content?.content ?? "") : (file.content?.diff ?? "") */
}
const node = local.file.node(file)
return (
<Code
file={{ name: node.path, contents: node.content?.content ?? "" }}
disableFileHeader
overflow="scroll"
class="pt-3 pb-40"
/>
)
})()}
</Tabs.Content>
)}
@@ -898,10 +827,10 @@ export default function Page() {
title="Select file"
placeholder="Search files"
emptyMessage="No files found"
items={local.file.search}
items={local.file.searchFiles}
key={(x) => x}
onOpenChange={(open) => setStore("fileSelectOpen", open)}
onSelect={(x) => (x ? local.file.open(x, { pinned: true }) : undefined)}
onSelect={(x) => (x ? local.session.open(x) : undefined)}
>
{(i) => (
<div
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.0.15",
"version": "1.0.21",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+9 -1
View File
@@ -6,6 +6,10 @@
import "sst"
declare module "sst" {
export interface Resource {
"ADMIN_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
"AUTH_API_URL": {
"type": "sst.sst.Linkable"
"value": string
@@ -74,7 +78,11 @@ declare module "sst" {
"type": "sst.cloudflare.Astro"
"url": string
}
"ZEN_MODELS": {
"ZEN_MODELS1": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS2": {
"type": "sst.sst.Secret"
"value": string
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.0.15",
"version": "1.0.21",
"name": "opencode",
"type": "module",
"private": true,
+10 -3
View File
@@ -41,7 +41,9 @@ for (const [os, arch] of targets) {
const opentui = `@opentui/core-${os === "windows" ? "win32" : os}-${arch.replace("-baseline", "")}`
await $`mkdir -p ../../node_modules/${opentui}`
await $`npm pack ${opentui}@${pkg.dependencies["@opentui/core"]}`.cwd(path.join(dir, "../../node_modules"))
await $`npm pack ${opentui}@${pkg.dependencies["@opentui/core"]}`.cwd(
path.join(dir, "../../node_modules"),
)
await $`tar -xf ../../node_modules/${opentui.replace("@opentui/", "opentui-")}-*.tgz -C ../../node_modules/${opentui} --strip-components=1`
const watcher = `@parcel/watcher-${os === "windows" ? "win32" : os}-${arch.replace("-baseline", "")}${os === "linux" ? "-glibc" : ""}`
@@ -49,7 +51,11 @@ for (const [os, arch] of targets) {
await $`npm pack ${watcher}`.cwd(path.join(dir, "../../node_modules")).quiet()
await $`tar -xf ../../node_modules/${watcher.replace("@parcel/", "parcel-")}-*.tgz -C ../../node_modules/${watcher} --strip-components=1`
const parserWorker = fs.realpathSync(path.resolve(dir, "./node_modules/@opentui/core/parser.worker.js"))
const parserWorker = fs.realpathSync(
path.resolve(dir, "./node_modules/@opentui/core/parser.worker.js"),
)
const workerPath = "./src/cli/cmd/tui/worker.ts"
await Bun.build({
conditions: ["browser"],
tsconfig: "./tsconfig.json",
@@ -61,10 +67,11 @@ for (const [os, arch] of targets) {
execArgv: [`--user-agent=opencode/${Script.version}`, `--env-file=""`, `--`],
windows: {},
},
entrypoints: ["./src/index.ts", parserWorker, "./src/cli/cmd/tui/worker.ts"],
entrypoints: ["./src/index.ts", parserWorker, workerPath],
define: {
OPENCODE_VERSION: `'${Script.version}'`,
OTUI_TREE_SITTER_WORKER_PATH: "/$bunfs/root/" + path.relative(dir, parserWorker),
OPENCODE_WORKER_PATH: workerPath,
OPENCODE_CHANNEL: `'${Script.channel}'`,
},
})
+9 -1
View File
@@ -5,6 +5,14 @@ export const GenerateCommand = {
command: "generate",
handler: async () => {
const specs = await Server.openapi()
process.stdout.write(JSON.stringify(specs, null, 2))
const json = JSON.stringify(specs, null, 2)
// Wait for stdout to finish writing before process.exit() is called
await new Promise<void>((resolve, reject) => {
process.stdout.write(json, (err) => {
if (err) reject(err)
else resolve()
})
})
},
} satisfies CommandModule
+196 -9
View File
@@ -1,4 +1,10 @@
import type { Argv } from "yargs"
import { cmd } from "./cmd"
import { Session } from "../../session"
import { bootstrap } from "../bootstrap"
import { Storage } from "../../storage/storage"
import { Project } from "../../project/project"
import { Instance } from "../../project/instance"
interface SessionStats {
totalSessions: number
@@ -24,10 +30,186 @@ interface SessionStats {
export const StatsCommand = cmd({
command: "stats",
handler: async () => {},
describe: "show token usage and cost statistics",
builder: (yargs: Argv) => {
return yargs
.option("days", {
describe: "show stats for the last N days (default: all time)",
type: "number",
})
.option("tools", {
describe: "number of tools to show (default: all)",
type: "number",
})
.option("project", {
describe: "filter by project (default: all projects, empty string: current project)",
type: "string",
})
},
handler: async (args) => {
await bootstrap(process.cwd(), async () => {
const stats = await aggregateSessionStats(args.days, args.project)
displayStats(stats, args.tools)
})
},
})
export function displayStats(stats: SessionStats) {
async function getCurrentProject(): Promise<Project.Info> {
return Instance.project
}
async function getAllSessions(): Promise<Session.Info[]> {
const sessions: Session.Info[] = []
const projectKeys = await Storage.list(["project"])
const projects = await Promise.all(projectKeys.map((key) => Storage.read<Project.Info>(key)))
for (const project of projects) {
if (!project) continue
const sessionKeys = await Storage.list(["session", project.id])
const projectSessions = await Promise.all(
sessionKeys.map((key) => Storage.read<Session.Info>(key)),
)
for (const session of projectSessions) {
if (session) {
sessions.push(session)
}
}
}
return sessions
}
async function aggregateSessionStats(days?: number, projectFilter?: string): Promise<SessionStats> {
const sessions = await getAllSessions()
const DAYS_IN_SECOND = 24 * 60 * 60 * 1000
const cutoffTime = days ? Date.now() - days * DAYS_IN_SECOND : 0
let filteredSessions = days
? sessions.filter((session) => session.time.updated >= cutoffTime)
: sessions
if (projectFilter !== undefined) {
if (projectFilter === "") {
const currentProject = await getCurrentProject()
filteredSessions = filteredSessions.filter(
(session) => session.projectID === currentProject.id,
)
} else {
filteredSessions = filteredSessions.filter((session) => session.projectID === projectFilter)
}
}
const stats: SessionStats = {
totalSessions: filteredSessions.length,
totalMessages: 0,
totalCost: 0,
totalTokens: {
input: 0,
output: 0,
reasoning: 0,
cache: {
read: 0,
write: 0,
},
},
toolUsage: {},
dateRange: {
earliest: Date.now(),
latest: Date.now(),
},
days: 0,
costPerDay: 0,
}
if (filteredSessions.length > 1000) {
console.log(
`Large dataset detected (${filteredSessions.length} sessions). This may take a while...`,
)
}
if (filteredSessions.length === 0) {
return stats
}
let earliestTime = Date.now()
let latestTime = 0
const BATCH_SIZE = 20
for (let i = 0; i < filteredSessions.length; i += BATCH_SIZE) {
const batch = filteredSessions.slice(i, i + BATCH_SIZE)
const batchPromises = batch.map(async (session) => {
const messages = await Session.messages(session.id)
let sessionCost = 0
let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
let sessionToolUsage: Record<string, number> = {}
for (const message of messages) {
if (message.info.role === "assistant") {
sessionCost += message.info.cost || 0
if (message.info.tokens) {
sessionTokens.input += message.info.tokens.input || 0
sessionTokens.output += message.info.tokens.output || 0
sessionTokens.reasoning += message.info.tokens.reasoning || 0
sessionTokens.cache.read += message.info.tokens.cache?.read || 0
sessionTokens.cache.write += message.info.tokens.cache?.write || 0
}
}
for (const part of message.parts) {
if (part.type === "tool" && part.tool) {
sessionToolUsage[part.tool] = (sessionToolUsage[part.tool] || 0) + 1
}
}
}
return {
messageCount: messages.length,
sessionCost,
sessionTokens,
sessionToolUsage,
earliestTime: session.time.created,
latestTime: session.time.updated,
}
})
const batchResults = await Promise.all(batchPromises)
for (const result of batchResults) {
earliestTime = Math.min(earliestTime, result.earliestTime)
latestTime = Math.max(latestTime, result.latestTime)
stats.totalMessages += result.messageCount
stats.totalCost += result.sessionCost
stats.totalTokens.input += result.sessionTokens.input
stats.totalTokens.output += result.sessionTokens.output
stats.totalTokens.reasoning += result.sessionTokens.reasoning
stats.totalTokens.cache.read += result.sessionTokens.cache.read
stats.totalTokens.cache.write += result.sessionTokens.cache.write
for (const [tool, count] of Object.entries(result.sessionToolUsage)) {
stats.toolUsage[tool] = (stats.toolUsage[tool] || 0) + count
}
}
}
const actualDays = Math.max(1, Math.ceil((latestTime - earliestTime) / DAYS_IN_SECOND))
stats.dateRange = {
earliest: earliestTime,
latest: latestTime,
}
stats.days = actualDays
stats.costPerDay = stats.totalCost / actualDays
return stats
}
export function displayStats(stats: SessionStats, toolLimit?: number) {
const width = 56
function renderRow(label: string, value: string): string {
@@ -64,30 +246,35 @@ export function displayStats(stats: SessionStats) {
// Tool Usage section
if (Object.keys(stats.toolUsage).length > 0) {
const sortedTools = Object.entries(stats.toolUsage)
.sort(([, a], [, b]) => b - a)
.slice(0, 10)
const sortedTools = Object.entries(stats.toolUsage).sort(([, a], [, b]) => b - a)
const toolsToDisplay = toolLimit ? sortedTools.slice(0, toolLimit) : sortedTools
console.log("┌────────────────────────────────────────────────────────┐")
console.log("│ TOOL USAGE │")
console.log("├────────────────────────────────────────────────────────┤")
const maxCount = Math.max(...sortedTools.map(([, count]) => count))
const maxCount = Math.max(...toolsToDisplay.map(([, count]) => count))
const totalToolUsage = Object.values(stats.toolUsage).reduce((a, b) => a + b, 0)
for (const [tool, count] of sortedTools) {
for (const [tool, count] of toolsToDisplay) {
const barLength = Math.max(1, Math.floor((count / maxCount) * 20))
const bar = "█".repeat(barLength)
const percentage = ((count / totalToolUsage) * 100).toFixed(1)
const content = ` ${tool.padEnd(10)} ${bar.padEnd(20)} ${count.toString().padStart(3)} (${percentage.padStart(4)}%)`
const padding = Math.max(0, width - content.length)
const maxToolLength = 18
const truncatedTool =
tool.length > maxToolLength ? tool.substring(0, maxToolLength - 2) + ".." : tool
const toolName = truncatedTool.padEnd(maxToolLength)
const content = ` ${toolName} ${bar.padEnd(20)} ${count.toString().padStart(3)} (${percentage.padStart(4)}%)`
const padding = Math.max(0, width - content.length - 1)
console.log(`${content}${" ".repeat(padding)}`)
}
console.log("└────────────────────────────────────────────────────────┘")
}
console.log()
}
function formatNumber(num: number): string {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + "M"
-222
View File
@@ -1,222 +0,0 @@
import { Global } from "../../global"
import { Provider } from "../../provider/provider"
import { Server } from "../../server/server"
import { UI } from "../ui"
import { cmd } from "./cmd"
import path from "path"
import fs from "fs/promises"
import { Installation } from "../../installation"
import { Config } from "../../config/config"
import { Bus } from "../../bus"
import { Log } from "../../util/log"
import { Ide } from "../../ide"
import { Flag } from "../../flag/flag"
import { Session } from "../../session"
import { $ } from "bun"
import { bootstrap } from "../bootstrap"
declare global {
const OPENCODE_TUI_PATH: string
}
if (typeof OPENCODE_TUI_PATH !== "undefined") {
await import(OPENCODE_TUI_PATH as string, {
with: { type: "file" },
})
}
export const TuiCommand = cmd({
command: "$0 [project]",
describe: "start opencode tui",
builder: (yargs) =>
yargs
.positional("project", {
type: "string",
describe: "path to start opencode in",
})
.option("model", {
type: "string",
alias: ["m"],
describe: "model to use in the format of provider/model",
})
.option("continue", {
alias: ["c"],
describe: "continue the last session",
type: "boolean",
})
.option("session", {
alias: ["s"],
describe: "session id to continue",
type: "string",
})
.option("prompt", {
alias: ["p"],
type: "string",
describe: "prompt to use",
})
.option("agent", {
type: "string",
describe: "agent to use",
})
.option("port", {
type: "number",
describe: "port to listen on",
default: 0,
})
.option("hostname", {
type: "string",
describe: "hostname to listen on",
default: "127.0.0.1",
}),
handler: async (args) => {
while (true) {
const cwd = args.project ? path.resolve(args.project) : process.cwd()
try {
process.chdir(cwd)
} catch (e) {
UI.error("Failed to change directory to " + cwd)
return
}
const result = await bootstrap(cwd, async () => {
const sessionID = await (async () => {
if (args.continue) {
const it = Session.list()
try {
for await (const s of it) {
if (s.parentID === undefined) {
return s.id
}
}
return
} finally {
await it.return()
}
}
if (args.session) {
return args.session
}
return undefined
})()
const providers = await Provider.list()
if (Object.keys(providers).length === 0) {
return "needs_provider"
}
const server = Server.listen({
port: args.port,
hostname: args.hostname,
})
let cmd = [] as string[]
const tui = Bun.embeddedFiles.find((item) => (item as File).name.includes("tui")) as File
if (tui) {
let binaryName = tui.name
if (process.platform === "win32" && !binaryName.endsWith(".exe")) {
binaryName += ".exe"
}
const binary = path.join(Global.Path.cache, "tui", binaryName)
const file = Bun.file(binary)
if (!(await file.exists())) {
await Bun.write(file, tui, { mode: 0o755 })
if (process.platform !== "win32") await fs.chmod(binary, 0o755)
}
cmd = [binary]
}
if (!tui) {
const dir = Bun.fileURLToPath(new URL("../../../../tui/cmd/opencode", import.meta.url))
let binaryName = `./dist/tui${process.platform === "win32" ? ".exe" : ""}`
await $`go build -o ${binaryName} ./main.go`.cwd(dir)
cmd = [path.join(dir, binaryName)]
}
Log.Default.info("tui", {
cmd,
})
const proc = Bun.spawn({
cmd: [
...cmd,
...(args.model ? ["--model", args.model] : []),
...(args.prompt ? ["--prompt", args.prompt] : []),
...(args.agent ? ["--agent", args.agent] : []),
...(sessionID ? ["--session", sessionID] : []),
],
cwd,
stdout: "inherit",
stderr: "inherit",
stdin: "inherit",
env: {
...process.env,
CGO_ENABLED: "0",
OPENCODE_SERVER: server.url.toString(),
},
onExit: () => {
server.stop()
},
})
;(async () => {
// if (Installation.isLocal()) return
const config = await Config.get()
if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return
const latest = await Installation.latest().catch(() => {})
if (!latest) return
if (Installation.VERSION === latest) return
const method = await Installation.method()
if (method === "unknown") return
await Installation.upgrade(method, latest)
.then(() => Bus.publish(Installation.Event.Updated, { version: latest }))
.catch(() => {})
})()
;(async () => {
if (Ide.alreadyInstalled()) return
const ide = Ide.ide()
if (ide === "unknown") return
await Ide.install(ide)
.then(() => Bus.publish(Ide.Event.Installed, { ide }))
.catch(() => {})
})()
await proc.exited
server.stop()
return "done"
})
if (result === "done") break
if (result === "needs_provider") {
UI.empty()
UI.println(UI.logo(" "))
const result = await Bun.spawn({
cmd: [...getOpencodeCommand(), "auth", "login"],
cwd: process.cwd(),
stdout: "inherit",
stderr: "inherit",
stdin: "inherit",
}).exited
if (result !== 0) return
UI.empty()
}
}
},
})
/**
* Get the correct command to run opencode CLI
* In development: ["bun", "run", "packages/opencode/src/index.ts"]
* In production: ["/path/to/opencode"]
*/
function getOpencodeCommand(): string[] {
// Check if OPENCODE_BIN_PATH is set (used by shell wrapper scripts)
if (process.env["OPENCODE_BIN_PATH"]) {
return [process.env["OPENCODE_BIN_PATH"]]
}
const execPath = process.execPath.toLowerCase()
if (Installation.isLocal()) {
// In development, use bun to run the TypeScript entry point
return [execPath, "run", process.argv[1]]
}
// In production, use the current executable path
return [process.execPath]
}
@@ -30,6 +30,9 @@ import { TuiEvent } from "./event"
import { KVProvider, useKV } from "./context/kv"
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
// can't set raw mode if not a TTY
if (!process.stdin.isTTY) return "dark"
return new Promise((resolve) => {
let timeout: NodeJS.Timeout
@@ -177,6 +180,7 @@ function App() {
const exit = useExit()
useKeyboard(async (evt) => {
if (!Installation.isLocal()) return
if (evt.meta && evt.name === "t") {
renderer.toggleDebugOverlay()
return
@@ -354,6 +358,7 @@ function App() {
event.on(SessionApi.Event.Deleted.type, (evt) => {
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
dialog.clear()
route.navigate({ type: "home" })
toast.show({
variant: "info",
@@ -72,6 +72,7 @@ export function DialogSessionList() {
},
})
setToDelete(undefined)
// dialog.clear()
return
}
setToDelete(option.value)
@@ -52,14 +52,14 @@ export function Autocomplete(props: {
// Track props.value to make memo reactive to text changes
props.value // <- there surely is a better way to do this, like making .input() reactive
const val = props.input().getTextRange(store.index + 1, props.input().visualCursor.offset + 1)
const val = props.input().getTextRange(store.index + 1, props.input().cursorOffset + 1)
return val
})
function insertPart(text: string, part: PromptInfo["parts"][number]) {
const input = props.input()
const currentCursorOffset = input.visualCursor.offset
const currentCursorOffset = input.cursorOffset
const charAfterCursor = props.value.at(currentCursorOffset)
const needsSpace = charAfterCursor !== " "
@@ -204,7 +204,10 @@ export function Autocomplete(props: {
{
display: "/undo",
description: "undo the last message",
onSelect: () => command.trigger("session.undo"),
onSelect: () => {
hide()
command.trigger("session.undo")
},
},
{
display: "/redo",
@@ -266,6 +269,7 @@ export function Autocomplete(props: {
},
{
display: "/status",
aliases: ["/mcp"],
description: "show status",
onSelect: () => command.trigger("opencode.status"),
},
@@ -344,7 +348,7 @@ export function Autocomplete(props: {
command.keybinds(false)
setStore({
visible: mode,
index: props.input().visualCursor.offset,
index: props.input().cursorOffset,
position: {
x: props.anchor().x,
y: props.anchor().y,
@@ -368,8 +372,8 @@ export function Autocomplete(props: {
get visible() {
return store.visible
},
onInput(value: string) {
if (store.visible && Bun.stringWidth(value) <= store.index) hide()
onInput() {
if (store.visible && props.input().cursorOffset <= store.index) hide()
},
onKeyDown(e: KeyEvent) {
if (store.visible) {
@@ -381,23 +385,20 @@ export function Autocomplete(props: {
}
if (!store.visible) {
if (e.name === "@") {
const cursorOffset = props.input().visualCursor.offset
const cursorOffset = props.input().cursorOffset
const charBeforeCursor =
cursorOffset === 0
? undefined
: props.input().getTextRange(cursorOffset - 1, cursorOffset)
if (
charBeforeCursor === " " ||
charBeforeCursor === "\n" ||
charBeforeCursor === undefined
) {
show("@")
}
const canTrigger =
charBeforeCursor === undefined ||
charBeforeCursor === "" ||
/\s/.test(charBeforeCursor)
if (canTrigger) show("@")
}
if (e.name === "/") {
if (props.input().visualCursor.offset === 0) show("/")
if (props.input().cursorOffset === 0) show("/")
}
}
},
@@ -587,8 +587,12 @@ export function Prompt(props: PromptProps) {
return
}
if (e.name === "up" && input.visualCursor.visualRow === 0) input.cursorOffset = 0
if (e.name === "down" && input.visualCursor.visualRow === input.height - 1)
if (keybind.match("history_previous", e) && input.visualCursor.visualRow === 0)
input.cursorOffset = 0
if (
keybind.match("history_next", e) &&
input.visualCursor.visualRow === input.height - 1
)
input.cursorOffset = input.plainText.length
}
if (!autocomplete.visible) {
@@ -639,7 +643,7 @@ export function Prompt(props: PromptProps) {
} catch {}
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
if (lineCount >= 5) {
if (lineCount >= 5 && !sync.data.config.experimental?.disable_paste_summary) {
event.preventDefault()
const currentOffset = input.visualCursor.offset
const virtualText = `[Pasted ~${lineCount} lines]`
@@ -712,7 +716,8 @@ export function Prompt(props: PromptProps) {
<Match when={props.hint}>{props.hint!}</Match>
<Match when={true}>
<text fg={theme.text}>
ctrl+p <span style={{ fg: theme.textMuted }}>commands</span>
{keybind.print("command_list")}{" "}
<span style={{ fg: theme.textMuted }}>commands</span>
</text>
</Match>
</Switch>
@@ -73,6 +73,14 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex
return store.leader
},
parse(evt: ParsedKey): Keybind.Info {
if (evt.name === "\x1F")
return {
ctrl: true,
name: "_",
shift: false,
leader: false,
meta: false,
}
return {
ctrl: evt.ctrl,
name: evt.name,
@@ -9,6 +9,7 @@ import { iife } from "@/util/iife"
import { createSimpleContext } from "./helper"
import { useToast } from "../ui/toast"
import { createEventBus } from "@solid-primitives/event-bus"
import { Provider } from "@/provider/provider"
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
name: "Local",
@@ -38,7 +39,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
agent.set(props.initialAgent)
}
if (props.initialModel) {
const [providerID, modelID] = props.initialModel.split("/")
const { providerID, modelID } = Provider.parseModel(props.initialModel)
if (!providerID || !modelID)
return toast.show({
variant: "warning",
@@ -149,8 +150,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
const fallbackModel = createMemo(() => {
if (props.initialModel) {
const { providerID, modelID } = Provider.parseModel(props.initialModel)
if (isModelValid({ providerID, modelID })) {
return {
providerID,
modelID,
}
}
}
if (sync.data.config.model) {
const [providerID, modelID] = sync.data.config.model.split("/")
const { providerID, modelID } = Provider.parseModel(sync.data.config.model)
if (isModelValid({ providerID, modelID })) {
return {
providerID,
@@ -243,8 +254,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const setInitialPrompt = createEventBus<string>()
onMount(() => {
if (props.initialPrompt)
setInitialPrompt.emit(props.initialPrompt)
if (props.initialPrompt) setInitialPrompt.emit(props.initialPrompt)
})
const result = {
@@ -1,17 +1,38 @@
import { createMemo, Match, Show, Switch } from "solid-js"
import { type Accessor, createMemo, Match, Show, Switch } from "solid-js"
import { useRouteData } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { pipe, sumBy } from "remeda"
import { useTheme } from "@tui/context/theme"
import { SplitBorder } from "@tui/component/border"
import type { AssistantMessage } from "@opencode-ai/sdk"
import type { AssistantMessage, Session } from "@opencode-ai/sdk"
const Title = (props: { session: Accessor<Session> }) => {
const { theme } = useTheme()
return (
<text fg={theme.text}>
<span style={{ bold: true, fg: theme.accent }}>#</span>{" "}
<span style={{ bold: true }}>{props.session().title}</span>
</text>
)
}
const ContextInfo = (props: { context: Accessor<string | undefined>; cost: Accessor<string> }) => {
const { theme } = useTheme()
return (
<Show when={props.context()}>
<text fg={theme.textMuted} wrapMode="none" flexShrink={0}>
{props.context()} ({props.cost()})
</text>
</Show>
)
}
export function Header() {
const route = useRouteData("session")
const sync = useSync()
const { theme } = useTheme()
const session = createMemo(() => sync.session.get(route.sessionID)!)
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
const cost = createMemo(() => {
const total = pipe(
@@ -43,6 +64,8 @@ export function Header() {
return result
})
const { theme } = useTheme()
return (
<box
paddingLeft={1}
@@ -51,31 +74,34 @@ export function Header() {
borderColor={theme.backgroundElement}
flexShrink={0}
>
<text fg={theme.text}>
<span style={{ bold: true, fg: theme.accent }}>#</span>{" "}
<span style={{ bold: true }}>{session().title}</span>
</text>
<box flexDirection="row" justifyContent="space-between" gap={1}>
<box flexGrow={1} flexShrink={1}>
<Switch>
<Match when={session().share?.url}>
<text fg={theme.textMuted} wrapMode="word">
{session().share!.url}
</text>
</Match>
<Match when={true}>
<text fg={theme.text} wrapMode="word">
/share <span style={{ fg: theme.textMuted }}>to create a shareable link</span>
</text>
</Match>
</Switch>
<Show
when={shareEnabled()}
fallback={
<box flexDirection="row" justifyContent="space-between" gap={1}>
<Title session={session} />
<ContextInfo context={context} cost={cost} />
</box>
}
>
<Title session={session} />
<box flexDirection="row" justifyContent="space-between" gap={1}>
<box flexGrow={1} flexShrink={1}>
<Switch>
<Match when={session().share?.url}>
<text fg={theme.textMuted} wrapMode="word">
{session().share!.url}
</text>
</Match>
<Match when={true}>
<text fg={theme.text} wrapMode="word">
/share <span style={{ fg: theme.textMuted }}>to create a shareable link</span>
</text>
</Match>
</Switch>
</box>
<ContextInfo context={context} cost={cost} />
</box>
<Show when={context()}>
<text fg={theme.textMuted} wrapMode="none" flexShrink={0}>
{context()} ({cost()})
</text>
</Show>
</box>
</Show>
</box>
)
}
@@ -64,7 +64,6 @@ import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import parsers from "../../../../../../parsers-config.ts"
import { Clipboard } from "../../util/clipboard"
import { Toast, useToast } from "../../ui/toast"
import { DialogSessionRename } from "../../component/dialog-session-rename"
import { useKV } from "../../context/kv.tsx"
addDefaultParsers(parsers.parsers)
@@ -401,12 +400,49 @@ export function Session() {
},
},
{
title: "Rename session",
value: "session.rename",
keybind: "session_rename",
title: "Copy last assistant message",
value: "messages.copy",
keybind: "messages_copy",
category: "Session",
onSelect: (dialog) => {
dialog.replace(() => <DialogSessionRename session={route.sessionID} />)
const lastAssistantMessage = messages().findLast((msg) => msg.role === "assistant")
if (!lastAssistantMessage) {
toast.show({ message: "No assistant messages found", variant: "error" })
dialog.clear()
return
}
const parts = sync.data.part[lastAssistantMessage.id] ?? []
const textParts = parts.filter((part) => part.type === "text")
if (textParts.length === 0) {
toast.show({ message: "No text parts found in last assistant message", variant: "error" })
dialog.clear()
return
}
const text = textParts
.map((part) => part.text)
.join("\n")
.trim()
if (!text) {
toast.show({
message: "No text content found in last assistant message",
variant: "error",
})
dialog.clear()
return
}
console.log(text)
const base64 = Buffer.from(text).toString("base64")
const osc52 = `\x1b]52;c;${base64}\x07`
const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
/* @ts-expect-error */
renderer.writeOut(finalOsc52)
Clipboard.copy(text)
.then(() => toast.show({ message: "Message copied to clipboard!", variant: "success" }))
.catch(() => toast.show({ message: "Failed to copy to clipboard", variant: "error" }))
dialog.clear()
},
},
{
@@ -444,22 +480,26 @@ export function Session() {
const diffText = s.revert?.diff || ""
if (!diffText) return []
const patches = parsePatch(diffText)
return patches.map((patch) => {
const filename = patch.newFileName || patch.oldFileName || "unknown"
const cleanFilename = filename.replace(/^[ab]\//, "")
return {
filename: cleanFilename,
additions: patch.hunks.reduce(
(sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("+")).length,
0,
),
deletions: patch.hunks.reduce(
(sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("-")).length,
0,
),
}
})
try {
const patches = parsePatch(diffText)
return patches.map((patch) => {
const filename = patch.newFileName || patch.oldFileName || "unknown"
const cleanFilename = filename.replace(/^[ab]\//, "")
return {
filename: cleanFilename,
additions: patch.hunks.reduce(
(sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("+")).length,
0,
),
deletions: patch.hunks.reduce(
(sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("-")).length,
0,
),
}
})
} catch (error) {
return []
}
})()
return {
@@ -879,7 +919,7 @@ function ToolPart(props: { part: ToolPart; message: AssistantMessage }) {
const render = ToolRegistry.render(props.part.tool) ?? GenericTool
const metadata = props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {})
const input = props.part.state.input
const input = props.part.state.input ?? {}
const container = ToolRegistry.container(props.part.tool)
const permissions = sync.data.permission[props.message.sessionID] ?? []
const permissionIndex = permissions.findIndex((x) => x.callID === props.part.callID)
@@ -1209,58 +1249,63 @@ ToolRegistry.register<typeof EditTool>({
const diff = createMemo(() => {
const diff = props.metadata.diff ?? props.permission["diff"]
if (!diff) return null
const patches = parsePatch(diff)
if (patches.length === 0) return null
const patch = patches[0]
const oldLines: string[] = []
const newLines: string[] = []
try {
const patches = parsePatch(diff)
if (patches.length === 0) return null
for (const hunk of patch.hunks) {
let i = 0
while (i < hunk.lines.length) {
const line = hunk.lines[i]
const patch = patches[0]
const oldLines: string[] = []
const newLines: string[] = []
if (line.startsWith("-")) {
const removedLines: string[] = []
while (i < hunk.lines.length && hunk.lines[i].startsWith("-")) {
removedLines.push("- " + hunk.lines[i].slice(1))
for (const hunk of patch.hunks) {
let i = 0
while (i < hunk.lines.length) {
const line = hunk.lines[i]
if (line.startsWith("-")) {
const removedLines: string[] = []
while (i < hunk.lines.length && hunk.lines[i].startsWith("-")) {
removedLines.push("- " + hunk.lines[i].slice(1))
i++
}
const addedLines: string[] = []
while (i < hunk.lines.length && hunk.lines[i].startsWith("+")) {
addedLines.push("+ " + hunk.lines[i].slice(1))
i++
}
const maxLen = Math.max(removedLines.length, addedLines.length)
for (let j = 0; j < maxLen; j++) {
oldLines.push(removedLines[j] ?? "")
newLines.push(addedLines[j] ?? "")
}
} else if (line.startsWith("+")) {
const addedLines: string[] = []
while (i < hunk.lines.length && hunk.lines[i].startsWith("+")) {
addedLines.push("+ " + hunk.lines[i].slice(1))
i++
}
for (const added of addedLines) {
oldLines.push("")
newLines.push(added)
}
} else {
oldLines.push(" " + line.slice(1))
newLines.push(" " + line.slice(1))
i++
}
const addedLines: string[] = []
while (i < hunk.lines.length && hunk.lines[i].startsWith("+")) {
addedLines.push("+ " + hunk.lines[i].slice(1))
i++
}
const maxLen = Math.max(removedLines.length, addedLines.length)
for (let j = 0; j < maxLen; j++) {
oldLines.push(removedLines[j] ?? "")
newLines.push(addedLines[j] ?? "")
}
} else if (line.startsWith("+")) {
const addedLines: string[] = []
while (i < hunk.lines.length && hunk.lines[i].startsWith("+")) {
addedLines.push("+ " + hunk.lines[i].slice(1))
i++
}
for (const added of addedLines) {
oldLines.push("")
newLines.push(added)
}
} else {
oldLines.push(" " + line.slice(1))
newLines.push(" " + line.slice(1))
i++
}
}
}
return {
oldContent: oldLines.join("\n"),
newContent: newLines.join("\n"),
return {
oldContent: oldLines.join("\n"),
newContent: newLines.join("\n"),
}
} catch (error) {
return null
}
})
+27 -3
View File
@@ -8,6 +8,10 @@ import { bootstrap } from "@/cli/bootstrap"
import path from "path"
import { UI } from "@/cli/ui"
declare global {
const OPENCODE_WORKER_PATH: string
}
export const TuiThreadCommand = cmd({
command: "$0 [project]",
describe: "start opencode tui",
@@ -52,13 +56,27 @@ export const TuiThreadCommand = cmd({
default: "127.0.0.1",
}),
handler: async (args) => {
const cwd = args.project ? path.resolve(args.project) : process.cwd()
const prompt = await (async () => {
const piped = !process.stdin.isTTY ? await Bun.stdin.text() : undefined
if (!args.prompt) return piped
return piped ? piped + "\n" + args.prompt : args.prompt
})()
// Resolve relative paths against PWD to preserve behavior when using --cwd flag
const baseCwd = process.env.PWD ?? process.cwd()
const cwd = args.project ? path.resolve(baseCwd, args.project) : process.cwd()
let workerPath: string | URL = new URL("./worker.ts", import.meta.url)
if (typeof OPENCODE_WORKER_PATH !== "undefined") {
workerPath = OPENCODE_WORKER_PATH
}
try {
process.chdir(cwd)
} catch (e) {
UI.error("Failed to change directory to " + cwd)
return
}
await bootstrap(cwd, async () => {
upgrade()
@@ -82,7 +100,13 @@ export const TuiThreadCommand = cmd({
return undefined
})()
const worker = new Worker("./src/cli/cmd/tui/worker.ts")
const worker = new Worker(workerPath, {
env: Object.fromEntries(
Object.entries(process.env).filter(
(entry): entry is [string, string] => entry[1] !== undefined,
),
),
})
worker.onerror = console.error
const client = Rpc.client<typeof rpc>(worker)
process.on("uncaughtException", (e) => {
@@ -100,7 +124,7 @@ export const TuiThreadCommand = cmd({
sessionID,
model: args.model,
agent: args.agent,
prompt: args.prompt,
prompt,
onExit: async () => {
await client.call("shutdown", undefined)
},
@@ -2,6 +2,7 @@ import { TextareaRenderable, TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { onMount } from "solid-js"
import { useKeyboard } from "@opentui/solid"
export type DialogPromptProps = {
title: string
@@ -15,6 +16,13 @@ export function DialogPrompt(props: DialogPromptProps) {
const { theme } = useTheme()
let textarea: TextareaRenderable
useKeyboard((evt) => {
if (evt.name === "return") {
props.onConfirm?.(textarea.plainText)
dialog.clear()
}
})
onMount(() => {
dialog.setSize("large")
setTimeout(() => {
+41
View File
@@ -0,0 +1,41 @@
import { Server } from "../../server/server"
import { UI } from "../ui"
import { cmd } from "./cmd"
import open from "open"
export const WebCommand = cmd({
command: "web",
builder: (yargs) =>
yargs
.option("port", {
alias: ["p"],
type: "number",
describe: "port to listen on",
default: 0,
})
.option("hostname", {
type: "string",
describe: "hostname to listen on",
default: "127.0.0.1",
}),
describe: "starts a headless opencode server",
handler: async (args) => {
const hostname = args.hostname
const port = args.port
const server = Server.listen({
port,
hostname,
})
UI.empty()
UI.println(UI.logo(" "))
UI.empty()
UI.println(
UI.Style.TEXT_INFO_BOLD + " Web interface: ",
UI.Style.TEXT_NORMAL,
server.url.toString(),
)
open(server.url.toString()).catch(() => {})
await new Promise(() => {})
await server.stop()
},
})
+4 -3
View File
@@ -4,9 +4,10 @@ import { NamedError } from "../util/error"
export namespace UI {
const LOGO = [
[`█▀▀█ █▀▀█ █▀▀ █▀▀▄ `, `█▀▀ █▀▀█ █▀▀▄ █▀▀`],
[`░░█ █░░█ █▀▀ █░░█ `, `░░ █░░█ █░░█ █▀▀`],
[`▀▀▀▀ █▀▀▀ ▀▀▀ ▀ ▀ `, `▀▀▀ ▀▀▀▀ ▀▀▀ ▀▀▀`],
[`  `, ``],
[`▀▀█ █▀▀█ █▀▀▀▀▄ `, `▀▀▀ █▀▀█ █▀▀█ █▀▀`],
[`█░░█ █░░█ █▀▀▀ █░░█ `, `█░░░ █░░█ █░░█ █▀▀▀`],
[`▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ `, `▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`],
]
export const CancelledError = NamedError.create("UICancelledError", z.void())
+1 -1
View File
@@ -473,7 +473,7 @@ export namespace Config {
.default("shift+return,ctrl+j")
.describe("Insert newline in input"),
history_previous: z.string().optional().default("up").describe("Previous history item"),
history_next: z.string().optional().default("down").describe("Previous history item"),
history_next: z.string().optional().default("down").describe("Next history item"),
session_child_cycle: z
.string()
.optional()
+18 -6
View File
@@ -165,7 +165,11 @@ export namespace File {
const project = Instance.project
if (project.vcs !== "git") return []
const diffOutput = await $`git diff --numstat HEAD`.cwd(Instance.directory).quiet().nothrow().text()
const diffOutput = await $`git diff --numstat HEAD`
.cwd(Instance.directory)
.quiet()
.nothrow()
.text()
const changedFiles: Info[] = []
@@ -257,9 +261,14 @@ export namespace File {
if (project.vcs === "git") {
let diff = await $`git diff ${file}`.cwd(Instance.directory).quiet().nothrow().text()
if (!diff.trim()) diff = await $`git diff --staged ${file}`.cwd(Instance.directory).quiet().nothrow().text()
if (!diff.trim())
diff = await $`git diff --staged ${file}`.cwd(Instance.directory).quiet().nothrow().text()
if (diff.trim()) {
const original = await $`git show HEAD:${file}`.cwd(Instance.directory).quiet().nothrow().text()
const original = await $`git show HEAD:${file}`
.cwd(Instance.directory)
.quiet()
.nothrow()
.text()
const patch = structuredPatch(file, file, original, content, "old", "new", {
context: Infinity,
ignoreWhitespace: true,
@@ -307,12 +316,15 @@ export namespace File {
})
}
export async function search(input: { query: string; limit?: number }) {
export async function search(input: { query: string; limit?: number; dirs?: boolean }) {
log.info("search", { query: input.query })
const limit = input.limit ?? 100
const result = await state().then((x) => x.files())
if (!input.query) return result.dirs.toSorted().slice(0, limit)
const items = [...result.files, ...result.dirs]
if (!input.query)
return input.dirs !== false
? result.dirs.toSorted().slice(0, limit)
: result.files.slice(0, limit)
const items = input.dirs !== false ? [...result.files, ...result.dirs] : result.files
const sorted = fuzzysort.go(input.query, items, { limit: limit }).map((r) => r.target)
log.info("search", { query: input.query, results: sorted.length })
return sorted
+4 -1
View File
@@ -22,6 +22,7 @@ import { TuiThreadCommand } from "./cli/cmd/tui/thread"
import { TuiSpawnCommand } from "./cli/cmd/tui/spawn"
import { AcpCommand } from "./cli/cmd/acp"
import { EOL } from "os"
import { WebCommand } from "./cli/cmd/web"
process.on("unhandledRejection", (e) => {
Log.Default.error("rejection", {
@@ -61,7 +62,8 @@ const cli = yargs(hideBin(process.argv))
})(),
})
process.env["OPENCODE"] = "1"
process.env.AGENT = "1"
process.env.OPENCODE = "1"
Log.Default.info("opencode", {
version: Installation.VERSION,
@@ -81,6 +83,7 @@ const cli = yargs(hideBin(process.argv))
.command(AgentCommand)
.command(UpgradeCommand)
.command(ServeCommand)
.command(WebCommand)
.command(ModelsCommand)
.command(StatsCommand)
.command(ExportCommand)
+36 -22
View File
@@ -73,7 +73,7 @@ export namespace Installation {
},
{
name: "brew" as const,
command: () => $`brew list --formula opencode-ai`.throws(false).text(),
command: () => $`brew list --formula opencode`.throws(false).text(),
},
]
@@ -87,7 +87,7 @@ export namespace Installation {
for (const check of checks) {
const output = await check.command()
if (output.includes("opencode-ai")) {
if (output.includes(check.name === "brew" ? "opencode" : "opencode-ai")) {
return check.name
}
}
@@ -102,28 +102,42 @@ export namespace Installation {
}),
)
async function getBrewFormula() {
const tapFormula = await $`brew list --formula sst/tap/opencode`.throws(false).text()
if (tapFormula.includes("opencode")) return "sst/tap/opencode"
const coreFormula = await $`brew list --formula opencode`.throws(false).text()
if (coreFormula.includes("opencode")) return "opencode"
return "opencode"
}
export async function upgrade(method: Method, target: string) {
const cmd = (() => {
switch (method) {
case "curl":
return $`curl -fsSL https://opencode.ai/install | bash`.env({
...process.env,
VERSION: target,
})
case "npm":
return $`npm install -g opencode-ai@${target}`
case "pnpm":
return $`pnpm install -g opencode-ai@${target}`
case "bun":
return $`bun install -g opencode-ai@${target}`
case "brew":
return $`brew install sst/tap/opencode`.env({
HOMEBREW_NO_AUTO_UPDATE: "1",
})
default:
throw new Error(`Unknown method: ${method}`)
let cmd
switch (method) {
case "curl":
cmd = $`curl -fsSL https://opencode.ai/install | bash`.env({
...process.env,
VERSION: target,
})
break
case "npm":
cmd = $`npm install -g opencode-ai@${target}`
break
case "pnpm":
cmd = $`pnpm install -g opencode-ai@${target}`
break
case "bun":
cmd = $`bun install -g opencode-ai@${target}`
break
case "brew": {
const formula = await getBrewFormula()
cmd = $`brew install ${formula}`.env({
HOMEBREW_NO_AUTO_UPDATE: "1",
})
break
}
})()
default:
throw new Error(`Unknown method: ${method}`)
}
const result = await cmd.quiet().throws(false)
log.info("upgraded", {
method,
+24 -12
View File
@@ -100,7 +100,7 @@ export namespace MCP {
}
log.info("found", { key, type: mcp.type })
let mcpClient: MCPClient | undefined
let status: Status | undefined
let status: Status | undefined = undefined
if (mcp.type === "remote") {
const transports = [
@@ -142,7 +142,7 @@ export namespace MCP {
error: lastError.message,
})
status = {
status: "failed",
status: "failed" as const,
error: lastError.message,
}
return false
@@ -179,7 +179,7 @@ export namespace MCP {
error: error instanceof Error ? error.message : String(error),
})
status = {
status: "failed",
status: "failed" as const,
error: error instanceof Error ? error.message : String(error),
}
})
@@ -187,7 +187,7 @@ export namespace MCP {
if (!status) {
status = {
status: "failed",
status: "failed" as const,
error: "Unknown error",
}
}
@@ -202,13 +202,12 @@ export namespace MCP {
const result = await withTimeout(mcpClient.tools(), mcp.timeout ?? 5000).catch(() => {})
if (!result) {
await mcpClient.close()
status = {
status: "failed",
error: "Failed to get tools",
}
return {
mcpClient: undefined,
status,
status: {
status: "failed" as const,
error: "Failed to get tools",
},
}
}
@@ -228,10 +227,23 @@ export namespace MCP {
export async function tools() {
const result: Record<string, Tool> = {}
const s = await state()
for (const [clientName, client] of Object.entries(await clients())) {
for (const [toolName, tool] of Object.entries(await client.tools())) {
const sanitizedClientName = clientName.replace(/\s+/g, "_")
const sanitizedToolName = toolName.replace(/[-\s]+/g, "_")
const tools = await client.tools().catch((e) => {
log.error("failed to get tools", { clientName, error: e.message })
const failedStatus = {
status: "failed" as const,
error: e instanceof Error ? e.message : String(e),
}
s.status[clientName] = failedStatus
delete s.clients[clientName]
})
if (!tools) {
continue
}
for (const [toolName, tool] of Object.entries(tools)) {
const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
const sanitizedToolName = toolName.replace(/[^a-zA-Z0-9_-]/g, "_")
result[sanitizedClientName + "_" + sanitizedToolName] = tool
}
}
+4 -2
View File
@@ -34,8 +34,10 @@ export namespace Plugin {
for (let plugin of plugins) {
log.info("loading plugin", { path: plugin })
if (!plugin.startsWith("file://")) {
const [pkg, version] = plugin.split("@")
plugin = await BunProc.install(pkg, version ?? "latest")
const lastAtIndex = plugin.lastIndexOf("@")
const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin
const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest"
plugin = await BunProc.install(pkg, version)
}
const mod = await import(plugin)
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
+13 -1
View File
@@ -10,6 +10,7 @@ import {
import { Hono } from "hono"
import { cors } from "hono/cors"
import { stream, streamSSE } from "hono/streaming"
import { proxy } from "hono/proxy"
import { Session } from "../session"
import z from "zod"
import { Provider } from "../provider/provider"
@@ -1106,13 +1107,16 @@ export namespace Server {
"query",
z.object({
query: z.string(),
dirs: z.union([z.literal("true"), z.literal("false")]).optional(),
}),
),
async (c) => {
const query = c.req.valid("query").query
const dirs = c.req.valid("query").dirs
const results = await File.search({
query,
limit: 10,
dirs: dirs !== "false",
})
return c.json(results)
},
@@ -1693,7 +1697,15 @@ export namespace Server {
})
})
},
),
)
.all("/*", async (c) => {
return proxy(`https://desktop.dev.opencode.ai${c.req.path}`, {
...c.req,
headers: {
host: "desktop.dev.opencode.ai",
},
})
}),
)
export async function openapi() {
+3
View File
@@ -144,6 +144,9 @@ export const BashTool = Tool.define("bash", {
const proc = spawn(params.command, {
shell: true,
cwd: Instance.directory,
env: {
...process.env,
},
stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32",
})
@@ -4,6 +4,7 @@ import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import path from "path"
import fs from "fs/promises"
import { pathToFileURL } from "url"
test("loads config with defaults when no files exist", async () => {
await using tmp = await tmpdir()
@@ -350,3 +351,59 @@ test("gets config directories", async () => {
},
})
})
test("resolves scoped npm plugins in config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const pluginDir = path.join(dir, "node_modules", "@scope", "plugin")
await fs.mkdir(pluginDir, { recursive: true })
await Bun.write(
path.join(dir, "package.json"),
JSON.stringify({ name: "config-fixture", version: "1.0.0", type: "module" }, null, 2),
)
await Bun.write(
path.join(pluginDir, "package.json"),
JSON.stringify(
{
name: "@scope/plugin",
version: "1.0.0",
type: "module",
main: "./index.js",
},
null,
2,
),
)
await Bun.write(path.join(pluginDir, "index.js"), "export default {}\n")
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify(
{ $schema: "https://opencode.ai/config.json", plugin: ["@scope/plugin"] },
null,
2,
),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const pluginEntries = config.plugin ?? []
const baseUrl = pathToFileURL(path.join(tmp.path, "opencode.json")).href
const expected = import.meta.resolve("@scope/plugin", baseUrl)
expect(pluginEntries.includes(expected)).toBe(true)
const scopedEntry = pluginEntries.find((entry) => entry === expected)
expect(scopedEntry).toBeDefined()
expect(scopedEntry?.includes("/node_modules/@scope/plugin/")).toBe(true)
},
})
})
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, test } from "bun:test"
import { iife } from "../../src/util/iife"
describe("util.iife", () => {
test("should execute function immediately and return result", () => {
let called = false
const result = iife(() => {
called = true
return 42
})
expect(called).toBe(true)
expect(result).toBe(42)
})
test("should work with async functions", async () => {
let called = false
const result = await iife(async () => {
called = true
return "async result"
})
expect(called).toBe(true)
expect(result).toBe("async result")
})
test("should handle functions with no return value", () => {
let called = false
const result = iife(() => {
called = true
})
expect(called).toBe(true)
expect(result).toBeUndefined()
})
})
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, test } from "bun:test"
import { lazy } from "../../src/util/lazy"
describe("util.lazy", () => {
test("should call function only once", () => {
let callCount = 0
const getValue = () => {
callCount++
return "expensive value"
}
const lazyValue = lazy(getValue)
expect(callCount).toBe(0)
const result1 = lazyValue()
expect(result1).toBe("expensive value")
expect(callCount).toBe(1)
const result2 = lazyValue()
expect(result2).toBe("expensive value")
expect(callCount).toBe(1)
})
test("should preserve the same reference", () => {
const obj = { value: 42 }
const lazyObj = lazy(() => obj)
const result1 = lazyObj()
const result2 = lazyObj()
expect(result1).toBe(obj)
expect(result2).toBe(obj)
expect(result1).toBe(result2)
})
test("should work with different return types", () => {
const lazyString = lazy(() => "string")
const lazyNumber = lazy(() => 123)
const lazyBoolean = lazy(() => true)
const lazyNull = lazy(() => null)
const lazyUndefined = lazy(() => undefined)
expect(lazyString()).toBe("string")
expect(lazyNumber()).toBe(123)
expect(lazyBoolean()).toBe(true)
expect(lazyNull()).toBe(null)
expect(lazyUndefined()).toBe(undefined)
})
})
@@ -0,0 +1,21 @@
import { describe, expect, test } from "bun:test"
import { withTimeout } from "../../src/util/timeout"
describe("util.timeout", () => {
test("should resolve when promise completes before timeout", async () => {
const fastPromise = new Promise<string>((resolve) => {
setTimeout(() => resolve("fast"), 10)
})
const result = await withTimeout(fastPromise, 100)
expect(result).toBe("fast")
})
test("should reject when promise exceeds timeout", async () => {
const slowPromise = new Promise<string>((resolve) => {
setTimeout(() => resolve("slow"), 200)
})
await expect(withTimeout(slowPromise, 50)).rejects.toThrow("Operation timed out after 50ms")
})
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.0.15",
"version": "1.0.21",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
+9
View File
@@ -0,0 +1,9 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.0.15",
"version": "1.0.21",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
+2 -1
View File
@@ -163,7 +163,7 @@ export type KeybindsConfig = {
*/
history_previous?: string
/**
* Previous history item
* Next history item
*/
history_next?: string
/**
@@ -2346,6 +2346,7 @@ export type FindFilesData = {
query: {
directory?: string
query: string
dirs?: "true" | "false"
}
url: "/find/file"
}
+83
View File
@@ -0,0 +1,83 @@
# Automatically generated by SST
# pylint: disable=all
from typing import Any
class Resource:
class ADMIN_SECRET:
type: str
value: str
class AUTH_API_URL:
type: str
value: str
class AWS_SES_ACCESS_KEY_ID:
type: str
value: str
class AWS_SES_SECRET_ACCESS_KEY:
type: str
value: str
class Api:
type: str
url: str
class App:
name: str
stage: str
class AuthApi:
type: str
url: str
class AuthStorage:
type: str
class Bucket:
name: str
type: str
class Console:
type: str
url: str
class Database:
database: str
host: str
password: str
port: float
type: str
username: str
class Desktop:
type: str
url: str
class EMAILOCTOPUS_API_KEY:
type: str
value: str
class GITHUB_APP_ID:
type: str
value: str
class GITHUB_APP_PRIVATE_KEY:
type: str
value: str
class GITHUB_CLIENT_ID_CONSOLE:
type: str
value: str
class GITHUB_CLIENT_SECRET_CONSOLE:
type: str
value: str
class GOOGLE_CLIENT_ID:
type: str
value: str
class HONEYCOMB_API_KEY:
type: str
value: str
class LogProcessor:
type: str
class STRIPE_SECRET_KEY:
type: str
value: str
class STRIPE_WEBHOOK_SECRET:
type: str
value: str
class Web:
type: str
url: str
class ZEN_MODELS1:
type: str
value: str
class ZEN_MODELS2:
type: str
value: str
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.0.15",
"version": "1.0.21",
"type": "module",
"scripts": {
"dev": "bun run src/index.ts",
+8 -2
View File
@@ -1,3 +1,9 @@
/// <reference types="../../../sst-env.d.ts" />
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
export {}
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.0.15",
"version": "1.0.21",
"type": "module",
"exports": {
".": "./src/components/index.ts",
+3
View File
@@ -0,0 +1,3 @@
[data-component="code"] {
overflow: hidden;
}
+52
View File
@@ -0,0 +1,52 @@
import { type FileContents, File, FileOptions, LineAnnotation } from "@pierre/precision-diffs"
import { ComponentProps, createEffect, splitProps } from "solid-js"
export type CodeProps<T = {}> = FileOptions<T> & {
file: FileContents
annotations?: LineAnnotation<T>[]
class?: string
classList?: ComponentProps<"div">["classList"]
}
export function Code<T>(props: CodeProps<T>) {
let container!: HTMLDivElement
const [local, others] = splitProps(props, ["file", "class", "classList", "annotations"])
const file = () => local.file
createEffect(() => {
const instance = new File<T>({
theme: { dark: "oc-1-dark", light: "oc-1-light" }, // or any Shiki theme
overflow: "wrap", // or 'scroll'
themeType: "system", // 'system', 'light', or 'dark'
disableLineNumbers: false, // optional
// lang: 'typescript', // optional - auto-detected from filename if not provided
...others,
})
instance.render({
file: file(),
lineAnnotations: local.annotations,
containerWrapper: container,
})
})
return (
<div
data-component="code"
style={{
"--pjs-font-family": "var(--font-family-mono)",
"--pjs-font-size": "var(--font-size-small)",
"--pjs-line-height": "24px",
"--pjs-tab-size": 2,
"--pjs-font-features": "var(--font-family-mono--font-feature-settings)",
"--pjs-header-font-family": "var(--font-family-sans)",
"--pjs-gap-block": 0,
}}
classList={{
...(local.classList || {}),
[local.class ?? ""]: !!local.class,
}}
ref={container}
/>
)
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -2,6 +2,7 @@ export * from "./accordion"
export * from "./button"
export * from "./card"
export * from "./checkbox"
export * from "./code"
export * from "./collapsible"
export * from "./dialog"
export * from "./diff"
+1
View File
@@ -10,6 +10,7 @@
@import "../components/button.css" layer(components);
@import "../components/card.css" layer(components);
@import "../components/checkbox.css" layer(components);
@import "../components/code.css" layer(components);
@import "../components/diff.css" layer(components);
@import "../components/diff-changes.css" layer(components);
@import "../components/collapsible.css" layer(components);
+9
View File
@@ -0,0 +1,9 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/web",
"type": "module",
"version": "1.0.15",
"version": "1.0.21",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+2 -2
View File
@@ -109,9 +109,9 @@ if (image) {
</div>
<div class="col3">
<h3>Homebrew</h3>
<button class="command" data-command="brew install sst/tap/opencode">
<button class="command" data-command="brew install opencode">
<code>
<span>brew install</span> <span class="highlight">sst/tap/opencode</span>
<span>brew install</span> <span class="highlight">opencode</span>
</code>
<span class="copy">
<CopyIcon />
+1 -1
View File
@@ -67,7 +67,7 @@ You can also install it with the following commands:
- **Using Homebrew on macOS and Linux**
```bash
brew install sst/tap/opencode
brew install opencode
```
- **Using Paru on Arch Linux**
+1 -1
View File
@@ -114,7 +114,7 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**.
| Model | Input | Output | Cached Read | Cached Write |
| --------------------------------- | ------ | ------ | ----------- | ------------ |
| GLM 4.6 | $0.60 | $1.90 | $0.11 | - |
| GLM 4.6 | $0.60 | $2.20 | $0.10 | - |
| Kimi K2 | $0.60 | $2.50 | $0.36 | - |
| Qwen3 Coder 480B | $0.45 | $1.50 | - | - |
| Grok Code Fast 1 | Free | Free | - | - |
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.0.15",
"version": "1.0.21",
"publisher": "sst-dev",
"repository": {
"type": "git",
-101
View File
@@ -1,101 +0,0 @@
# Sprint: Agent MaxSteps Feature
## Problem Statement
Users need the ability to limit the number of steps that agents (especially subagents) can take during execution, as outlined in [Issue #3631](https://github.com/sst/opencode/issues/3631). This feature will prevent runaway agent loops and give users better control over agent execution limits.
## Context
### Existing System
OpenCode has an agentic loop system controlled in `packages/opencode/src/session/prompt.ts`. Currently, agents can run indefinitely without any step limits, which can lead to:
- Excessive resource consumption
- Runaway loops in faulty agent logic
- Unpredictable costs when using paid APIs
- Difficulty in debugging agent behavior
### MaxSteps Feature Requirements
The maxSteps feature must:
- Allow optional step limits for agent definitions
- Work for both primary agents and subagents (primary use case is subagents)
- Stop execution when the limit is reached
- On the n-1 step, remove all tools from the request to force a text-only response
- Be configurable per agent definition
- Default to unlimited if not specified (backward compatibility)
## Success Criteria
- [ ] Agent definitions accept optional `maxSteps` parameter
- [ ] Step counter tracks execution steps accurately
- [ ] Agent stops at maxSteps limit
- [ ] On step n-1, tools are removed from the request
- [ ] Final step produces a text-only response summarizing status
- [ ] Feature works for both primary and subagents
- [ ] No breaking changes to existing agent definitions
- [ ] Step limit is logged for debugging purposes
- [ ] Clear error/status message when limit is reached
- [ ] Tests cover various step limit scenarios
- [ ] Documentation updated with maxSteps usage
## Technical Requirements
### Agent Definition Schema
```typescript
interface AgentDefinition {
name: string;
description: string;
tools?: Tool[];
maxSteps?: number; // New optional field
// ... other existing fields
}
```
### Implementation Location
- Primary implementation in: `packages/opencode/src/session/prompt.ts`
- Agent definition types updated
- Step counter logic added to agentic loop
### Step Counting Logic
1. Initialize step counter when agent starts
2. Increment counter after each tool execution
3. Check if current step === maxSteps - 1
- If true: Remove tools from next request
4. Check if current step === maxSteps
- If true: Stop execution and return final response
### Edge Cases to Handle
- maxSteps = 0 (should be invalid)
- maxSteps = 1 (immediate text response)
- maxSteps = 2 (one tool call, then text)
- Nested subagent calls (each has own counter)
- Error handling when limit reached mid-operation
## Testing Strategy
1. Unit tests for step counter logic
2. Integration tests with mock agents
3. Test various maxSteps values (1, 2, 10, undefined)
4. Test tool removal on n-1 step
5. Test nested agent scenarios
6. Test error cases and boundary conditions
7. Performance tests to ensure no overhead when maxSteps not used
## Validation Checklist
- [ ] maxSteps parameter accepted in agent definitions
- [ ] Step counter increments correctly
- [ ] Execution stops at limit
- [ ] Tools removed on penultimate step
- [ ] Final response is text-only
- [ ] No regression in unlimited agents
- [ ] Logs show step count and limit
- [ ] Clear status message on limit reached
- [ ] Tests pass for all scenarios
- [ ] Documentation includes examples
## Example Usage
```typescript
const limitedAgent = {
name: "limited-helper",
description: "An agent with step limits",
maxSteps: 5, // Will stop after 5 steps
tools: [/* ... tools ... */]
};
// On step 4, tools will be removed
// On step 5, execution stops with text response
```
@@ -1,79 +0,0 @@
# Sprint: OpenCode Uninstaller Feature
## Problem Statement
Users need a reliable way to completely remove OpenCode from their system, including all configuration files, cached data, and installed components, as outlined in [Issue #3900](https://github.com/sst/opencode/issues/3900).
## Context
### Existing System
OpenCode is a CLI tool that installs various components and configurations across the system. Currently, there is no comprehensive uninstall command that cleanly removes all traces of the installation.
### Uninstaller Feature Requirements
The uninstaller must provide a complete removal process that:
- Removes the OpenCode binary/executable
- Cleans up configuration files from user directories
- Removes cached data and temporary files
- Provides options for selective removal (keep configs, etc.)
- Confirms actions before destructive operations
## Success Criteria
- [ ] Uninstall command (`opencode uninstall`) is available in the CLI
- [ ] Command removes OpenCode executable from installation path
- [ ] Configuration files in ~/.opencode are removed (with --all flag)
- [ ] Cache directories are cleaned up
- [ ] User is prompted for confirmation before removal
- [ ] Option to keep configuration files (--keep-config)
- [ ] Uninstaller provides clear feedback on what was removed
- [ ] Process is reversible by reinstalling OpenCode
- [ ] Exit code 0 on successful uninstallation
- [ ] Comprehensive error handling for permission issues
- [ ] Documentation updated with uninstall instructions
## Technical Requirements
### CLI Interface
```bash
opencode uninstall [options]
--all Remove everything including configs
--keep-config Keep configuration files
--force Skip confirmation prompts
--dry-run Show what would be removed without doing it
```
### File Locations to Handle
- **Binary**: `/usr/local/bin/opencode` or installation path
- **Config**: `~/.opencode/` directory
- **Cache**: `~/.cache/opencode/` directory
- **Temp**: `/tmp/opencode-*` files
- **Logs**: `~/.opencode/logs/` directory
### Implementation Steps
1. Parse command arguments and flags
2. Identify all OpenCode-related files and directories
3. Show user what will be removed (with confirmation)
4. Remove files in correct order (temp -> cache -> config -> binary)
5. Verify removal and report status
6. Clean up any remaining symlinks or PATH entries
### Error Handling
- Permission denied errors should suggest using sudo
- Missing files should not cause failure
- Partial uninstall should be reported clearly
- Network operations should have timeout handling
## Testing Strategy
1. Install OpenCode in a test container
2. Create configuration and cache files
3. Run uninstall command with various flags
4. Verify complete removal of all components
5. Test edge cases (permissions, missing files, etc.)
6. Validate that reinstallation works after uninstall
## Validation Checklist
- [ ] Binary file removed from system
- [ ] Config directory removed (when using --all)
- [ ] Cache directory cleaned up
- [ ] No orphaned files remain
- [ ] PATH environment cleaned if modified
- [ ] Exit codes match expected values
- [ ] Help text includes uninstall command
- [ ] Man page or docs updated
+9 -1
View File
@@ -5,6 +5,10 @@
declare module "sst" {
export interface Resource {
"ADMIN_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
"AUTH_API_URL": {
"type": "sst.sst.Linkable"
"value": string
@@ -91,7 +95,11 @@ declare module "sst" {
"type": "sst.cloudflare.Astro"
"url": string
}
"ZEN_MODELS": {
"ZEN_MODELS1": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS2": {
"type": "sst.sst.Secret"
"value": string
}