Compare commits

..

36 Commits

Author SHA1 Message Date
opencode ef39bf549a release: v1.0.22 2025-11-04 18:41:38 +00:00
Dax Raad 643352d20e fix session performance issue from large diffs 2025-11-04 13:35:44 -05:00
opencode 4ca50dda89 release: v1.0.21 2025-11-04 17:47:34 +00:00
Sebastian Herrlinger 3479a5d39f Revert "upgrade opentui to address disappearing content issues #3776, #3697"
This reverts commit a21cf346d7.
2025-11-04 18:38:44 +01:00
Dax Raad f1bc996cf5 local web 2025-11-04 12:33:14 -05:00
Aiden Cline bd69345e7f respect: disable_paste_summary 2025-11-04 11:29:12 -06:00
Aiden Cline 464709c69c fix: /undo command 2025-11-04 10:59:34 -06:00
Christian Stewart 57dc67f27a 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
Err 20a17386d1 fix: support scoped npm plugins (#3785)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-11-04 09:15:01 -06:00
Pranshu Raj f214076457 [FIX]: Refocus prompt after session delete (#3882) 2025-11-04 08:57:59 -06:00
frankdierolf 098cc000fe fix: correct history_next keybinding description (#3891) 2025-11-04 08:52:49 -06:00
arc-source-coder 05615b2929 tui: make /mcp an alias for /status (#3894) 2025-11-04 08:52:28 -06:00
Sebastian Herrlinger a21cf346d7 upgrade opentui to address disappearing content issues #3776, #3697 2025-11-04 14:57:15 +01:00
Adam d7bf177aa9 fix: lander specs 2025-11-04 06:15:12 -06:00
GitHub Action 86944cf792 ignore: update download stats 2025-11-04 2025-11-04 12:05:02 +00:00
Aiden Cline 7a45bf4e77 update brew handling 2025-11-04 00:52:26 -06:00
opencode 436640374e release: v1.0.20 2025-11-04 05:56:24 +00:00
Dax Raad abe25eda0e tui: fix tool permission lookup to use correct session ID 2025-11-04 00:50:12 -05:00
Dax Raad 9ebdccea03 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 03539a049f fix: piping 2025-11-03 22:36:10 -06:00
Dax Raad da1670a0c7 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 Pstrucha 30f3f7f6c8 Fix file tagging in multi line inputs (#3865) 2025-11-03 23:22:38 -05:00
Aiden Cline c5f902d7a3 ignore: delete unused code 2025-11-04 04:01:22 +00:00
Stephen Collings bbd2f1cebd fix: Provide OPENCODE & AGENT env vars (#3843)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-11-04 04:01:22 +00:00
Dax Raad 8c46a89732 tui: use keybind helper for history navigation to respect custom keybindings 2025-11-04 04:01:22 +00:00
opencode 7663b23420 release: v1.0.19 2025-11-04 04:01:22 +00:00
Dax Raad 3bd0c0899f 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
kaanmertkoc 6d17e71892 feat: implement stats command (#3832) 2025-11-03 18:41:30 -06:00
Mikhail Wahib baa9ef9e10 fix: print the modified keybind for command_list (#3859) 2025-11-03 18:29:23 -06:00
Jay V 3d17962fa2 ignore: lander 2025-11-03 18:17:32 -05:00
Frank 94b897eb5d wip: zen 2025-11-03 17:30:18 -05:00
Adi Yeroslav e949513444 fix: update logo (#3833) 2025-11-03 16:18:38 -06:00
Aiden Cline 0591f2f656 ci: auto label web 2025-11-03 16:17:53 -06:00
Dax Raad d943db71db fix: correct dirs parameter type in file search 2025-11-03 17:10:31 -05:00
Dax Raad 1fedcaf7ae tui: remove duplicate copy message command entry 2025-11-03 17:10:20 -05:00
Dax Raad 9076376ba6 ci: stuff 2025-11-03 22:01:30 +00:00
56 changed files with 1366 additions and 595 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
View File
@@ -11,3 +11,5 @@ playground
tmp
dist
.turbo
**/.serena
.serena/
+1
View File
@@ -129,3 +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,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.18",
"version": "1.0.22",
"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.18",
"version": "1.0.22",
"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.18",
"version": "1.0.22",
"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.18",
"version": "1.0.22",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -150,7 +150,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.0.18",
"version": "1.0.22",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "22.0.0",
@@ -166,7 +166,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.0.18",
"version": "1.0.22",
"bin": {
"opencode": "./bin/opencode",
},
@@ -243,7 +243,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.0.18",
"version": "1.0.22",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -263,7 +263,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.0.18",
"version": "1.0.22",
"devDependencies": {
"@hey-api/openapi-ts": "0.81.0",
"@tsconfig/node22": "catalog:",
@@ -274,7 +274,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.0.18",
"version": "1.0.22",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -287,7 +287,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.0.18",
"version": "1.0.22",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -317,7 +317,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.0.18",
"version": "1.0.22",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
+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.18"
"version": "1.0.22"
},
"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>
@@ -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
},
) {
@@ -248,12 +247,14 @@ export async function handler(
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.18",
"version": "1.0.22",
"private": true,
"type": "module",
"dependencies": {
+4
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(),
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.0.18",
"version": "1.0.22",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.0.18",
"version": "1.0.22",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/desktop",
"version": "1.0.18",
"version": "1.0.22",
"description": "",
"type": "module",
"scripts": {
+2 -2
View File
@@ -326,9 +326,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
const searchFiles = (query: string) =>
sdk.client.find.files({ query: { query, dirs: false } }).then((x) => x.data!)
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.client.find.files({ query: { query, dirs: "true" } }).then((x) => x.data!)
sdk.event.listen((e) => {
const event = e.details
+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)) {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.0.18",
"version": "1.0.22",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.0.18",
"version": "1.0.22",
"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}'`,
},
})
+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
@@ -355,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>
@@ -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 = {
@@ -467,47 +467,6 @@ export function Session() {
dialog.clear()
},
},
{
title: "Copy last assistant message",
value: "messages.copy",
keybind: "messages_copy",
category: "Session",
onSelect: (dialog) => {
const lastAssistantMessage = messages().findLast((msg) => msg.role === "assistant")
if (lastAssistantMessage) {
const parts = sync.data.part[lastAssistantMessage.id] ?? []
const textParts = parts.filter((part) => part.type === "text")
if (textParts.length > 0) {
const text = textParts
.map((part) => part.text)
.join("\n")
.trim()
if (text) {
Clipboard.copy(text)
.then(() =>
toast.show({ message: "Message copied to clipboard!", variant: "success" }),
)
.catch(() =>
toast.show({ message: "Failed to copy to clipboard", variant: "error" }),
)
} else {
toast.show({
message: "No text content found in last assistant message",
variant: "error",
})
}
} else {
toast.show({
message: "No text parts found in last assistant message",
variant: "error",
})
}
} else {
toast.show({ message: "No assistant messages found", variant: "error" })
}
dialog.clear()
},
},
])
const revert = createMemo(() => {
@@ -521,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 {
@@ -956,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)
@@ -1286,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)
},
+6 -3
View File
@@ -26,12 +26,15 @@ export const WebCommand = cmd({
port,
hostname,
})
const url = `https://desktop.dev.opencode.ai?url=${server.url}`
UI.empty()
UI.println(UI.logo(" "))
UI.empty()
UI.println(UI.Style.TEXT_INFO_BOLD + " Web interface: ", UI.Style.TEXT_NORMAL, url)
open(url).catch(() => {})
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()
+2 -1
View File
@@ -62,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,
+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,
+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)) {
+38 -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"
@@ -757,6 +758,34 @@ export namespace Server {
return c.json(messages)
},
)
.get(
"/session/:id/diff",
describeRoute({
description: "Get the diff for this session",
operationId: "session.diff",
responses: {
200: {
description: "List of diffs",
content: {
"application/json": {
schema: resolver(Snapshot.FileDiff.array()),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
id: z.string().meta({ description: "Session ID" }),
}),
),
async (c) => {
const diff = await Session.diff(c.req.valid("param").id)
return c.json(diff)
},
)
.get(
"/session/:id/message/:messageID",
describeRoute({
@@ -1696,7 +1725,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() {
+9 -2
View File
@@ -15,8 +15,8 @@ import { MessageV2 } from "./message-v2"
import { Instance } from "../project/instance"
import { SessionPrompt } from "./prompt"
import { fn } from "@/util/fn"
import { Snapshot } from "@/snapshot"
import { Command } from "../command"
import { Snapshot } from "@/snapshot"
export namespace Session {
const log = Log.create({ service: "session" })
@@ -42,7 +42,9 @@ export namespace Session {
parentID: Identifier.schema("session").optional(),
summary: z
.object({
diffs: Snapshot.FileDiff.array(),
additions: z.number(),
deletions: z.number(),
diffs: Snapshot.FileDiff.array().optional(),
})
.optional(),
share: z
@@ -258,6 +260,11 @@ export namespace Session {
return result
}
export const diff = fn(Identifier.schema("session"), async (sessionID) => {
const diffs = await Storage.read<Snapshot.FileDiff[]>(["session_diff", sessionID])
return diffs ?? []
})
export const messages = fn(Identifier.schema("session"), async (sessionID) => {
const result = [] as MessageV2.WithParts[]
for (const p of await Storage.list(["message", sessionID])) {
+4 -1
View File
@@ -11,6 +11,7 @@ import { SystemPrompt } from "./system"
import { Log } from "@/util/log"
import path from "path"
import { Instance } from "@/project/instance"
import { Storage } from "@/storage/storage"
export namespace SessionSummary {
const log = Log.create({ service: "session.summary" })
@@ -44,9 +45,11 @@ export namespace SessionSummary {
)
await Session.update(input.sessionID, (draft) => {
draft.summary = {
diffs,
additions: diffs.reduce((sum, x) => sum + x.additions, 0),
deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
}
})
await Storage.write(["session_diff", input.sessionID], diffs)
}
async function summarizeMessage(input: { messageID: string; messages: MessageV2.WithParts[] }) {
+33 -10
View File
@@ -85,7 +85,9 @@ export namespace Storage {
const session = await Bun.file(sessionFile).json()
await Bun.write(dest, JSON.stringify(session))
log.info(`migrating messages for session ${session.id}`)
for await (const msgFile of new Bun.Glob(`storage/session/message/${session.id}/*.json`).scan({
for await (const msgFile of new Bun.Glob(
`storage/session/message/${session.id}/*.json`,
).scan({
cwd: fullProjectDir,
absolute: true,
})) {
@@ -98,12 +100,12 @@ export namespace Storage {
await Bun.write(dest, JSON.stringify(message))
log.info(`migrating parts for message ${message.id}`)
for await (const partFile of new Bun.Glob(`storage/session/part/${session.id}/${message.id}/*.json`).scan(
{
cwd: fullProjectDir,
absolute: true,
},
)) {
for await (const partFile of new Bun.Glob(
`storage/session/part/${session.id}/${message.id}/*.json`,
).scan({
cwd: fullProjectDir,
absolute: true,
})) {
const dest = path.join(dir, "part", message.id, path.basename(partFile))
const part = await Bun.file(partFile).json()
log.info("copying", {
@@ -117,6 +119,29 @@ export namespace Storage {
}
}
},
async (dir) => {
for await (const item of new Bun.Glob("session/*/*.json").scan({
cwd: dir,
absolute: true,
})) {
const session = await Bun.file(item).json()
if (!session.projectID) continue
if (!session.summary?.diffs) continue
const { diffs } = session.summary
await Bun.file(path.join(dir, "session_diff", session.id + ".json")).write(
JSON.stringify(diffs),
)
await Bun.file(path.join(dir, "session", session.projectID, session.id + ".json")).write(
JSON.stringify({
...session,
summary: {
additions: diffs.reduce((sum: any, x: any) => sum + x.additions, 0),
deletions: diffs.reduce((sum: any, x: any) => sum + x.deletions, 0),
},
}),
)
}
},
]
const state = lazy(async () => {
@@ -128,9 +153,7 @@ export namespace Storage {
for (let index = migration; index < MIGRATIONS.length; index++) {
log.info("running migration", { index })
const migration = MIGRATIONS[index]
await migration(dir).catch((e) => {
log.error("failed to run migration", { error: e, index })
})
await migration(dir).catch(() => log.error("failed to run migration", { index }))
await Bun.write(path.join(dir, "migration"), (index + 1).toString())
}
return {
+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)
},
})
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.0.18",
"version": "1.0.22",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.0.18",
"version": "1.0.22",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
+7 -2
View File
@@ -55,6 +55,7 @@ import type {
SessionShareErrors,
SessionDiffData,
SessionDiffResponses,
SessionDiffErrors,
SessionSummarizeData,
SessionSummarizeResponses,
SessionSummarizeErrors,
@@ -475,12 +476,16 @@ class Session extends _HeyApiClient {
}
/**
* Get the diff that resulted from this user message
* Get the diff for this session
*/
public diff<ThrowOnError extends boolean = false>(
options: Options<SessionDiffData, ThrowOnError>,
) {
return (options.client ?? this._client).get<SessionDiffResponses, unknown, ThrowOnError>({
return (options.client ?? this._client).get<
SessionDiffResponses,
SessionDiffErrors,
ThrowOnError
>({
url: "/session/{id}/diff",
...options,
})
+21 -3
View File
@@ -163,7 +163,7 @@ export type KeybindsConfig = {
*/
history_previous?: string
/**
* Previous history item
* Next history item
*/
history_next?: string
/**
@@ -527,7 +527,9 @@ export type Session = {
directory: string
parentID?: string
summary?: {
diffs: Array<FileDiff>
additions: number
deletions: number
diffs?: Array<FileDiff>
}
share?: {
url: string
@@ -1882,6 +1884,9 @@ export type SessionShareResponse = SessionShareResponses[keyof SessionShareRespo
export type SessionDiffData = {
body?: never
path: {
/**
* Session ID
*/
id: string
}
query?: {
@@ -1891,9 +1896,22 @@ export type SessionDiffData = {
url: "/session/{id}/diff"
}
export type SessionDiffErrors = {
/**
* Bad request
*/
400: BadRequestError
/**
* Not found
*/
404: NotFoundError
}
export type SessionDiffError = SessionDiffErrors[keyof SessionDiffErrors]
export type SessionDiffResponses = {
/**
* Successfully retrieved diff
* List of diffs
*/
200: Array<FileDiff>
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.0.18",
"version": "1.0.22",
"type": "module",
"scripts": {
"dev": "bun run src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.0.18",
"version": "1.0.22",
"type": "module",
"exports": {
".": "./src/components/index.ts",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/web",
"type": "module",
"version": "1.0.18",
"version": "1.0.22",
"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**
+3 -10
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bun
import { $ } from "bun"
import { createOpencodeClient, createOpencodeServer } from "@opencode-ai/sdk"
import { createOpencode } from "@opencode-ai/sdk"
import { Script } from "@opencode-ai/script"
const notes = [] as string[]
@@ -24,15 +24,7 @@ if (!Script.preview) {
.filter((line) => line && !line.match(/^\w+ (ignore:|test:|chore:)/i))
.join("\n")
createOpencodeServer({
port: 4096,
}).catch(() => {})
await new Promise((resolve) => setTimeout(resolve, 1_000))
const opencode = {
client: createOpencodeClient({
baseUrl: `http://127.0.0.1:4096`,
}),
}
const opencode = await createOpencode()
const session = await opencode.client.session.create()
console.log("generating changelog since " + previous)
const raw = await opencode.client.session
@@ -76,6 +68,7 @@ if (!Script.preview) {
}
}
console.log(notes)
opencode.server.close()
}
const pkgjsons = await Array.fromAsync(
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.0.18",
"version": "1.0.22",
"publisher": "sst-dev",
"repository": {
"type": "git",