OpenTUI is here (#2685)

This commit is contained in:
Dax
2025-10-31 15:07:36 -04:00
committed by GitHub
parent 81c617770d
commit 96bdeb3c7b
104 changed files with 8459 additions and 716 deletions

View File

@@ -1,2 +1,2 @@
#!/bin/sh
bun run typecheck
bun typecheck

View File

@@ -18,3 +18,6 @@ For anything in the packages/app use the ignore: prefix.
prefer to explain WHY something was done from an end user perspective instead of
WHAT was done.
do not do generic messages like "improvied agent experience" be very specific
about what user facing changes were made

53
CHANGES.md Normal file
View File

@@ -0,0 +1,53 @@
# OpenCode 1.0
OpenCode 1.0 is a rewrite of the TUI
We went from the go+bubbletea based TUI which suffered from both performance and capability issues to an in-house
framework (OpenTUI) written in zig+solidjs.
The new TUI mostly works like the old one as it's connecting to the same
opencode server.
There are some notable UX changes:
1. The session history is more compressed, only showing the full details of the edit
and bash tool.
2. We've added a command bar which almost everything flows through. Can press
ctrl+p to bring it up in any context and see everything you can do.
3. Added a session sidebar (can be toggled) with some useful information.
We've also stripped out some functionality that we were not sure if anyone
actually used - if something important is missing please open an issue and we'll add it back
quickly.
### Breaking Changes
## Keybinds
### Renamed
- messages_revert -> messages_undo
- switch_agent -> agent_cycle
- switch_agent_reverse -> agent_cycle_reverse
- switch_mode -> agent_cycle
- switch_mode_reverse -> agent_cycle_reverse
### Removed
- messages_layout_toggle
- messages_next
- messages_previous
- file_diff_toggle
- file_search
- file_close
- file_list
- app_help
- project_init
- tool_details
- thinking_blocks
- session_child_cycle
- session_child_cycle_reverse
- model_cycle_recent
- model_cycle_recent_reverse

728
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
{
"keep": {
"days": true,
"amount": 14
},
"auditLog": "/home/thdxr/dev/projects/sst/opencode/logs/.2c5480b3b2480f80fa29b850af461dce619c0b2f-audit.json",
"files": [
{
"date": 1759827172859,
"name": "/home/thdxr/dev/projects/sst/opencode/logs/mcp-puppeteer-2025-10-07.log",
"hash": "a3d98b26edd793411b968a0d24cfeee8332138e282023c3b83ec169d55c67f16"
}
],
"hashType": "sha256"
}

View File

@@ -0,0 +1,48 @@
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:52.879"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:52.880"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:56.191"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:56.192"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:59.267"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:59.268"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:20.276"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:20.277"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:30.838"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:30.839"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:42.452"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:42.452"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:46.499"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:46.500"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:02.295"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:02.295"}
{"arguments":{"url":"https://google.com"},"level":"debug","message":"Tool call received","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:37.150","tool":"puppeteer_navigate"}
{"0":"n","1":"p","2":"x","level":"info","message":"Launching browser with config:","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:37.150"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:55:08.488"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:55:08.489"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:11.815"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:11.816"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:21.934"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:21.935"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:32.544"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:32.544"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:41.154"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:41.155"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:55.426"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:55.427"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:15.715"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:15.716"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:25.063"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:25.064"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:48.567"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:48.568"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:25:08.937"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:25:08.938"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:37.120"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:37.121"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:52.490"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:52.491"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:39:25.524"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:39:25.525"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:40:57.126"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:40:57.127"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:42:24.175"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:42:24.176"}

View File

@@ -1,3 +1,17 @@
{
"$schema": "https://opencode.ai/config.json"
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-openai-codex-auth"],
"mcp": {
"weather": {
"type": "local",
"command": ["bun", "x", "@h1deya/mcp-server-weather"]
},
"context7": {
"type": "remote",
"url": "https://mcp.context7.com/mcp",
"headers": {
"CONTEXT7_API_KEY": "{env:CONTEXT7_API_KEY}"
}
}
}
}

View File

@@ -1,13 +1,15 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"private": true,
"type": "module",
"packageManager": "bun@1.3.0",
"scripts": {
"dev": "bun run packages/opencode/src/index.ts",
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"typecheck": "bun turbo typecheck",
"prepare": "husky"
"prepare": "husky",
"random": "echo 'Random script'"
},
"workspaces": {
"packages": [
@@ -19,6 +21,7 @@
"catalog": {
"@types/bun": "1.3.0",
"@hono/zod-validator": "0.4.2",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/node": "22.13.9",
"@tsconfig/node22": "22.0.2",

View File

@@ -11,9 +11,11 @@
},
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode-ai/console-core": "workspace:*",
"@opencode-ai/console-mail": "workspace:*",
"@openauthjs/openauth": "catalog:",
"@kobalte/core": "catalog:",
"@jsx-email/render": "1.1.1",
"@opencode-ai/console-resource": "workspace:*",
"@solidjs/meta": "^0.29.4",
"@solidjs/router": "^0.15.0",

View File

@@ -14,7 +14,7 @@
"drizzle-orm": "0.41.0",
"postgres": "3.4.7",
"stripe": "18.0.0",
"ulid": "3.0.0",
"ulid": "catalog:",
"zod": "catalog:"
},
"exports": {

View File

@@ -1,4 +1,16 @@
import type { Message, Agent, Provider, Session, Part, Config, Path, File, FileNode, Project } from "@opencode-ai/sdk"
import type {
Message,
Agent,
Provider,
Session,
Part,
Config,
Path,
File,
FileNode,
Project,
Command,
} from "@opencode-ai/sdk"
import { createStore, produce, reconcile } from "solid-js/store"
import { createMemo } from "solid-js"
import { Binary } from "@/utils/binary"

View File

@@ -238,10 +238,16 @@ export default new Hono<{ Bindings: Env }>()
// Lookup installation
const octokit = new Octokit({ auth: appAuth.token })
const { data: installation } = await octokit.apps.getRepoInstallation({ owner, repo })
const { data: installation } = await octokit.apps.getRepoInstallation({
owner,
repo,
})
// Get installation token
const installationAuth = await auth({ type: "installation", installationId: installation.id })
const installationAuth = await auth({
type: "installation",
installationId: installation.id,
})
return c.json({ token: installationAuth.token })
})
@@ -274,10 +280,16 @@ export default new Hono<{ Bindings: Env }>()
// Lookup installation
const appClient = new Octokit({ auth: appAuth.token })
const { data: installation } = await appClient.apps.getRepoInstallation({ owner, repo })
const { data: installation } = await appClient.apps.getRepoInstallation({
owner,
repo,
})
// Get installation token
const installationAuth = await auth({ type: "installation", installationId: installation.id })
const installationAuth = await auth({
type: "installation",
installationId: installation.id,
})
return c.json({ token: installationAuth.token })
} catch (e: any) {

View File

@@ -1,2 +1,4 @@
preload = ["@opentui/solid/preload"]
[test]
preload = ["./test/preload.ts"]

View File

@@ -8,7 +8,8 @@
"typecheck": "tsgo --noEmit",
"test": "bun test",
"build": "./script/build.ts",
"dev": "bun run ./src/index.ts"
"dev": "bun run --conditions=browser ./src/index.ts",
"random": "echo 'Random script updated at $(date)'"
},
"bin": {
"opencode": "./bin/opencode"
@@ -19,6 +20,7 @@
"devDependencies": {
"@ai-sdk/amazon-bedrock": "2.2.10",
"@ai-sdk/google-vertex": "3.0.16",
"@babel/core": "7.28.4",
"@octokit/webhooks-types": "7.6.1",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
@@ -27,12 +29,15 @@
"@parcel/watcher-win32-x64": "2.5.1",
"@standard-schema/spec": "1.0.0",
"@tsconfig/bun": "catalog:",
"@types/babel__core": "7.20.5",
"@types/bun": "catalog:",
"@types/turndown": "5.0.5",
"@types/yargs": "17.0.33",
"typescript": "catalog:",
"@typescript/native-preview": "catalog:",
"vscode-languageserver-types": "3.17.5",
"why-is-node-running": "3.2.2",
"zod-to-json-schema": "3.24.5",
"@opencode-ai/script": "workspace:*"
},
"dependencies": {
@@ -49,12 +54,16 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opentui/core": "0.0.0-20251031-fc297165",
"@opentui/solid": "0.0.0-20251031-fc297165",
"@parcel/watcher": "2.5.1",
"@solid-primitives/event-bus": "1.1.2",
"@pierre/precision-diffs": "catalog:",
"@standard-schema/spec": "1.0.0",
"@zip.js/zip.js": "2.7.62",
"ai": "catalog:",
"chokidar": "4.0.3",
"clipboardy": "4.0.0",
"decimal.js": "10.5.0",
"diff": "catalog:",
"fuzzysort": "3.1.0",
@@ -65,13 +74,14 @@
"jsonc-parser": "3.3.1",
"minimatch": "10.0.3",
"open": "10.1.2",
"partial-json": "0.1.7",
"remeda": "catalog:",
"tree-sitter": "0.22.4",
"tree-sitter-bash": "0.23.3",
"solid-js": "catalog:",
"tree-sitter-bash": "0.25.0",
"turndown": "7.2.0",
"ulid": "3.0.1",
"ulid": "catalog:",
"vscode-jsonrpc": "8.2.1",
"web-tree-sitter": "0.22.6",
"web-tree-sitter": "0.25.10",
"xdg-basedir": "5.1.0",
"yargs": "18.0.0",
"zod": "catalog:",

View File

@@ -0,0 +1,207 @@
export default {
// NOTE: FOR markdown, javascript and typescript, we use the opentui built-in parsers
// Warn: when taking queries from the nvim-treesitter repo, make sure to include the query dependencies as well
// marked with for example `; inherits: ecma` at the top of the file. Just put the dependencies before the actual query.
// ALSO: Some queries use breaking changes in the nvim-treesitter repo, that are not compatible with the (web-)tree-sitter parser.
parsers: [
{
filetype: "python",
wasm: "https://github.com/tree-sitter/tree-sitter-python/releases/download/v0.23.6/tree-sitter-python.wasm",
queries: {
highlights: [
// NOTE: This nvim-treesitter query is currently broken, because the parser is not compatible with the query apparently.
// it is using "except" nodes that the parser is complaining about, but it has been in the query for 3+ years.
// Unclear.
// "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/python/highlights.scm",
"https://github.com/tree-sitter/tree-sitter-python/raw/refs/heads/master/queries/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/python/locals.scm",
],
},
},
{
filetype: "rust",
wasm: "https://github.com/tree-sitter/tree-sitter-rust/releases/download/v0.24.0/tree-sitter-rust.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/rust/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/rust/locals.scm",
],
},
},
{
filetype: "go",
wasm: "https://github.com/tree-sitter/tree-sitter-go/releases/download/v0.25.0/tree-sitter-go.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/go/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/go/locals.scm",
],
},
},
{
filetype: "cpp",
wasm: "https://github.com/tree-sitter/tree-sitter-cpp/releases/download/v0.23.4/tree-sitter-cpp.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/cpp/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/cpp/locals.scm",
],
},
},
{
filetype: "csharp",
wasm: "https://github.com/tree-sitter/tree-sitter-c-sharp/releases/download/v0.23.1/tree-sitter-c_sharp.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/c_sharp/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/c_sharp/locals.scm",
],
},
},
{
filetype: "bash",
wasm: "https://github.com/tree-sitter/tree-sitter-bash/releases/download/v0.25.0/tree-sitter-bash.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/bash/highlights.scm",
],
},
},
{
filetype: "c",
wasm: "https://github.com/tree-sitter/tree-sitter-c/releases/download/v0.24.1/tree-sitter-c.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/c/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/c/locals.scm",
],
},
},
{
filetype: "java",
wasm: "https://github.com/tree-sitter/tree-sitter-java/releases/download/v0.23.5/tree-sitter-java.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/java/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/java/locals.scm",
],
},
},
{
filetype: "ruby",
wasm: "https://github.com/tree-sitter/tree-sitter-ruby/releases/download/v0.23.1/tree-sitter-ruby.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/ruby/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/ruby/locals.scm",
],
},
},
{
filetype: "php",
wasm: "https://github.com/tree-sitter/tree-sitter-php/releases/download/v0.24.2/tree-sitter-php.wasm",
queries: {
highlights: [
// NOTE: This nvim-treesitter query is currently broken, because the parser is not compatible with the query apparently.
// "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/php/highlights.scm",
"https://github.com/tree-sitter/tree-sitter-php/raw/refs/heads/master/queries/highlights.scm",
],
},
},
{
filetype: "scala",
wasm: "https://github.com/tree-sitter/tree-sitter-scala/releases/download/v0.24.0/tree-sitter-scala.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/scala/highlights.scm",
],
},
},
{
filetype: "html",
wasm: "https://github.com/tree-sitter/tree-sitter-html/releases/download/v0.23.2/tree-sitter-html.wasm",
queries: {
highlights: [
// NOTE: This nvim-treesitter query is currently broken, because the parser is not compatible with the query apparently.
// "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/html/highlights.scm",
"https://github.com/tree-sitter/tree-sitter-html/raw/refs/heads/master/queries/highlights.scm",
],
// TODO: Injections not working for some reason
// injections: [
// "https://github.com/tree-sitter/tree-sitter-html/raw/refs/heads/master/queries/injections.scm",
// ],
},
// injectionMapping: {
// nodeTypes: {
// script_element: "javascript",
// style_element: "css",
// },
// infoStringMap: {
// javascript: "javascript",
// css: "css",
// },
// },
},
{
filetype: "json",
wasm: "https://github.com/tree-sitter/tree-sitter-json/releases/download/v0.24.8/tree-sitter-json.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/json/highlights.scm",
],
},
},
{
filetype: "haskell",
wasm: "https://github.com/tree-sitter/tree-sitter-haskell/releases/download/v0.23.1/tree-sitter-haskell.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/haskell/highlights.scm",
],
},
},
{
filetype: "css",
wasm: "https://github.com/tree-sitter/tree-sitter-css/releases/download/v0.25.0/tree-sitter-css.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/css/highlights.scm",
],
},
},
{
filetype: "julia",
wasm: "https://github.com/tree-sitter/tree-sitter-julia/releases/download/v0.23.1/tree-sitter-julia.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/julia/highlights.scm",
],
},
},
{
filetype: "ocaml",
wasm: "https://github.com/tree-sitter/tree-sitter-ocaml/releases/download/v0.24.2/tree-sitter-ocaml.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/ocaml/highlights.scm",
],
},
},
],
}

View File

@@ -1,5 +1,9 @@
#!/usr/bin/env bun
import solidPlugin from "../node_modules/@opentui/solid/scripts/solid-plugin"
import path from "path"
import fs from "fs"
import { $ } from "bun"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
@@ -7,18 +11,13 @@ const __dirname = path.dirname(__filename)
const dir = path.resolve(__dirname, "..")
process.chdir(dir)
import { $ } from "bun"
import pkg from "../package.json"
import { Script } from "@opencode-ai/script"
const GOARCH: Record<string, string> = {
arm64: "arm64",
x64: "amd64",
"x64-baseline": "amd64",
}
const singleFlag = process.argv.includes("--single")
const targets = [
const allTargets = [
["windows", "x64"],
["linux", "arm64"],
["linux", "x64"],
@@ -28,6 +27,10 @@ const targets = [
["darwin", "arm64"],
]
const targets = singleFlag
? allTargets.filter(([os, arch]) => os === process.platform && arch === process.arch)
: allTargets
await $`rm -rf dist`
const binaries: Record<string, string> = {}
@@ -35,16 +38,22 @@ for (const [os, arch] of targets) {
console.log(`building ${os}-${arch}`)
const name = `${pkg.name}-${os}-${arch}`
await $`mkdir -p dist/${name}/bin`
await $`CGO_ENABLED=0 GOOS=${os} GOARCH=${GOARCH[arch]} go build -ldflags="-s -w -X main.Version=${Script.version}" -o ../opencode/dist/${name}/bin/tui ../tui/cmd/opencode/main.go`
.cwd("../tui")
.quiet()
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 $`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" : ""}`
await $`mkdir -p ../../node_modules/${watcher}`
await $`npm pack npm pack ${watcher}`.cwd(path.join(dir, "../../node_modules")).quiet()
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"))
await Bun.build({
conditions: ["browser"],
tsconfig: "./tsconfig.json",
plugins: [solidPlugin],
sourcemap: "external",
compile: {
target: `bun-${os}-${arch}` as any,
@@ -52,13 +61,14 @@ for (const [os, arch] of targets) {
execArgv: [`--user-agent=opencode/${Script.version}`, `--env-file=""`, `--`],
windows: {},
},
entrypoints: ["./src/index.ts"],
entrypoints: ["./src/index.ts", parserWorker, "./src/cli/cmd/tui/worker.ts"],
define: {
OPENCODE_VERSION: `'${Script.version}'`,
OTUI_TREE_SITTER_WORKER_PATH: "/$bunfs/root/" + path.relative(dir, parserWorker),
OPENCODE_CHANNEL: `'${Script.channel}'`,
OPENCODE_TUI_PATH: `'../../../dist/${name}/bin/tui'`,
},
})
await $`rm -rf ./dist/${name}/bin/tui`
await Bun.file(`dist/${name}/package.json`).write(
JSON.stringify(

View File

@@ -25,8 +25,8 @@ await Bun.file(`./dist/${pkg.name}/package.json`).write(
[pkg.name]: `./bin/${pkg.name}`,
},
scripts: {
preinstall: "node ./preinstall.mjs",
postinstall: "node ./postinstall.mjs",
preinstall: "bun ./preinstall.mjs || node ./preinstall.mjs",
postinstall: "bun ./postinstall.mjs || node ./postinstall.mjs",
},
version: Script.version,
optionalDependencies: binaries,

View File

@@ -74,7 +74,10 @@ export namespace BunProc {
// - If .npmrc files exist, Bun will use them automatically
// - If no .npmrc files exist, Bun will default to https://registry.npmjs.org
// - No need to pass --registry flag
log.info("installing package using Bun's default registry resolution", { pkg, version })
log.info("installing package using Bun's default registry resolution", {
pkg,
version,
})
await BunProc.run(args, {
cwd: Global.Path.cache,

View File

@@ -1,65 +0,0 @@
import { Global } from "../../global"
import { cmd } from "./cmd"
import path from "path"
import fs from "fs/promises"
import { Log } from "../../util/log"
import { $ } from "bun"
export const AttachCommand = cmd({
command: "attach <server>",
describe: "attach to a running opencode server",
builder: (yargs) =>
yargs
.positional("server", {
type: "string",
describe: "http://localhost:4096",
})
.option("session", {
alias: ["s"],
describe: "session id to continue",
type: "string",
}),
handler: async (args) => {
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)]
}
if (args.session) {
cmd.push("--session", args.session)
}
Log.Default.info("tui", {
cmd,
})
const proc = Bun.spawn({
cmd,
stdout: "inherit",
stderr: "inherit",
stdin: "inherit",
env: {
...process.env,
CGO_ENABLED: "0",
OPENCODE_SERVER: args.server,
},
})
await proc.exited
},
})

View File

@@ -80,7 +80,7 @@ export const AuthLoginCommand = cmd({
UI.empty()
prompts.intro("Add credential")
if (args.url) {
const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json())
const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json() as any)
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
const proc = Bun.spawn({
cmd: wellknown.auth.command,

View File

@@ -1,5 +1,4 @@
import path from "path"
import { $ } from "bun"
import { exec } from "child_process"
import * as prompts from "@clack/prompts"
import { map, pipe, sortBy, values } from "remeda"
@@ -20,6 +19,7 @@ import { Provider } from "../../provider/provider"
import { Bus } from "../../bus"
import { MessageV2 } from "../../session/message-v2"
import { SessionPrompt } from "@/session/prompt"
import { $ } from "bun"
type GitHubAuthor = {
login: string

View File

@@ -0,0 +1,327 @@
import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { Clipboard } from "@tui/util/clipboard"
import { TextAttributes } from "@opentui/core"
import { RouteProvider, useRoute, type Route } from "@tui/context/route"
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal } from "solid-js"
import { Installation } from "@/installation"
import { Global } from "@/global"
import { DialogProvider, useDialog } from "@tui/ui/dialog"
import { SDKProvider, useSDK } from "@tui/context/sdk"
import { SyncProvider, useSync } from "@tui/context/sync"
import { LocalProvider, useLocal } from "@tui/context/local"
import { DialogModel } from "@tui/component/dialog-model"
import { DialogStatus } from "@tui/component/dialog-status"
import { DialogThemeList } from "@tui/component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help"
import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
import { DialogAgent } from "@tui/component/dialog-agent"
import { DialogSessionList } from "@tui/component/dialog-session-list"
import { KeybindProvider } from "@tui/context/keybind"
import { ThemeProvider, useTheme } from "@tui/context/theme"
import { Home } from "@tui/routes/home"
import { Session } from "@tui/routes/session"
import { PromptHistoryProvider } from "./component/prompt/history"
import { DialogAlert } from "./ui/dialog-alert"
import { ToastProvider, useToast } from "./ui/toast"
import { ExitProvider } from "./context/exit"
import type { SessionRoute } from "./context/route"
import { Session as SessionApi } from "@/session"
import { TuiEvent } from "./event"
export function tui(input: {
url: string
sessionID?: string
model?: string
agent?: string
onExit?: () => Promise<void>
}) {
// promise to prevent immediate exit
return new Promise<void>((resolve) => {
const routeData: Route | undefined = input.sessionID
? {
type: "session",
sessionID: input.sessionID,
}
: undefined
const onExit = async () => {
await input.onExit?.()
resolve()
}
render(
() => {
return (
<ErrorBoundary fallback={<text>Something went wrong</text>}>
<ExitProvider onExit={onExit}>
<ToastProvider>
<RouteProvider data={routeData}>
<SDKProvider url={input.url}>
<SyncProvider>
<ThemeProvider>
<LocalProvider initialModel={input.model} initialAgent={input.agent}>
<KeybindProvider>
<DialogProvider>
<CommandProvider>
<PromptHistoryProvider>
<App />
</PromptHistoryProvider>
</CommandProvider>
</DialogProvider>
</KeybindProvider>
</LocalProvider>
</ThemeProvider>
</SyncProvider>
</SDKProvider>
</RouteProvider>
</ToastProvider>
</ExitProvider>
</ErrorBoundary>
)
},
{
targetFps: 60,
gatherStats: false,
exitOnCtrlC: false,
},
)
})
}
function App() {
const route = useRoute()
const dimensions = useTerminalDimensions()
const renderer = useRenderer()
renderer.disableStdoutInterception()
const dialog = useDialog()
const local = useLocal()
const command = useCommandDialog()
const { event } = useSDK()
const sync = useSync()
const toast = useToast()
const [sessionExists, setSessionExists] = createSignal(false)
const { theme } = useTheme()
useKeyboard(async (evt) => {
if (evt.meta && evt.name === "t") {
renderer.toggleDebugOverlay()
return
}
if (evt.meta && evt.name === "d") {
renderer.console.toggle()
return
}
})
// Make sure session is valid, otherwise redirect to home
createEffect(async () => {
if (route.data.type === "session") {
const data = route.data as SessionRoute
await sync.session.sync(data.sessionID).catch(() => {
toast.show({
message: `Session not found: ${data.sessionID}`,
variant: "error",
})
return route.navigate({ type: "home" })
})
setSessionExists(true)
}
})
createEffect(() => {
console.log(JSON.stringify(route.data))
})
command.register(() => [
{
title: "Switch session",
value: "session.list",
keybind: "session_list",
category: "Session",
onSelect: () => {
dialog.replace(() => <DialogSessionList />)
},
},
{
title: "New session",
value: "session.new",
keybind: "session_new",
category: "Session",
onSelect: () => {
route.navigate({
type: "home",
})
dialog.clear()
},
},
{
title: "Switch model",
value: "model.list",
keybind: "model_list",
category: "Agent",
onSelect: () => {
dialog.replace(() => <DialogModel />)
},
},
{
title: "Switch agent",
value: "agent.list",
keybind: "agent_list",
category: "Agent",
onSelect: () => {
dialog.replace(() => <DialogAgent />)
},
},
{
title: "Agent cycle",
value: "agent.cycle",
keybind: "agent_cycle",
category: "Agent",
disabled: true,
onSelect: () => {
local.agent.move(1)
},
},
{
title: "Agent cycle reverse",
value: "agent.cycle.reverse",
keybind: "agent_cycle_reverse",
category: "Agent",
disabled: true,
onSelect: () => {
local.agent.move(-1)
},
},
{
title: "View status",
keybind: "status_view",
value: "opencode.status",
onSelect: () => {
dialog.replace(() => <DialogStatus />)
},
category: "System",
},
{
title: "Switch theme",
value: "theme.switch",
onSelect: () => {
dialog.replace(() => <DialogThemeList />)
},
category: "System",
},
{
title: "Help",
value: "help.show",
onSelect: () => {
dialog.replace(() => <DialogHelp />)
},
category: "System",
},
])
createEffect(() => {
const providerID = local.model.current().providerID
if (providerID === "openrouter" && !local.kv.data.openrouter_warning) {
untrack(() => {
DialogAlert.show(
dialog,
"Warning",
"While openrouter is a convenient way to access LLMs your request will often be routed to subpar providers that do not work well in our testing.\n\nFor reliable access to models check out OpenCode Zen\nhttps://opencode.ai/zen",
).then(() => local.kv.set("openrouter_warning", true))
})
}
})
event.on(TuiEvent.CommandExecute.type, (evt) => {
command.trigger(evt.properties.command)
})
event.on(TuiEvent.ToastShow.type, (evt) => {
toast.show({
title: evt.properties.title,
message: evt.properties.message,
variant: evt.properties.variant,
duration: evt.properties.duration,
})
})
event.on(SessionApi.Event.Deleted.type, (evt) => {
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
route.navigate({ type: "home" })
toast.show({
variant: "info",
message: "The current session was deleted",
})
}
})
return (
<box
width={dimensions().width}
height={dimensions().height}
backgroundColor={theme.background}
onMouseUp={async () => {
const text = renderer.getSelection()?.getSelectedText()
if (text && text.length > 0) {
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)
await Clipboard.copy(text)
renderer.clearSelection()
toast.show({ message: "Copied to clipboard", variant: "info" })
}
}}
>
<box flexDirection="column" flexGrow={1}>
<Switch>
<Match when={route.data.type === "home"}>
<Home />
</Match>
<Match when={route.data.type === "session" && sessionExists()}>
<Session />
</Match>
</Switch>
</box>
<box
height={1}
backgroundColor={theme.backgroundPanel}
flexDirection="row"
justifyContent="space-between"
flexShrink={0}
>
<box flexDirection="row">
<box
flexDirection="row"
backgroundColor={theme.backgroundElement}
paddingLeft={1}
paddingRight={1}
>
<text fg={theme.textMuted}>open</text>
<text attributes={TextAttributes.BOLD}>code </text>
<text fg={theme.textMuted}>v{Installation.VERSION}</text>
</box>
<box paddingLeft={1} paddingRight={1}>
<text fg={theme.textMuted}>{process.cwd().replace(Global.Path.home, "~")}</text>
</box>
</box>
<box flexDirection="row" flexShrink={0}>
<text fg={theme.textMuted} paddingRight={1}>
tab
</text>
<text fg={local.agent.color(local.agent.current().name)}>{""}</text>
<text
bg={local.agent.color(local.agent.current().name)}
fg={theme.background}
wrapMode="none"
>
<span style={{ bold: true }}> {local.agent.current().name.toUpperCase()}</span>
<span> AGENT </span>
</text>
</box>
</box>
</box>
)
}

View File

@@ -0,0 +1,22 @@
import { cmd } from "../cmd"
import { tui } from "./app"
export const AttachCommand = cmd({
command: "attach <url>",
describe: "attach to a running opencode server",
builder: (yargs) =>
yargs
.positional("url", {
type: "string",
describe: "http://localhost:4096",
demandOption: true,
})
.option("dir", {
type: "string",
description: "directory to run in",
}),
handler: async (args) => {
if (args.dir) process.chdir(args.dir)
await tui(args)
},
})

View File

@@ -0,0 +1,16 @@
export const SplitBorder = {
border: ["left" as const, "right" as const],
customBorderChars: {
topLeft: "",
bottomLeft: "",
vertical: "┃",
topRight: "",
bottomRight: "",
horizontal: "",
bottomT: "",
topT: "",
cross: "",
leftT: "",
rightT: "",
},
}

View File

@@ -0,0 +1,31 @@
import { createMemo } from "solid-js"
import { useLocal } from "@tui/context/local"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
export function DialogAgent() {
const local = useLocal()
const dialog = useDialog()
const options = createMemo(() =>
local.agent.list().map((item) => {
return {
value: item.name,
title: item.name,
description: item.builtIn ? "native" : item.description,
}
}),
)
return (
<DialogSelect
title="Select agent"
current={local.agent.current().name}
options={options()}
onSelect={(option) => {
local.agent.set(option.value)
dialog.clear()
}}
/>
)
}

View File

@@ -0,0 +1,96 @@
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
import {
createContext,
createMemo,
createSignal,
onCleanup,
useContext,
type Accessor,
type ParentProps,
} from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { useKeybind } from "@tui/context/keybind"
import type { KeybindsConfig } from "@opencode-ai/sdk"
type Context = ReturnType<typeof init>
const ctx = createContext<Context>()
export type CommandOption = DialogSelectOption & {
keybind?: keyof KeybindsConfig
}
function init() {
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
const dialog = useDialog()
const keybind = useKeybind()
const options = createMemo(() => {
return registrations().flatMap((x) => x())
})
useKeyboard((evt) => {
for (const option of options()) {
if (option.keybind && keybind.match(option.keybind, evt)) {
evt.preventDefault()
option.onSelect?.(dialog)
return
}
}
})
const result = {
trigger(name: string) {
for (const option of options()) {
if (option.value === name) {
option.onSelect?.(dialog)
return
}
}
},
register(cb: () => CommandOption[]) {
const results = createMemo(cb)
setRegistrations((arr) => [results, ...arr])
onCleanup(() => {
setRegistrations((arr) => arr.filter((x) => x !== results))
})
},
get options() {
return options()
},
}
return result
}
export function useCommandDialog() {
const value = useContext(ctx)
if (!value) {
throw new Error("useCommandDialog must be used within a CommandProvider")
}
return value
}
export function CommandProvider(props: ParentProps) {
const value = init()
const dialog = useDialog()
const keybind = useKeybind()
useKeyboard((evt) => {
if (keybind.match("command_list", evt)) {
evt.preventDefault()
dialog.replace(() => <DialogCommand options={value.options} />)
return
}
})
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
}
function DialogCommand(props: { options: CommandOption[] }) {
const keybind = useKeybind()
return (
<DialogSelect
title="Commands"
options={props.options.map((x) => ({ ...x, footer: x.keybind ? keybind.print(x.keybind) : undefined }))}
/>
)
}

View File

@@ -0,0 +1,74 @@
import { createMemo, createSignal } from "solid-js"
import { useLocal } from "@tui/context/local"
import { useSync } from "@tui/context/sync"
import { map, pipe, flatMap, entries, filter, isDeepEqual, sortBy } from "remeda"
import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
export function DialogModel() {
const local = useLocal()
const sync = useSync()
const dialog = useDialog()
const [ref, setRef] = createSignal<DialogSelectRef<unknown>>()
const options = createMemo(() => {
return [
...(!ref()?.filter
? local.model.recent().flatMap((item) => {
const provider = sync.data.provider.find((x) => x.id === item.providerID)!
if (!provider) return []
const model = provider.models[item.modelID]
if (!model) return []
return [
{
key: item,
value: {
providerID: provider.id,
modelID: model.id,
},
title: model.name ?? item.modelID,
description: provider.name,
category: "Recent",
},
]
})
: []),
...pipe(
sync.data.provider,
sortBy(
(provider) => provider.id !== "opencode",
(provider) => provider.name,
),
flatMap((provider) =>
pipe(
provider.models,
entries(),
map(([model, info]) => ({
value: {
providerID: provider.id,
modelID: model,
},
title: info.name ?? model,
description: provider.name,
category: provider.name,
})),
filter((x) => Boolean(ref()?.filter) || !local.model.recent().find((y) => isDeepEqual(y, x.value))),
),
),
),
]
})
return (
<DialogSelect
ref={setRef}
title="Select model"
current={local.model.current()}
options={options()}
onSelect={(option) => {
dialog.clear()
local.model.set(option.value, { recent: true })
}}
/>
)
}

View File

@@ -0,0 +1,80 @@
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { createMemo, createSignal, onMount } from "solid-js"
import { Locale } from "@/util/locale"
import { Keybind } from "@/util/keybind"
import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk"
export function DialogSessionList() {
const dialog = useDialog()
const sync = useSync()
const { theme } = useTheme()
const route = useRoute()
const sdk = useSDK()
const [toDelete, setToDelete] = createSignal<string>()
const options = createMemo(() => {
const today = new Date().toDateString()
return sync.data.session
.filter((x) => x.parentID === undefined)
.map((x) => {
const date = new Date(x.time.updated)
let category = date.toDateString()
if (category === today) {
category = "Today"
}
const isDeleting = toDelete() === x.id
return {
title: isDeleting ? "Press delete again to confirm" : x.title,
bg: isDeleting ? theme.error : undefined,
value: x.id,
category,
footer: Locale.time(x.time.updated),
}
})
})
onMount(() => {
dialog.setSize("large")
})
return (
<DialogSelect
title="Sessions"
options={options()}
limit={50}
onMove={() => {
setToDelete(undefined)
}}
onSelect={(option) => {
route.navigate({
type: "session",
sessionID: option.value,
})
dialog.clear()
}}
keybind={[
{
keybind: Keybind.parse("delete")[0],
title: "delete",
onTrigger: async (option) => {
if (toDelete() === option.value) {
sdk.client.session.delete({
path: {
id: option.value,
},
})
setToDelete(undefined)
return
}
setToDelete(option.value)
},
},
]}
/>
)
}

View File

@@ -0,0 +1,78 @@
import { TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useSync } from "@tui/context/sync"
import { For, Match, Switch, Show } from "solid-js"
export type DialogStatusProps = {}
export function DialogStatus() {
const sync = useSync()
const { theme } = useTheme()
return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD}>Status</text>
<text fg={theme.textMuted}>esc</text>
</box>
<Show when={Object.keys(sync.data.mcp).length > 0}>
<box>
<text>{Object.keys(sync.data.mcp).length} MCP Servers</text>
<For each={Object.entries(sync.data.mcp)}>
{([key, item]) => (
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
style={{
fg: {
connected: theme.success,
failed: theme.error,
disabled: theme.textMuted,
}[item.status],
}}
>
</text>
<text wrapMode="word">
<b>{key}</b>{" "}
<span style={{ fg: theme.textMuted }}>
<Switch>
<Match when={item.status === "connected"}>Connected</Match>
<Match when={item.status === "failed" && item}>{(val) => val().error}</Match>
<Match when={item.status === "disabled"}>Disabled in configuration</Match>
</Switch>
</span>
</text>
</box>
)}
</For>
</box>
</Show>
{sync.data.lsp.length > 0 && (
<box>
<text>{sync.data.lsp.length} LSP Servers</text>
<For each={sync.data.lsp}>
{(item) => (
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
style={{
fg: {
connected: theme.success,
error: theme.error,
}[item.status],
}}
>
</text>
<text wrapMode="word">
<b>{item.id}</b> <span style={{ fg: theme.textMuted }}>{item.root}</span>
</text>
</box>
)}
</For>
</box>
)}
</box>
)
}

View File

@@ -0,0 +1,46 @@
import { createMemo, createResource } from "solid-js"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
import { useSDK } from "@tui/context/sdk"
import { createStore } from "solid-js/store"
export function DialogTag(props: { onSelect?: (value: string) => void }) {
const sdk = useSDK()
const dialog = useDialog()
const [store] = createStore({
filter: "",
})
const [files] = createResource(
() => [store.filter],
async () => {
const result = await sdk.client.find.files({
query: {
query: store.filter,
},
})
if (result.error) return []
const sliced = (result.data ?? []).slice(0, 5)
return sliced
},
)
const options = createMemo(() =>
(files() ?? []).map((file) => ({
value: file,
title: file,
})),
)
return (
<DialogSelect
title="Autocomplete"
options={options()}
onSelect={(option) => {
props.onSelect?.(option.value)
dialog.clear()
}}
/>
)
}

View File

@@ -0,0 +1,52 @@
import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select"
import { THEMES, useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { onCleanup, onMount } from "solid-js"
export function DialogThemeList() {
const { selectedTheme, setSelectedTheme } = useTheme()
const options = Object.keys(THEMES).map((value) => ({
title: value,
value: value as keyof typeof THEMES,
}))
const initial = selectedTheme()
const dialog = useDialog()
let confirmed = false
let ref: DialogSelectRef<keyof typeof THEMES>
onMount(() => {
// highlight the first theme in the list when we open it for UX
setSelectedTheme(Object.keys(THEMES)[0] as keyof typeof THEMES)
})
onCleanup(() => {
// if we close the dialog without confirming, reset back to the initial theme
if (!confirmed) setSelectedTheme(initial)
})
return (
<DialogSelect
title="Themes"
options={options}
onMove={(opt) => {
setSelectedTheme(opt.value)
}}
onSelect={(opt) => {
setSelectedTheme(opt.value)
confirmed = true
dialog.clear()
}}
ref={(r) => {
ref = r
}}
onFilter={(query) => {
if (query.length === 0) {
setSelectedTheme(initial)
return
}
const first = ref.filtered[0]
if (first) setSelectedTheme(first.value)
}}
/>
)
}

View File

@@ -0,0 +1,29 @@
import { Installation } from "@/installation"
import { TextAttributes } from "@opentui/core"
import { For } from "solid-js"
import { useTheme } from "@tui/context/theme"
const LOGO_LEFT = [` `, `█▀▀█ █▀▀█ █▀▀█ █▀▀▄`, `█░░█ █░░█ █▀▀▀ █░░█`, `▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀`]
const LOGO_RIGHT = [``, `█▀▀▀ █▀▀█ █▀▀█ █▀▀█`, `█░░░ █░░█ █░░█ █▀▀▀`, `▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`]
export function Logo() {
const { theme } = useTheme()
return (
<box>
<For each={LOGO_LEFT}>
{(line, index) => (
<box flexDirection="row" gap={1}>
<text fg={theme.textMuted}>{line}</text>
<text fg={theme.text} attributes={TextAttributes.BOLD}>
{LOGO_RIGHT[index()]}
</text>
</box>
)}
</For>
<box flexDirection="row" justifyContent="flex-end">
<text fg={theme.textMuted}>{Installation.VERSION}</text>
</box>
</box>
)
}

View File

@@ -0,0 +1,403 @@
import type { BoxRenderable, TextareaRenderable, KeyEvent } from "@opentui/core"
import fuzzysort from "fuzzysort"
import { firstBy } from "remeda"
import { createMemo, createResource, createEffect, onMount, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useSDK } from "@tui/context/sdk"
import { useSync } from "@tui/context/sync"
import { useTheme } from "@tui/context/theme"
import { SplitBorder } from "@tui/component/border"
import { useCommandDialog } from "@tui/component/dialog-command"
import type { PromptInfo } from "./history"
export type AutocompleteRef = {
onInput: (value: string) => void
onKeyDown: (e: KeyEvent) => void
visible: false | "@" | "/"
}
export type AutocompleteOption = {
display: string
disabled?: boolean
description?: string
onSelect?: () => void
}
export function Autocomplete(props: {
value: string
sessionID?: string
setPrompt: (input: (prompt: PromptInfo) => void) => void
setExtmark: (partIndex: number, extmarkId: number) => void
anchor: () => BoxRenderable
input: () => TextareaRenderable
ref: (ref: AutocompleteRef) => void
fileStyleId: number
agentStyleId: number
promptPartTypeId: () => number
}) {
const sdk = useSDK()
const sync = useSync()
const command = useCommandDialog()
const { theme } = useTheme()
const [store, setStore] = createStore({
index: 0,
selected: 0,
visible: false as AutocompleteRef["visible"],
position: { x: 0, y: 0, width: 0 },
})
const filter = createMemo(() => {
if (!store.visible) return
return props.value.substring(store.index + 1).split(" ")[0]
})
function insertPart(text: string, part: PromptInfo["parts"][number]) {
const input = props.input()
const currentCursorOffset = input.visualCursor.offset
const charAfterCursor = props.value.at(currentCursorOffset)
const needsSpace = charAfterCursor !== " "
const append = "@" + text + (needsSpace ? " " : "")
input.cursorOffset = store.index
const startCursor = input.logicalCursor
input.cursorOffset = currentCursorOffset
const endCursor = input.logicalCursor
input.deleteRange(startCursor.row, startCursor.col, endCursor.row, endCursor.col)
input.insertText(append)
const virtualText = "@" + text
const extmarkStart = store.index
const extmarkEnd = extmarkStart + virtualText.length
const styleId =
part.type === "file"
? props.fileStyleId
: part.type === "agent"
? props.agentStyleId
: undefined
const extmarkId = input.extmarks.create({
start: extmarkStart,
end: extmarkEnd,
virtual: true,
styleId,
typeId: props.promptPartTypeId(),
})
props.setPrompt((draft) => {
if (part.type === "file" && part.source?.text) {
part.source.text.start = extmarkStart
part.source.text.end = extmarkEnd
part.source.text.value = virtualText
} else if (part.type === "agent" && part.source) {
part.source.start = extmarkStart
part.source.end = extmarkEnd
part.source.value = virtualText
}
const partIndex = draft.parts.length
draft.parts.push(part)
props.setExtmark(partIndex, extmarkId)
})
}
const [files] = createResource(
() => filter(),
async (query) => {
if (!store.visible || store.visible === "/") return []
// Get files from SDK
const result = await sdk.client.find.files({
query: {
query: query ?? "",
},
})
const options: AutocompleteOption[] = []
// Add file options
if (!result.error && result.data) {
options.push(
...result.data.map(
(item): AutocompleteOption => ({
display: item,
onSelect: () => {
insertPart(item, {
type: "file",
mime: "text/plain",
filename: item,
url: `file://${process.cwd()}/${item}`,
source: {
type: "file",
text: {
start: 0,
end: 0,
value: "",
},
path: item,
},
})
},
}),
),
)
}
return options
},
{
initialValue: [],
},
)
const agents = createMemo(() => {
if (store.index !== 0) return []
const agents = sync.data.agent
return agents
.filter((agent) => !agent.builtIn && agent.mode !== "primary")
.map(
(agent): AutocompleteOption => ({
display: "@" + agent.name,
onSelect: () => {
insertPart(agent.name, {
type: "agent",
name: agent.name,
source: {
start: 0,
end: 0,
value: "",
},
})
},
}),
)
})
const session = createMemo(() =>
props.sessionID ? sync.session.get(props.sessionID) : undefined,
)
const commands = createMemo((): AutocompleteOption[] => {
const results: AutocompleteOption[] = []
const s = session()
for (const command of sync.data.command) {
results.push({
display: "/" + command.name,
description: command.description,
onSelect: () => {
const newText = "/" + command.name + " "
const cursor = props.input().logicalCursor
props.input().deleteRange(0, 0, cursor.row, cursor.col)
props.input().insertText(newText)
props.input().cursorOffset = Bun.stringWidth(newText)
},
})
}
if (s) {
results.push(
{
display: "/undo",
description: "undo the last message",
onSelect: () => command.trigger("session.undo"),
},
{
display: "/redo",
description: "redo the last message",
onSelect: () => command.trigger("session.redo"),
},
{
display: "/compact",
description: "compact the session",
onSelect: () => command.trigger("session.compact"),
},
{
display: "/share",
disabled: !!s.share?.url,
description: "share a session",
onSelect: () => command.trigger("session.share"),
},
{
display: "/unshare",
disabled: !s.share,
description: "unshare a session",
onSelect: () => command.trigger("session.unshare"),
},
)
}
results.push(
{
display: "/new",
description: "create a new session",
onSelect: () => command.trigger("session.new"),
},
{
display: "/models",
description: "list models",
onSelect: () => command.trigger("model.list"),
},
{
display: "/agents",
description: "list agents",
onSelect: () => command.trigger("agent.list"),
},
{
display: "/status",
description: "show status",
onSelect: () => command.trigger("opencode.status"),
},
{
display: "/help",
description: "show help",
onSelect: () => command.trigger("help.show"),
},
)
const max = firstBy(results, [(x) => x.display.length, "desc"])?.display.length
if (!max) return results
return results.map((item) => ({
...item,
display: item.display.padEnd(max + 2),
}))
})
const options = createMemo(() => {
const mixed: AutocompleteOption[] = (
store.visible === "@"
? [...agents(), ...(files.loading ? files.latest || [] : files())]
: [...commands()]
).filter((x) => x.disabled !== true)
const currentFilter = filter()
if (!currentFilter) return mixed.slice(0, 10)
const result = fuzzysort.go(currentFilter, mixed, {
keys: ["display", "description"],
limit: 10,
})
return result.map((arr) => arr.obj)
})
createEffect(() => {
filter()
setStore("selected", 0)
})
function move(direction: -1 | 1) {
if (!store.visible) return
if (!options().length) return
let next = store.selected + direction
if (next < 0) next = options().length - 1
if (next >= options().length) next = 0
setStore("selected", next)
}
function select() {
const selected = options()[store.selected]
if (!selected) return
selected.onSelect?.()
hide()
}
function show(mode: "@" | "/") {
setStore({
visible: mode,
index: props.input().visualCursor.offset,
position: {
x: props.anchor().x,
y: props.anchor().y,
width: props.anchor().width,
},
})
}
function hide() {
const text = props.input().plainText
if (store.visible === "/" && !text.endsWith(" ")) {
const cursor = props.input().logicalCursor
props.input().deleteRange(0, 0, cursor.row, cursor.col)
}
setStore("visible", false)
}
onMount(() => {
props.ref({
get visible() {
return store.visible
},
onInput(value: string) {
if (store.visible && value.length <= store.index) hide()
},
onKeyDown(e: KeyEvent) {
if (store.visible) {
if (e.name === "up") move(-1)
if (e.name === "down") move(1)
if (e.name === "escape") hide()
if (e.name === "return") select()
if (["up", "down", "return", "escape"].includes(e.name)) e.preventDefault()
}
if (!store.visible) {
if (e.name === "@") {
const cursorOffset = props.input().visualCursor.offset
const charBeforeCursor =
cursorOffset === 0 ? undefined : props.value.at(cursorOffset - 1)
if (
charBeforeCursor === " " ||
charBeforeCursor === "\n" ||
charBeforeCursor === undefined
) {
show("@")
}
}
if (e.name === "/") {
if (props.input().visualCursor.offset === 0) show("/")
}
}
},
})
})
const height = createMemo(() => {
if (options().length) return Math.min(10, options().length)
return 1
})
return (
<box
visible={store.visible !== false}
position="absolute"
top={store.position.y - height()}
left={store.position.x}
width={store.position.width}
zIndex={100}
{...SplitBorder}
borderColor={theme.border}
>
<box backgroundColor={theme.backgroundElement} height={height()}>
<For
each={options()}
fallback={
<box paddingLeft={1} paddingRight={1}>
<text>No matching items</text>
</box>
}
>
{(option, index) => (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={index() === store.selected ? theme.primary : undefined}
flexDirection="row"
>
<text fg={index() === store.selected ? theme.background : theme.text}>
{option.display}
</text>
<Show when={option.description}>
<text fg={index() === store.selected ? theme.background : theme.textMuted}>
{option.description}
</text>
</Show>
</box>
)}
</For>
</box>
</box>
)
}

View File

@@ -0,0 +1,78 @@
import path from "path"
import { Global } from "@/global"
import { onMount } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { clone } from "remeda"
import { createSimpleContext } from "../../context/helper"
import { appendFile } from "fs/promises"
import type { AgentPart, FilePart, TextPart } from "@opencode-ai/sdk"
export type PromptInfo = {
input: string
parts: (
| Omit<FilePart, "id" | "messageID" | "sessionID">
| Omit<AgentPart, "id" | "messageID" | "sessionID">
| (Omit<TextPart, "id" | "messageID" | "sessionID"> & {
source?: {
text: {
start: number
end: number
value: string
}
}
})
)[]
}
export const { use: usePromptHistory, provider: PromptHistoryProvider } = createSimpleContext({
name: "PromptHistory",
init: () => {
const historyFile = Bun.file(path.join(Global.Path.state, "prompt-history.jsonl"))
onMount(async () => {
const text = await historyFile.text().catch(() => "")
const lines = text
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line))
setStore("history", lines as PromptInfo[])
})
const [store, setStore] = createStore({
index: 0,
history: [] as PromptInfo[],
})
return {
move(direction: 1 | -1, input: string) {
if (!store.history.length) return undefined
const current = store.history.at(store.index)
if (!current) return undefined
if (current.input !== input && input.length) return
setStore(
produce((draft) => {
const next = store.index + direction
if (Math.abs(next) > store.history.length) return
if (next > 0) return
draft.index = next
}),
)
if (store.index === 0)
return {
input: "",
parts: [],
}
return store.history.at(store.index)
},
append(item: PromptInfo) {
item = clone(item)
appendFile(historyFile.name!, JSON.stringify(item) + "\n")
setStore(
produce((draft) => {
draft.history.push(item)
draft.index = 0
}),
)
},
}
},
})

View File

@@ -0,0 +1,703 @@
import {
TextAttributes,
BoxRenderable,
TextareaRenderable,
MouseEvent,
KeyEvent,
PasteEvent,
t,
dim,
fg,
} from "@opentui/core"
import { createEffect, createMemo, Match, Switch, type JSX, onMount } from "solid-js"
import { useLocal } from "@tui/context/local"
import { SyntaxTheme, useTheme } from "@tui/context/theme"
import { SplitBorder } from "@tui/component/border"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { Identifier } from "@/id/id"
import { createStore, produce } from "solid-js/store"
import { useKeybind } from "@tui/context/keybind"
import { usePromptHistory, type PromptInfo } from "./history"
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
import { useCommandDialog } from "../dialog-command"
import { useRenderer } from "@opentui/solid"
import { Editor } from "@tui/util/editor"
import { useExit } from "../../context/exit"
import { Clipboard } from "../../util/clipboard"
import type { FilePart } from "@opencode-ai/sdk"
import { TuiEvent } from "../../event"
export type PromptProps = {
sessionID?: string
disabled?: boolean
onSubmit?: () => void
ref?: (ref: PromptRef) => void
hint?: JSX.Element
showPlaceholder?: boolean
}
export type PromptRef = {
focused: boolean
set(prompt: PromptInfo): void
reset(): void
blur(): void
focus(): void
}
export function Prompt(props: PromptProps) {
let input: TextareaRenderable
let anchor: BoxRenderable
let autocomplete: AutocompleteRef
const keybind = useKeybind()
const local = useLocal()
const sdk = useSDK()
const route = useRoute()
const sync = useSync()
const status = createMemo(() => (props.sessionID ? sync.session.status(props.sessionID) : "idle"))
const history = usePromptHistory()
const command = useCommandDialog()
const renderer = useRenderer()
const { theme } = useTheme()
const textareaKeybindings = createMemo(() => {
const newlineBindings = keybind.all.input_newline || []
const submitBindings = keybind.all.input_submit || []
return [
{ name: "return", action: "submit" },
{ name: "return", meta: true, action: "newline" },
...newlineBindings.map((binding) => ({
name: binding.name,
ctrl: binding.ctrl || undefined,
meta: binding.meta || undefined,
shift: binding.shift || undefined,
action: "newline" as const,
})),
...submitBindings.map((binding) => ({
name: binding.name,
ctrl: binding.ctrl || undefined,
meta: binding.meta || undefined,
shift: binding.shift || undefined,
action: "submit" as const,
})),
]
})
const fileStyleId = SyntaxTheme.getStyleId("extmark.file")!
const agentStyleId = SyntaxTheme.getStyleId("extmark.agent")!
const pasteStyleId = SyntaxTheme.getStyleId("extmark.paste")!
let promptPartTypeId: number
command.register(() => {
return [
{
title: "Open editor",
category: "Session",
keybind: "editor_open",
value: "prompt.editor",
onSelect: async (dialog) => {
dialog.clear()
const value = input.plainText
input.clear()
setStore("prompt", {
input: "",
parts: [],
})
const content = await Editor.open({ value, renderer })
if (content) {
input.setText(content, { history: false })
setStore("prompt", {
input: content,
parts: [],
})
input.cursorOffset = Bun.stringWidth(content)
}
},
},
{
title: "Clear prompt",
value: "prompt.clear",
disabled: true,
category: "Prompt",
onSelect: (dialog) => {
input.extmarks.clear()
setStore("prompt", {
input: "",
parts: [],
})
setStore("extmarkToPartIndex", new Map())
dialog.clear()
},
},
{
title: "Submit prompt",
value: "prompt.submit",
disabled: true,
keybind: "input_submit",
category: "Prompt",
onSelect: (dialog) => {
submit()
dialog.clear()
},
},
{
title: "Paste",
value: "prompt.paste",
disabled: true,
keybind: "input_paste",
category: "Prompt",
onSelect: async () => {
const content = await Clipboard.read()
if (content?.mime.startsWith("image/")) {
await pasteImage({
filename: "clipboard",
mime: content.mime,
content: content.data,
})
}
},
},
]
})
sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
setStore(
"prompt",
produce((draft) => {
draft.input += evt.properties.text
}),
)
})
createEffect(() => {
if (props.disabled) input.cursorColor = theme.backgroundElement
if (!props.disabled) input.cursorColor = theme.primary
})
const [store, setStore] = createStore<{
prompt: PromptInfo
mode: "normal" | "shell"
extmarkToPartIndex: Map<number, number>
}>({
prompt: {
input: "",
parts: [],
},
mode: "normal",
extmarkToPartIndex: new Map(),
})
createEffect(() => {
input.focus()
})
onMount(() => {
promptPartTypeId = input.extmarks.registerType("prompt-part")
})
function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
input.extmarks.clear()
setStore("extmarkToPartIndex", new Map())
parts.forEach((part, partIndex) => {
let start = 0
let end = 0
let virtualText = ""
let styleId: number | undefined
if (part.type === "file" && part.source?.text) {
start = part.source.text.start
end = part.source.text.end
virtualText = part.source.text.value
styleId = fileStyleId
} else if (part.type === "agent" && part.source) {
start = part.source.start
end = part.source.end
virtualText = part.source.value
styleId = agentStyleId
} else if (part.type === "text" && part.source?.text) {
start = part.source.text.start
end = part.source.text.end
virtualText = part.source.text.value
styleId = pasteStyleId
}
if (virtualText) {
const extmarkId = input.extmarks.create({
start,
end,
virtual: true,
styleId,
typeId: promptPartTypeId,
})
setStore("extmarkToPartIndex", (map: Map<number, number>) => {
const newMap = new Map(map)
newMap.set(extmarkId, partIndex)
return newMap
})
}
})
}
function syncExtmarksWithPromptParts() {
const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
setStore(
produce((draft) => {
const newMap = new Map<number, number>()
const newParts: typeof draft.prompt.parts = []
for (const extmark of allExtmarks) {
const partIndex = draft.extmarkToPartIndex.get(extmark.id)
if (partIndex !== undefined) {
const part = draft.prompt.parts[partIndex]
if (part) {
if (part.type === "agent" && part.source) {
part.source.start = extmark.start
part.source.end = extmark.end
} else if (part.type === "file" && part.source?.text) {
part.source.text.start = extmark.start
part.source.text.end = extmark.end
} else if (part.type === "text" && part.source?.text) {
part.source.text.start = extmark.start
part.source.text.end = extmark.end
}
newMap.set(extmark.id, newParts.length)
newParts.push(part)
}
}
}
draft.extmarkToPartIndex = newMap
draft.prompt.parts = newParts
}),
)
}
props.ref?.({
get focused() {
return input.focused
},
focus() {
input.focus()
},
blur() {
input.blur()
},
set(prompt) {
input.setText(prompt.input, { history: false })
setStore("prompt", prompt)
restoreExtmarksFromParts(prompt.parts)
input.gotoBufferEnd()
},
reset() {
input.clear()
input.extmarks.clear()
setStore("prompt", {
input: "",
parts: [],
})
setStore("extmarkToPartIndex", new Map())
},
})
async function submit() {
if (props.disabled) return
if (autocomplete.visible) return
if (!store.prompt.input) return
const sessionID = props.sessionID
? props.sessionID
: await (async () => {
const sessionID = await sdk.client.session.create({}).then((x) => x.data!.id)
return sessionID
})()
const messageID = Identifier.ascending("message")
let inputText = store.prompt.input
// Expand pasted text inline before submitting
const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
const sortedExtmarks = allExtmarks.sort(
(a: { start: number }, b: { start: number }) => b.start - a.start,
)
for (const extmark of sortedExtmarks) {
const partIndex = store.extmarkToPartIndex.get(extmark.id)
if (partIndex !== undefined) {
const part = store.prompt.parts[partIndex]
if (part?.type === "text" && part.text) {
const before = inputText.slice(0, extmark.start)
const after = inputText.slice(extmark.end)
inputText = before + part.text + after
}
}
}
// Filter out text parts (pasted content) since they're now expanded inline
const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text")
if (store.mode === "shell") {
sdk.client.session.shell({
path: {
id: sessionID,
},
body: {
agent: local.agent.current().name,
command: inputText,
},
})
setStore("mode", "normal")
} else if (inputText.startsWith("/")) {
const [command, ...args] = inputText.split(" ")
sdk.client.session.command({
path: {
id: sessionID,
},
body: {
command: command.slice(1),
arguments: args.join(" "),
agent: local.agent.current().name,
model: `${local.model.current().providerID}/${local.model.current().modelID}`,
messageID,
},
})
} else {
sdk.client.session.prompt({
path: {
id: sessionID,
},
body: {
...local.model.current(),
messageID,
agent: local.agent.current().name,
model: local.model.current(),
parts: [
{
id: Identifier.ascending("part"),
type: "text",
text: inputText,
},
...nonTextParts.map((x) => ({
id: Identifier.ascending("part"),
...x,
})),
],
},
})
}
history.append(store.prompt)
input.extmarks.clear()
setStore("prompt", {
input: "",
parts: [],
})
setStore("extmarkToPartIndex", new Map())
props.onSubmit?.()
// temporary hack to make sure the message is sent
if (!props.sessionID)
setTimeout(() => {
route.navigate({
type: "session",
sessionID,
})
}, 50)
input.clear()
}
const exit = useExit()
async function pasteImage(file: { filename?: string; content: string; mime: string }) {
const currentOffset = input.visualCursor.offset
const extmarkStart = currentOffset
const count = store.prompt.parts.filter((x) => x.type === "file").length
const virtualText = `[Image ${count + 1}]`
const extmarkEnd = extmarkStart + virtualText.length
const textToInsert = virtualText + " "
input.insertText(textToInsert)
const extmarkId = input.extmarks.create({
start: extmarkStart,
end: extmarkEnd,
virtual: true,
styleId: pasteStyleId,
typeId: promptPartTypeId,
})
const part: Omit<FilePart, "id" | "messageID" | "sessionID"> = {
type: "file" as const,
mime: file.mime,
filename: file.filename,
url: `data:${file.mime};base64,${file.content}`,
source: {
type: "file",
path: file.filename ?? "",
text: {
start: extmarkStart,
end: extmarkEnd,
value: virtualText,
},
},
}
setStore(
produce((draft) => {
const partIndex = draft.prompt.parts.length
draft.prompt.parts.push(part)
draft.extmarkToPartIndex.set(extmarkId, partIndex)
}),
)
return
}
return (
<>
<Autocomplete
sessionID={props.sessionID}
ref={(r) => (autocomplete = r)}
anchor={() => anchor}
input={() => input}
setPrompt={(cb) => {
setStore("prompt", produce(cb))
}}
setExtmark={(partIndex, extmarkId) => {
setStore("extmarkToPartIndex", (map: Map<number, number>) => {
const newMap = new Map(map)
newMap.set(extmarkId, partIndex)
return newMap
})
}}
value={store.prompt.input}
fileStyleId={fileStyleId}
agentStyleId={agentStyleId}
promptPartTypeId={() => promptPartTypeId}
/>
<box ref={(r) => (anchor = r)}>
<box
flexDirection="row"
{...SplitBorder}
borderColor={
keybind.leader ? theme.accent : store.mode === "shell" ? theme.secondary : theme.border
}
justifyContent="space-evenly"
>
<box
backgroundColor={theme.backgroundElement}
width={3}
height="100%"
alignItems="center"
paddingTop={1}
>
<text attributes={TextAttributes.BOLD} fg={theme.primary}>
{store.mode === "normal" ? ">" : "!"}
</text>
</box>
<box
paddingTop={1}
paddingBottom={1}
backgroundColor={theme.backgroundElement}
flexGrow={1}
>
<textarea
placeholder={
props.showPlaceholder
? t`${dim(fg(theme.primary)(" → up/down"))} ${dim(fg("#64748b")("history"))} ${dim(fg("#a78bfa")("•"))} ${dim(fg(theme.primary)(keybind.print("input_newline")))} ${dim(fg("#64748b")("newline"))} ${dim(fg("#a78bfa")("•"))} ${dim(fg(theme.primary)(keybind.print("input_submit")))} ${dim(fg("#64748b")("submit"))}`
: undefined
}
textColor={theme.text}
focusedTextColor={theme.text}
minHeight={1}
maxHeight={6}
onContentChange={() => {
const value = input.plainText
setStore("prompt", "input", value)
autocomplete.onInput(value)
syncExtmarksWithPromptParts()
}}
keyBindings={textareaKeybindings()}
onKeyDown={async (e: KeyEvent) => {
if (props.disabled) {
e.preventDefault()
return
}
if (keybind.match("input_clear", e) && store.prompt.input !== "") {
input.clear()
input.extmarks.clear()
setStore("prompt", {
input: "",
parts: [],
})
setStore("extmarkToPartIndex", new Map())
return
}
if (keybind.match("app_exit", e)) {
await exit()
return
}
if (e.name === "!" && input.visualCursor.offset === 0) {
setStore("mode", "shell")
e.preventDefault()
return
}
if (store.mode === "shell") {
if (
(e.name === "backspace" && input.visualCursor.offset === 0) ||
e.name === "escape"
) {
setStore("mode", "normal")
e.preventDefault()
return
}
}
if (store.mode === "normal") autocomplete.onKeyDown(e)
if (!autocomplete.visible) {
if (
(e.name === "up" && input.cursorOffset === 0) ||
(e.name === "down" && input.cursorOffset === input.plainText.length)
) {
const direction = e.name === "up" ? -1 : 1
const item = history.move(direction, input.plainText)
if (item) {
input.setText(item.input, { history: false })
setStore("prompt", item)
restoreExtmarksFromParts(item.parts)
e.preventDefault()
if (direction === -1) input.cursorOffset = 0
if (direction === 1) input.cursorOffset = input.plainText.length
}
return
}
if (e.name === "up" && input.visualCursor.visualRow === 0) input.cursorOffset = 0
if (e.name === "down" && input.visualCursor.visualRow === input.height - 1)
input.cursorOffset = input.plainText.length
}
if (!autocomplete.visible) {
if (keybind.match("session_interrupt", e) && props.sessionID) {
sdk.client.session.abort({
path: {
id: props.sessionID,
},
})
return
}
}
}}
onSubmit={submit}
onPaste={async (event: PasteEvent) => {
if (props.disabled) {
event.preventDefault()
return
}
const pastedContent = event.text.trim()
if (!pastedContent) {
command.trigger("prompt.paste")
return
}
// trim ' from the beginning and end of the pasted content. just
// ' and nothing else
const filepath = pastedContent.replace(/^'+|'+$/g, "")
try {
const file = Bun.file(filepath)
if (file.type.startsWith("image/")) {
const content = await file
.arrayBuffer()
.then((buffer) => Buffer.from(buffer).toString("base64"))
.catch(() => {})
if (content) {
await pasteImage({
filename: file.name,
mime: file.type,
content,
})
return
}
}
} catch {}
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
if (lineCount >= 5) {
event.preventDefault()
const currentOffset = input.visualCursor.offset
const virtualText = `[Pasted ~${lineCount} lines]`
const textToInsert = virtualText + " "
const extmarkStart = currentOffset
const extmarkEnd = extmarkStart + virtualText.length
input.insertText(textToInsert)
const extmarkId = input.extmarks.create({
start: extmarkStart,
end: extmarkEnd,
virtual: true,
styleId: pasteStyleId,
typeId: promptPartTypeId,
})
const part = {
type: "text" as const,
text: pastedContent,
source: {
text: {
start: extmarkStart,
end: extmarkEnd,
value: virtualText,
},
},
}
setStore(
produce((draft) => {
const partIndex = draft.prompt.parts.length
draft.prompt.parts.push(part)
draft.extmarkToPartIndex.set(extmarkId, partIndex)
}),
)
return
}
}}
ref={(r: TextareaRenderable) => (input = r)}
onMouseDown={(r: MouseEvent) => r.target?.focus()}
focusedBackgroundColor={theme.backgroundElement}
cursorColor={theme.primary}
syntaxStyle={SyntaxTheme}
/>
</box>
<box
backgroundColor={theme.backgroundElement}
width={1}
justifyContent="center"
alignItems="center"
></box>
</box>
<box flexDirection="row" justifyContent="space-between">
<text flexShrink={0} wrapMode="none">
<span style={{ fg: theme.textMuted }}>{local.model.parsed().provider}</span>{" "}
<span style={{ bold: true }}>{local.model.parsed().model}</span>
</text>
<Switch>
<Match when={status() === "compacting"}>
<text fg={theme.textMuted}>compacting...</text>
</Match>
<Match when={status() === "working"}>
<box flexDirection="row" gap={1}>
<text>
esc <span style={{ fg: theme.textMuted }}>interrupt</span>
</text>
</box>
</Match>
<Match when={props.hint}>{props.hint!}</Match>
<Match when={true}>
<text>
ctrl+p <span style={{ fg: theme.textMuted }}>commands</span>
</text>
</Match>
</Switch>
</box>
</box>
</>
)
}

View File

@@ -0,0 +1,14 @@
import { useRenderer } from "@opentui/solid"
import { createSimpleContext } from "./helper"
export const { use: useExit, provider: ExitProvider } = createSimpleContext({
name: "Exit",
init: (input: { onExit?: () => Promise<void> }) => {
const renderer = useRenderer()
return async () => {
renderer.destroy()
await input.onExit?.()
process.exit(0)
}
},
})

View File

@@ -0,0 +1,25 @@
import { createContext, Show, useContext, type ParentProps } from "solid-js"
export function createSimpleContext<T, Props extends Record<string, any>>(input: {
name: string
init: ((input: Props) => T) | (() => T)
}) {
const ctx = createContext<T>()
return {
provider: (props: ParentProps<Props>) => {
const init = input.init(props)
return (
// @ts-expect-error
<Show when={init.ready === undefined || init.ready === true}>
<ctx.Provider value={init}>{props.children}</ctx.Provider>
</Show>
)
},
use() {
const value = useContext(ctx)
if (!value) throw new Error(`${input.name} context must be used within a context provider`)
return value
},
}
}

View File

@@ -0,0 +1,103 @@
import { createMemo } from "solid-js"
import { useSync } from "@tui/context/sync"
import { Keybind } from "@/util/keybind"
import { pipe, mapValues } from "remeda"
import type { KeybindsConfig } from "@opencode-ai/sdk"
import type { ParsedKey, Renderable } from "@opentui/core"
import { createStore } from "solid-js/store"
import { useKeyboard, useRenderer } from "@opentui/solid"
import { createSimpleContext } from "./helper"
export const { use: useKeybind, provider: KeybindProvider } = createSimpleContext({
name: "Keybind",
init: () => {
const sync = useSync()
const keybinds = createMemo(() => {
return pipe(
sync.data.config.keybinds ?? {},
mapValues((value) => Keybind.parse(value)),
)
})
const [store, setStore] = createStore({
leader: false,
})
const renderer = useRenderer()
let focus: Renderable | null
let timeout: NodeJS.Timeout
function leader(active: boolean) {
if (active) {
setStore("leader", true)
focus = renderer.currentFocusedRenderable
focus?.blur()
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => {
if (!store.leader) return
leader(false)
if (focus) {
focus.focus()
}
}, 2000)
return
}
if (!active) {
if (focus && !renderer.currentFocusedRenderable) {
focus.focus()
}
setStore("leader", false)
}
}
useKeyboard(async (evt) => {
if (!store.leader && result.match("leader", evt)) {
leader(true)
return
}
if (store.leader && evt.name) {
setImmediate(() => {
if (focus && renderer.currentFocusedRenderable === focus) {
focus.focus()
}
leader(false)
})
}
})
const result = {
get all() {
return keybinds()
},
get leader() {
return store.leader
},
parse(evt: ParsedKey): Keybind.Info {
return {
ctrl: evt.ctrl,
name: evt.name,
shift: evt.shift,
leader: store.leader,
meta: evt.meta,
}
},
match(key: keyof KeybindsConfig, evt: ParsedKey) {
const keybind = keybinds()[key]
if (!keybind) return false
const parsed: Keybind.Info = result.parse(evt)
for (const key of keybind) {
if (Keybind.match(key, parsed)) {
return true
}
}
},
print(key: keyof KeybindsConfig) {
const first = keybinds()[key]?.at(0)
if (!first) return ""
const result = Keybind.toString(first)
return result.replace("<leader>", Keybind.toString(keybinds().leader![0]!))
},
}
return result
},
})

View File

@@ -0,0 +1,276 @@
import { createStore } from "solid-js/store"
import { batch, createEffect, createMemo, createSignal, onMount } from "solid-js"
import { useSync } from "@tui/context/sync"
import { useTheme } from "@tui/context/theme"
import { uniqueBy } from "remeda"
import path from "path"
import { Global } from "@/global"
import { iife } from "@/util/iife"
import { createSimpleContext } from "./helper"
import { useToast } from "../ui/toast"
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
name: "Local",
init: (props: { initialModel?: string; initialAgent?: string }) => {
const sync = useSync()
const toast = useToast()
function isModelValid(model: { providerID: string, modelID: string }) {
const provider = sync.data.provider.find((x) => x.id === model.providerID)
return !!provider?.models[model.modelID]
}
function getFirstValidModel(...modelFns: (() => { providerID: string, modelID: string } | undefined)[]) {
for (const modelFn of modelFns) {
const model = modelFn()
if (!model) continue
if (isModelValid(model))
return model
}
}
// Set initial model if provided
onMount(() => {
batch(() => {
if (props.initialAgent) {
agent.set(props.initialAgent)
}
if (props.initialModel) {
const [providerID, modelID] = props.initialModel.split("/")
if (!providerID || !modelID)
return toast.show({
variant: "warning",
message: `Invalid model format: ${props.initialModel}`,
duration: 3000,
})
model.set({ providerID, modelID }, { recent: true })
}
})
})
// Automatically update model when agent changes
createEffect(() => {
const value = agent.current()
if (value.model) {
if (isModelValid(value.model))
model.set({
providerID: value.model.providerID,
modelID: value.model.modelID,
})
else
toast.show({
variant: "warning",
message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`,
duration: 3000,
})
}
})
const agent = iife(() => {
const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent"))
const [agentStore, setAgentStore] = createStore<{
current: string
}>({
current: agents()[0].name,
})
const { theme } = useTheme()
const colors = createMemo(() => [
theme.secondary,
theme.accent,
theme.success,
theme.warning,
theme.primary,
theme.error,
])
return {
list() {
return agents()
},
current() {
return agents().find((x) => x.name === agentStore.current)!
},
set(name: string) {
if (!agents().some((x) => x.name === name))
return toast.show({
variant: "warning",
message: `Agent not found: ${name}`,
duration: 3000,
})
setAgentStore("current", name)
},
move(direction: 1 | -1) {
batch(() => {
let next = agents().findIndex((x) => x.name === agentStore.current) + direction
if (next < 0) next = agents().length - 1
if (next >= agents().length) next = 0
const value = agents()[next]
setAgentStore("current", value.name)
})
},
color(name: string) {
const index = agents().findIndex((x) => x.name === name)
return colors()[index % colors().length]
},
}
})
const model = iife(() => {
const [modelStore, setModelStore] = createStore<{
ready: boolean
model: Record<
string,
{
providerID: string
modelID: string
}
>
recent: {
providerID: string
modelID: string
}[]
}>({
ready: false,
model: {},
recent: [],
})
const file = Bun.file(path.join(Global.Path.state, "model.json"))
file
.json()
.then((x) => {
setModelStore("recent", x.recent)
})
.catch(() => { })
.finally(() => {
setModelStore("ready", true)
})
createEffect(() => {
Bun.write(
file,
JSON.stringify({
recent: modelStore.recent,
}),
)
})
const fallbackModel = createMemo(() => {
if (sync.data.config.model) {
const [providerID, modelID] = sync.data.config.model.split("/")
if (isModelValid({ providerID, modelID })) {
return {
providerID,
modelID,
}
}
}
for (const item of modelStore.recent) {
if (isModelValid(item)) {
return item
}
}
const provider = sync.data.provider[0]
const model = Object.values(provider.models)[0]
return {
providerID: provider.id,
modelID: model.id,
}
})
const currentModel = createMemo(() => {
const a = agent.current()
return getFirstValidModel(
() => modelStore.model[a.name],
() => a.model,
fallbackModel,
)!
})
return {
current: currentModel,
get ready() {
return modelStore.ready
},
recent() {
return modelStore.recent
},
parsed: createMemo(() => {
const value = currentModel()
const provider = sync.data.provider.find((x) => x.id === value.providerID)!
const model = provider.models[value.modelID]
return {
provider: provider.name ?? value.providerID,
model: model.name ?? value.modelID,
}
}),
set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
batch(() => {
if (!isModelValid(model)) {
toast.show({
message: `Model ${model.providerID}/${model.modelID} is not valid`,
variant: "warning",
duration: 3000,
})
return
}
setModelStore("model", agent.current().name, model)
if (options?.recent) {
const uniq = uniqueBy([model, ...modelStore.recent], (x) => x.providerID + x.modelID)
if (uniq.length > 5) uniq.pop()
setModelStore("recent", uniq)
}
})
},
}
})
const kv = iife(() => {
const [ready, setReady] = createSignal(false)
const [kvStore, setKvStore] = createStore({
openrouter_warning: false,
})
const file = Bun.file(path.join(Global.Path.state, "kv.json"))
file
.json()
.then((x) => {
setKvStore(x)
})
.catch(() => { })
.finally(() => {
setReady(true)
})
return {
get data() {
return kvStore
},
get ready() {
return ready()
},
set(key: string, value: any) {
setKvStore(key as any, value)
Bun.write(
file,
JSON.stringify({
[key]: value,
}),
)
},
}
})
const result = {
model,
agent,
kv,
get ready() {
return kv.ready && model.ready
},
}
return result
},
})

View File

@@ -0,0 +1,46 @@
import { createStore } from "solid-js/store"
import { createSimpleContext } from "./helper"
export type HomeRoute = {
type: "home"
}
export type SessionRoute = {
type: "session"
sessionID: string
}
export type Route = HomeRoute | SessionRoute
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
name: "Route",
init: (props: { data?: Route }) => {
const [store, setStore] = createStore<Route>(
props.data ??
(
process.env["OPENCODE_ROUTE"]
? JSON.parse(process.env["OPENCODE_ROUTE"])
: {
type: "home",
}
),
)
return {
get data() {
return store
},
navigate(route: Route) {
console.log("navigate", route)
setStore(route)
},
}
},
})
export type RouteContext = ReturnType<typeof useRoute>
export function useRouteData<T extends Route["type"]>(type: T) {
const route = useRoute()
return route.data as Extract<Route, { type: typeof type }>
}

View File

@@ -0,0 +1,37 @@
import { createOpencodeClient, type Event } from "@opencode-ai/sdk"
import { createSimpleContext } from "./helper"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup } from "solid-js"
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK",
init: (props: { url: string }) => {
const abort = new AbortController()
const sdk = createOpencodeClient({
baseUrl: props.url,
signal: abort.signal,
fetch: (req) => {
// @ts-ignore
req.timeout = false
return fetch(req)
},
})
const emitter = createGlobalEmitter<{
[key in Event["type"]]: Extract<Event, { type: key }>
}>()
sdk.event.subscribe().then(async (events) => {
for await (const event of events.stream) {
console.log("event", event.type)
emitter.emit(event.type, event)
}
})
onCleanup(() => {
abort.abort()
})
return { client: sdk, event: emitter }
},
})

View File

@@ -0,0 +1,270 @@
import type {
Message,
Agent,
Provider,
Session,
Part,
Config,
Todo,
Command,
Permission,
LspStatus,
McpStatus,
} from "@opencode-ai/sdk"
import { createStore, produce, reconcile } from "solid-js/store"
import { useSDK } from "@tui/context/sdk"
import { Binary } from "@/util/binary"
import { createSimpleContext } from "./helper"
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
init: () => {
const [store, setStore] = createStore<{
ready: boolean
provider: Provider[]
agent: Agent[]
command: Command[]
permission: {
[sessionID: string]: Permission[]
}
config: Config
session: Session[]
todo: {
[sessionID: string]: Todo[]
}
message: {
[sessionID: string]: Message[]
}
part: {
[messageID: string]: Part[]
}
lsp: LspStatus[]
mcp: {
[key: string]: McpStatus
}
}>({
config: {},
ready: false,
agent: [],
permission: {},
command: [],
provider: [],
session: [],
todo: {},
message: {},
part: {},
lsp: [],
mcp: {},
})
const sdk = useSDK()
sdk.event.listen((e) => {
const event = e.details
switch (event.type) {
case "permission.updated": {
const permissions = store.permission[event.properties.sessionID]
if (!permissions) {
setStore("permission", event.properties.sessionID, [event.properties])
break
}
const match = Binary.search(permissions, event.properties.id, (p) => p.id)
setStore(
"permission",
event.properties.sessionID,
produce((draft) => {
if (match.found) {
draft[match.index] = event.properties
return
}
draft.push(event.properties)
}),
)
break
}
case "permission.replied": {
const permissions = store.permission[event.properties.sessionID]
const match = Binary.search(permissions, event.properties.permissionID, (p) => p.id)
if (!match.found) break
setStore(
"permission",
event.properties.sessionID,
produce((draft) => {
draft.splice(match.index, 1)
}),
)
break
}
case "todo.updated":
setStore("todo", event.properties.sessionID, event.properties.todos)
break
case "session.deleted": {
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
if (result.found) {
setStore(
"session",
produce((draft) => {
draft.splice(result.index, 1)
}),
)
}
break
}
case "session.updated":
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
if (result.found) {
setStore("session", result.index, reconcile(event.properties.info))
break
}
setStore(
"session",
produce((draft) => {
draft.splice(result.index, 0, event.properties.info)
}),
)
break
case "message.updated": {
const messages = store.message[event.properties.info.sessionID]
if (!messages) {
setStore("message", event.properties.info.sessionID, [event.properties.info])
break
}
const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
if (result.found) {
setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
break
}
setStore(
"message",
event.properties.info.sessionID,
produce((draft) => {
draft.splice(result.index, 0, event.properties.info)
}),
)
break
}
case "message.removed": {
const messages = store.message[event.properties.sessionID]
const result = Binary.search(messages, event.properties.messageID, (m) => m.id)
if (result.found) {
setStore(
"message",
event.properties.sessionID,
produce((draft) => {
draft.splice(result.index, 1)
}),
)
}
break
}
case "message.part.updated": {
const parts = store.part[event.properties.part.messageID]
if (!parts) {
setStore("part", event.properties.part.messageID, [event.properties.part])
break
}
const result = Binary.search(parts, event.properties.part.id, (p) => p.id)
if (result.found) {
setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part))
break
}
setStore(
"part",
event.properties.part.messageID,
produce((draft) => {
draft.splice(result.index, 0, event.properties.part)
}),
)
break
}
case "message.part.removed": {
const parts = store.part[event.properties.messageID]
const result = Binary.search(parts, event.properties.partID, (p) => p.id)
if (result.found)
setStore(
"part",
event.properties.messageID,
produce((draft) => {
draft.splice(result.index, 1)
}),
)
break
}
case "lsp.updated": {
sdk.client.lsp.status().then((x) => setStore("lsp", x.data!))
break
}
}
})
// blocking
Promise.all([
sdk.client.config.providers().then((x) => setStore("provider", x.data!.providers)),
sdk.client.app.agents().then((x) => setStore("agent", x.data ?? [])),
sdk.client.config.get().then((x) => setStore("config", x.data!)),
]).then(() => setStore("ready", true))
// non-blocking
Promise.all([
sdk.client.session.list().then((x) =>
setStore(
"session",
(x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)),
),
),
sdk.client.command.list().then((x) => setStore("command", x.data ?? [])),
sdk.client.lsp.status().then((x) => setStore("lsp", x.data!)),
sdk.client.mcp.status().then((x) => setStore("mcp", x.data!)),
])
const result = {
data: store,
set: setStore,
get ready() {
return store.ready
},
session: {
get(sessionID: string) {
const match = Binary.search(store.session, sessionID, (s) => s.id)
if (match.found) return store.session[match.index]
return undefined
},
status(sessionID: string) {
const session = result.session.get(sessionID)
if (!session) return "idle"
if (session.time.compacting) return "compacting"
const messages = store.message[sessionID] ?? []
const last = messages.at(-1)
if (!last) return "idle"
if (last.role === "user") return "working"
return last.time.completed ? "idle" : "working"
},
async sync(sessionID: string) {
const [session, messages, todo] = await Promise.all([
sdk.client.session.get({ path: { id: sessionID }, throwOnError: true }),
sdk.client.session.messages({ path: { id: sessionID } }),
sdk.client.session.todo({ path: { id: sessionID } }),
])
setStore(
produce((draft) => {
const match = Binary.search(draft.session, sessionID, (s) => s.id)
if (match.found) draft.session[match.index] = session.data!
if (!match.found) draft.session.splice(match.index, 0, session.data!)
draft.todo[sessionID] = todo.data ?? []
draft.message[sessionID] = messages.data!.map((x) => x.info)
for (const message of messages.data!) {
draft.part[message.info.id] = message.parts
}
}),
)
},
},
}
return result
},
})

View File

@@ -0,0 +1,658 @@
import { SyntaxStyle, RGBA } from "@opentui/core"
import { createMemo, createSignal, createEffect } from "solid-js"
import { useSync } from "@tui/context/sync"
import { createSimpleContext } from "./helper"
import aura from "../../../../../../tui/internal/theme/themes/aura.json" with { type: "json" }
import ayu from "../../../../../../tui/internal/theme/themes/ayu.json" with { type: "json" }
import catppuccin from "../../../../../../tui/internal/theme/themes/catppuccin.json" with { type: "json" }
import cobalt2 from "../../../../../../tui/internal/theme/themes/cobalt2.json" with { type: "json" }
import dracula from "../../../../../../tui/internal/theme/themes/dracula.json" with { type: "json" }
import everforest from "../../../../../../tui/internal/theme/themes/everforest.json" with { type: "json" }
import github from "../../../../../../tui/internal/theme/themes/github.json" with { type: "json" }
import gruvbox from "../../../../../../tui/internal/theme/themes/gruvbox.json" with { type: "json" }
import kanagawa from "../../../../../../tui/internal/theme/themes/kanagawa.json" with { type: "json" }
import material from "../../../../../../tui/internal/theme/themes/material.json" with { type: "json" }
import matrix from "../../../../../../tui/internal/theme/themes/matrix.json" with { type: "json" }
import monokai from "../../../../../../tui/internal/theme/themes/monokai.json" with { type: "json" }
import nord from "../../../../../../tui/internal/theme/themes/nord.json" with { type: "json" }
import onedark from "../../../../../../tui/internal/theme/themes/one-dark.json" with { type: "json" }
import opencode from "../../../../../../tui/internal/theme/themes/opencode.json" with { type: "json" }
import palenight from "../../../../../../tui/internal/theme/themes/palenight.json" with { type: "json" }
import rosepine from "../../../../../../tui/internal/theme/themes/rosepine.json" with { type: "json" }
import solarized from "../../../../../../tui/internal/theme/themes/solarized.json" with { type: "json" }
import synthwave84 from "../../../../../../tui/internal/theme/themes/synthwave84.json" with { type: "json" }
import tokyonight from "../../../../../../tui/internal/theme/themes/tokyonight.json" with { type: "json" }
import vesper from "../../../../../../tui/internal/theme/themes/vesper.json" with { type: "json" }
import zenburn from "../../../../../../tui/internal/theme/themes/zenburn.json" with { type: "json" }
import { iife } from "@/util/iife"
import { createStore, reconcile } from "solid-js/store"
type Theme = {
primary: RGBA
secondary: RGBA
accent: RGBA
error: RGBA
warning: RGBA
success: RGBA
info: RGBA
text: RGBA
textMuted: RGBA
background: RGBA
backgroundPanel: RGBA
backgroundElement: RGBA
border: RGBA
borderActive: RGBA
borderSubtle: RGBA
diffAdded: RGBA
diffRemoved: RGBA
diffContext: RGBA
diffHunkHeader: RGBA
diffHighlightAdded: RGBA
diffHighlightRemoved: RGBA
diffAddedBg: RGBA
diffRemovedBg: RGBA
diffContextBg: RGBA
diffLineNumber: RGBA
diffAddedLineNumberBg: RGBA
diffRemovedLineNumberBg: RGBA
markdownText: RGBA
markdownHeading: RGBA
markdownLink: RGBA
markdownLinkText: RGBA
markdownCode: RGBA
markdownBlockQuote: RGBA
markdownEmph: RGBA
markdownStrong: RGBA
markdownHorizontalRule: RGBA
markdownListItem: RGBA
markdownListEnumeration: RGBA
markdownImage: RGBA
markdownImageText: RGBA
markdownCodeBlock: RGBA
}
type HexColor = `#${string}`
type RefName = string
type ColorModeObj = {
dark: HexColor | RefName
light: HexColor | RefName
}
type ColorValue = HexColor | RefName | ColorModeObj
type ThemeJson = {
$schema?: string
defs?: Record<string, HexColor | RefName>
theme: Record<keyof Theme, ColorValue>
}
export const THEMES = {
aura: resolveTheme(aura),
ayu: resolveTheme(ayu),
catppuccin: resolveTheme(catppuccin),
cobalt2: resolveTheme(cobalt2),
dracula: resolveTheme(dracula),
everforest: resolveTheme(everforest),
github: resolveTheme(github),
gruvbox: resolveTheme(gruvbox),
kanagawa: resolveTheme(kanagawa),
material: resolveTheme(material),
matrix: resolveTheme(matrix),
monokai: resolveTheme(monokai),
nord: resolveTheme(nord),
["one-dark"]: resolveTheme(onedark),
opencode: resolveTheme(opencode),
palenight: resolveTheme(palenight),
rosepine: resolveTheme(rosepine),
solarized: resolveTheme(solarized),
synthwave84: resolveTheme(synthwave84),
tokyonight: resolveTheme(tokyonight),
vesper: resolveTheme(vesper),
zenburn: resolveTheme(zenburn),
}
function resolveTheme(theme: ThemeJson) {
const defs = theme.defs ?? {}
function resolveColor(c: ColorValue): RGBA {
if (typeof c === "string") return c.startsWith("#") ? RGBA.fromHex(c) : resolveColor(defs[c])
// TODO: support light theme when opentui has the equivalent of lipgloss.AdaptiveColor
return resolveColor(c.dark)
}
return Object.fromEntries(
Object.entries(theme.theme).map(([key, value]) => {
return [key, resolveColor(value)]
}),
) as Theme
}
const syntaxThemeDark = [
{
scope: ["prompt"],
style: {
foreground: "#7dcfff",
},
},
{
scope: ["extmark.file"],
style: {
foreground: "#ff9e64",
bold: true,
},
},
{
scope: ["extmark.agent"],
style: {
foreground: "#bb9af7",
bold: true,
},
},
{
scope: ["extmark.paste"],
style: {
foreground: "#1a1b26",
background: "#ff9e64",
bold: true,
},
},
{
scope: ["comment"],
style: {
foreground: "#565f89",
italic: true,
},
},
{
scope: ["comment.documentation"],
style: {
foreground: "#565f89",
italic: true,
},
},
{
scope: ["string", "symbol"],
style: {
foreground: "#9ece6a",
},
},
{
scope: ["number", "boolean"],
style: {
foreground: "#ff9e64",
},
},
{
scope: ["character.special"],
style: {
foreground: "#9ece6a",
},
},
{
scope: ["keyword.return", "keyword.conditional", "keyword.repeat", "keyword.coroutine"],
style: {
foreground: "#bb9af7",
italic: true,
},
},
{
scope: ["keyword.type"],
style: {
foreground: "#2ac3de",
bold: true,
italic: true,
},
},
{
scope: ["keyword.function", "function.method"],
style: {
foreground: "#bb9af7",
},
},
{
scope: ["keyword"],
style: {
foreground: "#bb9af7",
italic: true,
},
},
{
scope: ["keyword.import"],
style: {
foreground: "#bb9af7",
},
},
{
scope: ["operator", "keyword.operator", "punctuation.delimiter"],
style: {
foreground: "#89ddff",
},
},
{
scope: ["keyword.conditional.ternary"],
style: {
foreground: "#89ddff",
},
},
{
scope: ["variable", "variable.parameter", "function.method.call", "function.call"],
style: {
foreground: "#7dcfff",
},
},
{
scope: ["variable.member", "function", "constructor"],
style: {
foreground: "#7aa2f7",
},
},
{
scope: ["type", "module"],
style: {
foreground: "#2ac3de",
},
},
{
scope: ["constant"],
style: {
foreground: "#ff9e64",
},
},
{
scope: ["property"],
style: {
foreground: "#73daca",
},
},
{
scope: ["class"],
style: {
foreground: "#2ac3de",
},
},
{
scope: ["parameter"],
style: {
foreground: "#e0af68",
},
},
{
scope: ["punctuation", "punctuation.bracket"],
style: {
foreground: "#89ddff",
},
},
{
scope: [
"variable.builtin",
"type.builtin",
"function.builtin",
"module.builtin",
"constant.builtin",
],
style: {
foreground: "#f7768e",
},
},
{
scope: ["variable.super"],
style: {
foreground: "#f7768e",
},
},
{
scope: ["string.escape", "string.regexp"],
style: {
foreground: "#bb9af7",
},
},
{
scope: ["keyword.directive"],
style: {
foreground: "#bb9af7",
italic: true,
},
},
{
scope: ["punctuation.special"],
style: {
foreground: "#89ddff",
},
},
{
scope: ["keyword.modifier"],
style: {
foreground: "#bb9af7",
italic: true,
},
},
{
scope: ["keyword.exception"],
style: {
foreground: "#bb9af7",
italic: true,
},
},
// Markdown specific styles
{
scope: ["markup.heading"],
style: {
foreground: "#7aa2f7",
bold: true,
},
},
{
scope: ["markup.heading.1"],
style: {
foreground: "#bb9af7",
bold: true,
},
},
{
scope: ["markup.heading.2"],
style: {
foreground: "#7aa2f7",
bold: true,
},
},
{
scope: ["markup.heading.3"],
style: {
foreground: "#7dcfff",
bold: true,
},
},
{
scope: ["markup.heading.4"],
style: {
foreground: "#73daca",
bold: true,
},
},
{
scope: ["markup.heading.5"],
style: {
foreground: "#9ece6a",
bold: true,
},
},
{
scope: ["markup.heading.6"],
style: {
foreground: "#565f89",
bold: true,
},
},
{
scope: ["markup.bold", "markup.strong"],
style: {
foreground: "#e6edf3",
bold: true,
},
},
{
scope: ["markup.italic"],
style: {
foreground: "#e6edf3",
italic: true,
},
},
{
scope: ["markup.list"],
style: {
foreground: "#ff9e64",
},
},
{
scope: ["markup.quote"],
style: {
foreground: "#565f89",
italic: true,
},
},
{
scope: ["markup.raw", "markup.raw.block"],
style: {
foreground: "#9ece6a",
},
},
{
scope: ["markup.raw.inline"],
style: {
foreground: "#9ece6a",
background: "#1a1b26",
},
},
{
scope: ["markup.link"],
style: {
foreground: "#7aa2f7",
underline: true,
},
},
{
scope: ["markup.link.label"],
style: {
foreground: "#7dcfff",
underline: true,
},
},
{
scope: ["markup.link.url"],
style: {
foreground: "#7aa2f7",
underline: true,
},
},
{
scope: ["label"],
style: {
foreground: "#73daca",
},
},
{
scope: ["spell", "nospell"],
style: {
foreground: "#e6edf3",
},
},
{
scope: ["conceal"],
style: {
foreground: "#565f89",
},
},
// Additional common highlight groups
{
scope: ["string.special", "string.special.url"],
style: {
foreground: "#73daca",
underline: true,
},
},
{
scope: ["character"],
style: {
foreground: "#9ece6a",
},
},
{
scope: ["float"],
style: {
foreground: "#ff9e64",
},
},
{
scope: ["comment.error"],
style: {
foreground: "#f7768e",
italic: true,
bold: true,
},
},
{
scope: ["comment.warning"],
style: {
foreground: "#e0af68",
italic: true,
bold: true,
},
},
{
scope: ["comment.todo", "comment.note"],
style: {
foreground: "#7aa2f7",
italic: true,
bold: true,
},
},
{
scope: ["namespace"],
style: {
foreground: "#2ac3de",
},
},
{
scope: ["field"],
style: {
foreground: "#73daca",
},
},
{
scope: ["type.definition"],
style: {
foreground: "#2ac3de",
bold: true,
},
},
{
scope: ["keyword.export"],
style: {
foreground: "#bb9af7",
},
},
{
scope: ["attribute", "annotation"],
style: {
foreground: "#e0af68",
},
},
{
scope: ["tag"],
style: {
foreground: "#f7768e",
},
},
{
scope: ["tag.attribute"],
style: {
foreground: "#bb9af7",
},
},
{
scope: ["tag.delimiter"],
style: {
foreground: "#89ddff",
},
},
{
scope: ["markup.strikethrough"],
style: {
foreground: "#565f89",
},
},
{
scope: ["markup.underline"],
style: {
foreground: "#e6edf3",
underline: true,
},
},
{
scope: ["markup.list.checked"],
style: {
foreground: "#9ece6a",
},
},
{
scope: ["markup.list.unchecked"],
style: {
foreground: "#565f89",
},
},
{
scope: ["diff.plus"],
style: {
foreground: "#9ece6a",
},
},
{
scope: ["diff.minus"],
style: {
foreground: "#f7768e",
},
},
{
scope: ["diff.delta"],
style: {
foreground: "#7dcfff",
},
},
{
scope: ["error"],
style: {
foreground: "#f7768e",
bold: true,
},
},
{
scope: ["warning"],
style: {
foreground: "#e0af68",
bold: true,
},
},
{
scope: ["info"],
style: {
foreground: "#7dcfff",
},
},
{
scope: ["debug"],
style: {
foreground: "#565f89",
},
},
]
export const SyntaxTheme = SyntaxStyle.fromTheme(syntaxThemeDark)
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
name: "Theme",
init: () => {
const sync = useSync()
const [selectedTheme, setSelectedTheme] = createSignal<keyof typeof THEMES>("opencode")
const [theme, setTheme] = createStore({} as Theme)
createEffect(() => {
if (!sync.ready) return
setSelectedTheme(
iife(() => {
if (typeof sync.data.config.theme === "string" && sync.data.config.theme in THEMES) {
return sync.data.config.theme as keyof typeof THEMES
}
return "opencode"
}),
)
})
createEffect(() => {
setTheme(reconcile(THEMES[selectedTheme()]))
})
return {
theme,
selectedTheme,
setSelectedTheme,
get ready() {
return sync.ready
},
}
},
})

View File

@@ -0,0 +1,39 @@
import { Bus } from "@/bus"
import z from "zod"
export const TuiEvent = {
PromptAppend: Bus.event("tui.prompt.append", z.object({ text: z.string() })),
CommandExecute: Bus.event(
"tui.command.execute",
z.object({
command: z.union([
z.enum([
"session.list",
"session.new",
"session.share",
"session.interrupt",
"session.compact",
"session.page.up",
"session.page.down",
"session.half.page.up",
"session.half.page.down",
"session.first",
"session.last",
"prompt.clear",
"prompt.submit",
"agent.cycle",
]),
z.string(),
]),
}),
),
ToastShow: Bus.event(
"tui.toast.show",
z.object({
title: z.string().optional(),
message: z.string(),
variant: z.enum(["info", "success", "warning", "error"]),
duration: z.number().default(5000).optional().describe("Duration in milliseconds"),
}),
),
}

View File

@@ -0,0 +1,83 @@
import { Prompt, type PromptRef } from "@tui/component/prompt"
import { createEffect, createMemo, Match, Show, Switch, type ParentProps } from "solid-js"
import { useTheme } from "@tui/context/theme"
import { useKeybind } from "../context/keybind"
import type { KeybindsConfig } from "@opencode-ai/sdk"
import { Logo } from "../component/logo"
import { Locale } from "@/util/locale"
import { useSync } from "../context/sync"
import { Toast } from "../ui/toast"
import { useDialog } from "../ui/dialog"
export function Home() {
const sync = useSync()
const { theme } = useTheme()
const dialog = useDialog()
const mcpError = createMemo(() => {
return Object.values(sync.data.mcp).some((x) => x.status === "failed")
})
let promptRef: PromptRef | undefined = undefined
createEffect(() => {
dialog.allClosedEvent.listen(() => {
promptRef?.focus()
})
})
const Hint = (
<Show when={Object.keys(sync.data.mcp).length > 0}>
<box flexShrink={0} flexDirection="row" gap={1}>
<text>
<Switch>
<Match when={mcpError()}>
<span style={{ fg: theme.error }}></span> mcp errors{" "}
<span style={{ fg: theme.textMuted }}>ctrl+x s</span>
</Match>
<Match when={true}>
<span style={{ fg: theme.success }}></span>{" "}
{Locale.pluralize(
Object.values(sync.data.mcp).length,
"{} mcp server",
"{} mcp servers",
)}
</Match>
</Switch>
</text>
</box>
</Show>
)
return (
<box
flexGrow={1}
justifyContent="center"
alignItems="center"
paddingLeft={2}
paddingRight={2}
gap={1}
>
<Logo />
<box width={39}>
<HelpRow keybind="command_list">Commands</HelpRow>
<HelpRow keybind="session_list">List sessions</HelpRow>
<HelpRow keybind="model_list">Switch model</HelpRow>
<HelpRow keybind="agent_cycle">Switch agent</HelpRow>
</box>
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1}>
<Prompt hint={Hint} ref={(r) => (promptRef = r)} />
</box>
<Toast />
</box>
)
}
function HelpRow(props: ParentProps<{ keybind: keyof KeybindsConfig }>) {
const keybind = useKeybind()
const { theme } = useTheme()
return (
<box flexDirection="row" justifyContent="space-between" width="100%">
<text>{props.children}</text>
<text fg={theme.primary}>{keybind.print(props.keybind)}</text>
</box>
)
}

View File

@@ -0,0 +1,56 @@
import { createMemo } from "solid-js"
import { useSync } from "@tui/context/sync"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
export function DialogMessage(props: { messageID: string; sessionID: string }) {
const sync = useSync()
const sdk = useSDK()
const message = createMemo(() => sync.data.message[props.sessionID]?.find((x) => x.id === props.messageID))
const route = useRoute()
return (
<DialogSelect
title="Message Actions"
options={[
{
title: "Revert",
value: "session.revert",
description: "undo messages and file changes",
onSelect: (dialog) => {
sdk.client.session.revert({
path: {
id: props.sessionID,
},
body: {
messageID: message()!.id,
},
})
dialog.clear()
},
},
{
title: "Fork",
value: "session.fork",
description: "create a new session",
onSelect: async (dialog) => {
const result = await sdk.client.session.fork({
path: {
id: props.sessionID,
},
body: {
messageID: props.messageID,
},
})
route.navigate({
sessionID: result.data!.id,
type: "session",
})
dialog.clear()
},
},
]}
/>
)
}

View File

@@ -0,0 +1,37 @@
import { createMemo, onMount } from "solid-js"
import { useSync } from "@tui/context/sync"
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
import type { TextPart } from "@opencode-ai/sdk"
import { Locale } from "@/util/locale"
import { DialogMessage } from "./dialog-message"
import { useDialog } from "../../ui/dialog"
export function DialogTimeline(props: { sessionID: string; onMove: (messageID: string) => void }) {
const sync = useSync()
const dialog = useDialog()
onMount(() => {
dialog.setSize("large")
})
const options = createMemo((): DialogSelectOption<string>[] => {
const messages = sync.data.message[props.sessionID] ?? []
const result = [] as DialogSelectOption<string>[]
for (const message of messages) {
if (message.role !== "user") continue
const part = (sync.data.part[message.id] ?? []).find((x) => x.type === "text" && !x.synthetic) as TextPart
if (!part) continue
result.push({
title: part.text.replace(/\n/g, " "),
value: message.id,
footer: Locale.time(message.time.created),
onSelect: (dialog) => {
dialog.replace(() => <DialogMessage messageID={message.id} sessionID={props.sessionID} />)
},
})
}
return result
})
return <DialogSelect onMove={(option) => props.onMove(option.value)} title="Timeline" options={options()} />
}

View File

@@ -0,0 +1,81 @@
import { 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"
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 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 context = createMemo(() => {
const last = messages().findLast(
(x) => x.role === "assistant" && x.tokens.output > 0,
) as AssistantMessage
if (!last) return
const total =
last.tokens.input +
last.tokens.output +
last.tokens.reasoning +
last.tokens.cache.read +
last.tokens.cache.write
const model = sync.data.provider.find((x) => x.id === last.providerID)?.models[last.modelID]
let result = total.toLocaleString()
if (model?.limit.context) {
result += "/" + Math.round((total / model.limit.context) * 100) + "%"
}
return result
})
return (
<box
paddingLeft={1}
paddingRight={1}
{...SplitBorder}
borderColor={theme.backgroundElement}
flexShrink={0}
>
<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 wrapMode="word">
/share <span style={{ fg: theme.textMuted }}>to create a shareable link</span>
</text>
</Match>
</Switch>
</box>
<Show when={context()}>
<text fg={theme.textMuted} wrapMode="none" flexShrink={0}>
{context()} ({cost()})
</text>
</Show>
</box>
</box>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,175 @@
import { useSync } from "@tui/context/sync"
import { createMemo, For, Show, Switch, Match } from "solid-js"
import { useTheme } from "../../context/theme"
import { Locale } from "@/util/locale"
import path from "path"
import type { AssistantMessage } from "@opencode-ai/sdk"
export function Sidebar(props: { sessionID: string }) {
const sync = useSync()
const { theme } = useTheme()
const session = createMemo(() => sync.session.get(props.sessionID)!)
const todo = createMemo(() => sync.data.todo[props.sessionID] ?? [])
const messages = createMemo(() => sync.data.message[props.sessionID] ?? [])
const cost = createMemo(() => {
const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(total)
})
const context = createMemo(() => {
const last = messages().findLast(
(x) => x.role === "assistant" && x.tokens.output > 0,
) as AssistantMessage
if (!last) return
const total =
last.tokens.input +
last.tokens.output +
last.tokens.reasoning +
last.tokens.cache.read +
last.tokens.cache.write
const model = sync.data.provider.find((x) => x.id === last.providerID)?.models[last.modelID]
return {
tokens: total.toLocaleString(),
percentage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null,
}
})
return (
<Show when={session()}>
<box flexShrink={0} gap={1} width={40}>
<box>
<text>
<b>{session().title}</b>
</text>
<Show when={session().share?.url}>
<text fg={theme.textMuted}>{session().share!.url}</text>
</Show>
</box>
<box>
<text>
<b>Context</b>
</text>
<text fg={theme.textMuted}>{context()?.tokens ?? 0} tokens</text>
<text fg={theme.textMuted}>{context()?.percentage ?? 0}% used</text>
<text fg={theme.textMuted}>{cost()} spent</text>
</box>
<Show when={Object.keys(sync.data.mcp).length > 0}>
<box>
<text>
<b>MCP</b>
</text>
<For each={Object.entries(sync.data.mcp)}>
{([key, item]) => (
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
style={{
fg: {
connected: theme.success,
failed: theme.error,
disabled: theme.textMuted,
}[item.status],
}}
>
</text>
<text wrapMode="word">
{key}{" "}
<span style={{ fg: theme.textMuted }}>
<Switch>
<Match when={item.status === "connected"}>Connected</Match>
<Match when={item.status === "failed" && item}>
{(val) => <i>{val().error}</i>}
</Match>
<Match when={item.status === "disabled"}>Disabled in configuration</Match>
</Switch>
</span>
</text>
</box>
)}
</For>
</box>
</Show>
<Show when={sync.data.lsp.length > 0}>
<box>
<text>
<b>LSP</b>
</text>
<For each={sync.data.lsp}>
{(item) => (
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
style={{
fg: {
connected: theme.success,
error: theme.error,
}[item.status],
}}
>
</text>
<text fg={theme.textMuted}>
{item.id} {item.root}
</text>
</box>
)}
</For>
</box>
</Show>
<Show when={session().summary?.diffs}>
<box>
<text>
<b>Modified Files</b>
</text>
<For each={session().summary?.diffs || []}>
{(item) => {
const file = createMemo(() => {
const splits = item.file.split(path.sep).filter(Boolean)
const last = splits.at(-1)!
const rest = splits.slice(0, -1).join(path.sep)
return Locale.truncateMiddle(rest, 30 - last.length) + "/" + last
})
return (
<box flexDirection="row" gap={1} justifyContent="space-between">
<text fg={theme.textMuted} wrapMode="char">
{file()}
</text>
<box flexDirection="row" gap={1} flexShrink={0}>
<Show when={item.additions}>
<text fg={theme.diffAdded}>+{item.additions}</text>
</Show>
<Show when={item.deletions}>
<text fg={theme.diffRemoved}>-{item.deletions}</text>
</Show>
</box>
</box>
)
}}
</For>
</box>
</Show>
<Show when={todo().length > 0}>
<box>
<text>
<b>Todo</b>
</text>
<For each={todo()}>
{(todo) => (
<text
style={{ fg: todo.status === "in_progress" ? theme.success : theme.textMuted }}
>
[{todo.status === "completed" ? "✓" : " "}] {todo.content}
</text>
)}
</For>
</box>
</Show>
</box>
</Show>
)
}

View File

@@ -0,0 +1,57 @@
import { cmd } from "@/cli/cmd/cmd"
import { Instance } from "@/project/instance"
import path from "path"
import { Server } from "@/server/server"
import { upgrade } from "@/cli/upgrade"
export const TuiSpawnCommand = cmd({
command: "spawn [project]",
builder: (yargs) =>
yargs
.positional("project", {
type: "string",
describe: "path to start opencode in",
})
.option("port", {
type: "number",
describe: "port to listen on",
default: 0,
})
.option("hostname", {
alias: ["h"],
type: "string",
describe: "hostname to listen on",
default: "127.0.0.1",
}),
handler: async (args) => {
upgrade()
const server = Server.listen({
port: args.port,
hostname: "127.0.0.1",
})
const bin = process.execPath
const cmd = []
let cwd = process.cwd()
if (bin.endsWith("bun")) {
cmd.push(
process.execPath,
"run",
"--conditions",
"browser",
new URL("../../../index.ts", import.meta.url).pathname,
)
cwd = new URL("../../../../", import.meta.url).pathname
} else cmd.push(process.execPath)
cmd.push("attach", server.url.toString(), "--dir", args.project ? path.resolve(args.project) : process.cwd())
const proc = Bun.spawn({
cmd,
cwd,
stdout: "inherit",
stderr: "inherit",
stdin: "inherit",
})
await proc.exited
await Instance.disposeAll()
await server.stop(true)
},
})

View File

@@ -0,0 +1,105 @@
import { cmd } from "@/cli/cmd/cmd"
import { tui } from "./app"
import { Rpc } from "@/util/rpc"
import { type rpc } from "./worker"
import { upgrade } from "@/cli/upgrade"
import { Session } from "@/session"
import { bootstrap } from "@/cli/bootstrap"
import path from "path"
import { UI } from "@/cli/ui"
export const TuiThreadCommand = 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("agent", {
type: "string",
describe: "agent to use",
})
.option("port", {
type: "number",
describe: "port to listen on",
default: 0,
})
.option("hostname", {
alias: ["h"],
type: "string",
describe: "hostname to listen on",
default: "127.0.0.1",
}),
handler: async (args) => {
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
}
await bootstrap(cwd, async () => {
upgrade()
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 worker = new Worker("./src/cli/cmd/tui/worker.ts")
worker.onerror = console.error
const client = Rpc.client<typeof rpc>(worker)
process.on("uncaughtException", (e) => {
console.error(e)
})
process.on("unhandledRejection", (e) => {
console.error(e)
})
const server = await client.call("server", {
port: args.port,
hostname: args.hostname,
})
await tui({
url: server.url,
sessionID,
model: args.model,
agent: args.agent,
onExit: async () => {
await client.call("shutdown", undefined)
},
})
})
},
})

View File

@@ -0,0 +1,55 @@
import { TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { useKeyboard } from "@opentui/solid"
export type DialogAlertProps = {
title: string
message: string
onConfirm?: () => void
}
export function DialogAlert(props: DialogAlertProps) {
const dialog = useDialog()
const { theme } = useTheme()
useKeyboard((evt) => {
if (evt.name === "return") {
props.onConfirm?.()
dialog.clear()
}
})
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD}>{props.title}</text>
<text fg={theme.textMuted}>esc</text>
</box>
<box paddingBottom={1}>
<text fg={theme.textMuted}>{props.message}</text>
</box>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
<box
paddingLeft={3}
paddingRight={3}
backgroundColor={theme.primary}
onMouseUp={() => {
props.onConfirm?.()
dialog.clear()
}}
>
<text fg={theme.background}>ok</text>
</box>
</box>
</box>
)
}
DialogAlert.show = (dialog: DialogContext, title: string, message: string) => {
return new Promise<void>((resolve) => {
dialog.replace(
() => <DialogAlert title={title} message={message} onConfirm={() => resolve()} />,
() => resolve(),
)
})
}

View File

@@ -0,0 +1,79 @@
import { TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { createStore } from "solid-js/store"
import { For } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { Locale } from "@/util/locale"
export type DialogConfirmProps = {
title: string
message: string
onConfirm?: () => void
onCancel?: () => void
}
export function DialogConfirm(props: DialogConfirmProps) {
const dialog = useDialog()
const { theme } = useTheme()
const [store, setStore] = createStore({
active: "confirm" as "confirm" | "cancel",
})
useKeyboard((evt) => {
if (evt.name === "return") {
if (store.active === "confirm") props.onConfirm?.()
if (store.active === "cancel") props.onCancel?.()
dialog.clear()
}
if (evt.name === "left" || evt.name === "right") {
setStore("active", store.active === "confirm" ? "cancel" : "confirm")
}
})
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD}>{props.title}</text>
<text fg={theme.textMuted}>esc</text>
</box>
<box paddingBottom={1}>
<text fg={theme.textMuted}>{props.message}</text>
</box>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
<For each={["cancel", "confirm"]}>
{(key) => (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={key === store.active ? theme.primary : undefined}
onMouseUp={(evt) => {
if (key === "confirm") props.onConfirm?.()
if (key === "cancel") props.onCancel?.()
dialog.clear()
}}
>
<text fg={key === store.active ? theme.background : theme.textMuted}>{Locale.titlecase(key)}</text>
</box>
)}
</For>
</box>
</box>
)
}
DialogConfirm.show = (dialog: DialogContext, title: string, message: string) => {
return new Promise<boolean>((resolve) => {
dialog.replace(
() => (
<DialogConfirm
title={title}
message={message}
onConfirm={() => resolve(true)}
onCancel={() => resolve(false)}
/>
),
() => resolve(false),
)
})
}

View File

@@ -0,0 +1,39 @@
import { TextAttributes } from "@opentui/core"
import { useTheme } from "@tui/context/theme"
import { useDialog } from "./dialog"
import { useKeyboard } from "@opentui/solid"
export function DialogHelp() {
const dialog = useDialog()
const { theme } = useTheme()
useKeyboard((evt) => {
if (evt.name === "return" || evt.name === "escape") {
dialog.clear()
}
})
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD}>Help</text>
<text fg={theme.textMuted}>esc/enter</text>
</box>
<box paddingBottom={1}>
<text fg={theme.textMuted}>
Press Ctrl+P to see all available actions and commands in any context.
</text>
</box>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
<box
paddingLeft={3}
paddingRight={3}
backgroundColor={theme.primary}
onMouseUp={() => dialog.clear()}
>
<text fg={theme.background}>ok</text>
</box>
</box>
</box>
)
}

View File

@@ -0,0 +1,275 @@
import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
import { useTheme } from "@tui/context/theme"
import { entries, filter, flatMap, groupBy, pipe, take } from "remeda"
import { batch, createEffect, createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import * as fuzzysort from "fuzzysort"
import { isDeepEqual } from "remeda"
import { useDialog, type DialogContext } from "@tui/ui/dialog"
import { useKeybind } from "@tui/context/keybind"
import { Keybind } from "@/util/keybind"
import { Locale } from "@/util/locale"
export interface DialogSelectProps<T> {
title: string
options: DialogSelectOption<T>[]
ref?: (ref: DialogSelectRef<T>) => void
onMove?: (option: DialogSelectOption<T>) => void
onFilter?: (query: string) => void
onSelect?: (option: DialogSelectOption<T>) => void
keybind?: {
keybind: Keybind.Info
title: string
onTrigger: (option: DialogSelectOption<T>) => void
}[]
limit?: number
current?: T
}
export interface DialogSelectOption<T = any> {
title: string
value: T
description?: string
footer?: string
category?: string
disabled?: boolean
bg?: RGBA
onSelect?: (ctx: DialogContext) => void
}
export type DialogSelectRef<T> = {
filter: string
filtered: DialogSelectOption<T>[]
}
export function DialogSelect<T>(props: DialogSelectProps<T>) {
const dialog = useDialog()
const { theme } = useTheme()
const [store, setStore] = createStore({
selected: 0,
filter: "",
})
let input: InputRenderable
const filtered = createMemo(() => {
const needle = store.filter.toLowerCase()
const result = pipe(
props.options,
filter((x) => x.disabled !== true),
take(props.limit ?? Infinity),
(x) => (!needle ? x : fuzzysort.go(needle, x, { keys: ["title", "category"] }).map((x) => x.obj)),
)
return result
})
const grouped = createMemo(() => {
const result = pipe(
filtered(),
groupBy((x) => x.category ?? ""),
// mapValues((x) => x.sort((a, b) => a.title.localeCompare(b.title))),
entries(),
)
return result
})
const flat = createMemo(() => {
return pipe(
grouped(),
flatMap(([_, options]) => options),
)
})
const dimensions = useTerminalDimensions()
const height = createMemo(() =>
Math.min(flat().length + grouped().length * 2 - 1, Math.floor(dimensions().height / 2) - 6),
)
const selected = createMemo(() => flat()[store.selected])
createEffect(() => {
store.filter
setStore("selected", 0)
scroll.scrollTo(0)
})
function move(direction: number) {
let next = store.selected + direction
if (next < 0) next = flat().length - 1
if (next >= flat().length) next = 0
moveTo(next)
}
function moveTo(next: number) {
setStore("selected", next)
props.onMove?.(selected()!)
const target = scroll.getChildren().find((child) => {
return child.id === JSON.stringify(selected()?.value)
})
if (!target) return
const y = target.y - scroll.y
if (y >= scroll.height) {
scroll.scrollBy(y - scroll.height + 1)
}
if (y < 0) {
scroll.scrollBy(y)
if (isDeepEqual(flat()[0].value, selected()?.value)) {
scroll.scrollTo(0)
}
}
}
const keybind = useKeybind()
useKeyboard((evt) => {
if (evt.name === "up") move(-1)
if (evt.name === "down") move(1)
if (evt.name === "pageup") move(-10)
if (evt.name === "pagedown") move(10)
if (evt.name === "return") {
const option = selected()
if (option.onSelect) option.onSelect(dialog)
props.onSelect?.(option)
}
for (const item of props.keybind ?? []) {
if (Keybind.match(item.keybind, keybind.parse(evt))) {
const s = selected()
if (s) item.onTrigger(s)
}
}
})
let scroll: ScrollBoxRenderable
const ref: DialogSelectRef<T> = {
get filter() {
return store.filter
},
get filtered() {
return filtered()
},
}
props.ref?.(ref)
return (
<box gap={1}>
<box paddingLeft={3} paddingRight={2}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD}>{props.title}</text>
<text fg={theme.textMuted}>esc</text>
</box>
<box paddingTop={1} paddingBottom={1}>
<input
onInput={(e) => {
batch(() => {
setStore("filter", e)
props.onFilter?.(e)
})
}}
focusedBackgroundColor={theme.backgroundPanel}
cursorColor={theme.primary}
focusedTextColor={theme.textMuted}
ref={(r) => {
input = r
input.focus()
}}
placeholder="Enter search term"
/>
</box>
</box>
<scrollbox
paddingLeft={2}
paddingRight={2}
scrollbarOptions={{ visible: false }}
ref={(r: ScrollBoxRenderable) => (scroll = r)}
maxHeight={height()}
>
<For each={grouped()}>
{([category, options], index) => (
<>
<Show when={category}>
<box paddingTop={index() > 0 ? 1 : 0} paddingLeft={1}>
<text fg={theme.accent} attributes={TextAttributes.BOLD}>
{category}
</text>
</box>
</Show>
<For each={options}>
{(option) => {
const active = createMemo(() => isDeepEqual(option.value, selected()?.value))
return (
<box
id={JSON.stringify(option.value)}
flexDirection="row"
onMouseUp={() => {
option.onSelect?.(dialog)
props.onSelect?.(option)
}}
onMouseOver={() => {
const index = filtered().findIndex((x) => isDeepEqual(x.value, option.value))
if (index === -1) return
moveTo(index)
}}
backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)}
paddingLeft={1}
paddingRight={1}
gap={1}
>
<Option
title={option.title}
footer={option.footer}
description={option.description !== category ? option.description : undefined}
active={active()}
current={isDeepEqual(option.value, props.current)}
/>
</box>
)
}}
</For>
</>
)}
</For>
</scrollbox>
<box paddingRight={2} paddingLeft={3} flexDirection="row" paddingBottom={1}>
<For each={props.keybind ?? []}>
{(item) => (
<text>
<span style={{ fg: theme.text, attributes: TextAttributes.BOLD }}>{Keybind.toString(item.keybind)}</span>
<span style={{ fg: theme.textMuted }}> {item.title}</span>
</text>
)}
</For>
</box>
</box>
)
}
function Option(props: {
title: string
description?: string
active?: boolean
current?: boolean
footer?: string
onMouseOver?: () => void
}) {
const { theme } = useTheme()
return (
<>
<text
flexGrow={1}
fg={props.active ? theme.background : props.current ? theme.primary : theme.text}
attributes={props.active ? TextAttributes.BOLD : undefined}
overflow="hidden"
wrapMode="none"
>
{Locale.truncate(props.title, 62)}
<span style={{ fg: props.active ? theme.background : theme.textMuted }}> {props.description}</span>
</text>
<Show when={props.footer}>
<box flexShrink={0}>
<text fg={props.active ? theme.background : theme.textMuted}>{props.footer}</text>
</box>
</Show>
</>
)
}

View File

@@ -0,0 +1,171 @@
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { batch, createContext, createEffect, Show, useContext, type JSX, type ParentProps } from "solid-js"
import { useTheme } from "@tui/context/theme"
import { Renderable, RGBA } from "@opentui/core"
import { createStore } from "solid-js/store"
import { createEventBus } from "@solid-primitives/event-bus"
const Border = {
topLeft: "┃",
topRight: "┃",
bottomLeft: "┃",
bottomRight: "┃",
horizontal: "",
vertical: "┃",
topT: "+",
bottomT: "+",
leftT: "+",
rightT: "+",
cross: "+",
}
export function Dialog(
props: ParentProps<{
size?: "medium" | "large"
onClose: () => void
}>,
) {
const dimensions = useTerminalDimensions()
const { theme } = useTheme()
return (
<box
onMouseUp={async () => {
props.onClose?.()
}}
width={dimensions().width}
height={dimensions().height}
alignItems="center"
position="absolute"
paddingTop={dimensions().height / 4}
left={0}
top={0}
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
>
<box
onMouseUp={async (e) => {
e.stopPropagation()
}}
customBorderChars={Border}
width={props.size === "large" ? 80 : 60}
maxWidth={dimensions().width - 2}
backgroundColor={theme.backgroundPanel}
borderColor={theme.border}
paddingTop={1}
>
{props.children}
</box>
</box>
)
}
function init() {
const [store, setStore] = createStore({
stack: [] as {
element: JSX.Element
onClose?: () => void
}[],
size: "medium" as "medium" | "large",
})
const allClosedEvent = createEventBus<void>()
useKeyboard((evt) => {
if (evt.name === "escape" && store.stack.length > 0) {
const current = store.stack.at(-1)!
current.onClose?.()
setStore("stack", store.stack.slice(0, -1))
evt.preventDefault()
refocus()
}
})
const renderer = useRenderer()
let focus: Renderable | null
function refocus() {
setTimeout(() => {
if (!focus) return
if (focus.isDestroyed) return
function find(item: Renderable) {
for (const child of item.getChildren()) {
if (child === focus) return true
if (find(child)) return true
}
return false
}
const found = find(renderer.root)
if (!found) return
focus.focus()
}, 1)
}
createEffect(() => {
if (store.stack.length === 0) {
allClosedEvent.emit()
}
})
return {
clear() {
for (const item of store.stack) {
if (item.onClose) item.onClose()
}
batch(() => {
setStore("size", "medium")
setStore("stack", [])
})
refocus()
},
replace(input: any, onClose?: () => void) {
if (store.stack.length === 0) focus = renderer.currentFocusedRenderable
for (const item of store.stack) {
if (item.onClose) item.onClose()
}
setStore("size", "medium")
setStore("stack", [
{
element: input,
onClose,
},
])
},
get stack() {
return store.stack
},
get size() {
return store.size
},
setSize(size: "medium" | "large") {
setStore("size", size)
},
get allClosedEvent() {
return allClosedEvent
}
}
}
export type DialogContext = ReturnType<typeof init>
const ctx = createContext<DialogContext>()
export function DialogProvider(props: ParentProps) {
const value = init()
return (
<ctx.Provider value={value}>
{props.children}
<box position="absolute">
<Show when={value.stack.length}>
<Dialog onClose={() => value.clear()} size={value.size}>
{value.stack.at(-1)!.element}
</Dialog>
</Show>
</box>
</ctx.Provider>
)
}
export function useDialog() {
const value = useContext(ctx)
if (!value) {
throw new Error("useDialog must be used within a DialogProvider")
}
return value
}

View File

@@ -0,0 +1,56 @@
import { RGBA } from "@opentui/core"
import { useTimeline } from "@opentui/solid"
import { createMemo, createSignal } from "solid-js"
export type ShimmerProps = {
text: string
color: RGBA
}
const DURATION = 2_500
export function Shimmer(props: ShimmerProps) {
const timeline = useTimeline({
duration: DURATION,
loop: true,
})
const characters = props.text.split("")
const color = props.color
const shimmerSignals = characters.map((_, i) => {
const [shimmer, setShimmer] = createSignal(0.4)
const target = {
shimmer: shimmer(),
setShimmer,
}
timeline!.add(
target,
{
shimmer: 1,
duration: DURATION / (props.text.length + 1),
ease: "linear",
alternate: true,
loop: 2,
onUpdate: () => {
target.setShimmer(target.shimmer)
},
},
(i * (DURATION / (props.text.length + 1))) / 2,
)
return shimmer
})
return (
<text>
{(() => {
return characters.map((ch, i) => {
const shimmer = shimmerSignals[i]
const fg = RGBA.fromInts(color.r * 255, color.g * 255, color.b * 255, shimmer() * 255)
return <span style={{ fg }}>{ch}</span>
})
})()}
</text>
)
}

View File

@@ -0,0 +1,83 @@
import { createContext, useContext, type ParentProps, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useTheme } from "@tui/context/theme"
import { SplitBorder } from "../component/border"
import { TextAttributes } from "@opentui/core"
import z from "zod"
import { TuiEvent } from "../event"
export type ToastOptions = z.infer<typeof TuiEvent.ToastShow.properties>
export function Toast() {
const toast = useToast()
const { theme } = useTheme()
return (
<Show when={toast.currentToast}>
{(current) => (
<box
position="absolute"
justifyContent="center"
alignItems="flex-start"
top={2}
right={2}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
backgroundColor={theme.backgroundPanel}
borderColor={theme[current().variant]}
border={["left", "right"]}
customBorderChars={SplitBorder.customBorderChars}
>
<Show when={current().title}>
<text attributes={TextAttributes.BOLD} marginBottom={1}>
{current().title}
</text>
</Show>
<text>{current().message}</text>
</box>
)}
</Show>
)
}
function init() {
const [store, setStore] = createStore({
currentToast: null as ToastOptions | null,
})
let timeoutHandle: NodeJS.Timeout | null = null
return {
show(options: ToastOptions) {
const parsedOptions = TuiEvent.ToastShow.properties.parse(options)
const { duration, ...currentToast } = parsedOptions
setStore("currentToast", currentToast)
if (timeoutHandle) clearTimeout(timeoutHandle)
timeoutHandle = setTimeout(() => {
setStore("currentToast", null)
}, duration).unref()
},
get currentToast(): ToastOptions | null {
return store.currentToast
},
}
}
export type ToastContext = ReturnType<typeof init>
const ctx = createContext<ToastContext>()
export function ToastProvider(props: ParentProps) {
const value = init()
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
}
export function useToast() {
const value = useContext(ctx)
if (!value) {
throw new Error("useToast must be used within a ToastProvider")
}
return value
}

View File

@@ -0,0 +1,127 @@
import { $ } from "bun"
import { platform } from "os"
import clipboardy from "clipboardy"
import { lazy } from "../../../../util/lazy.js"
import { tmpdir } from "os"
import path from "path"
export namespace Clipboard {
export interface Content {
data: string
mime: string
}
export async function read(): Promise<Content | undefined> {
const os = platform()
if (os === "darwin") {
const tmpfile = path.join(tmpdir(), "opencode-clipboard.png")
try {
await $`osascript -e 'set imageData to the clipboard as "PNGf"' -e 'set fileRef to open for access POSIX file "${tmpfile}" with write permission' -e 'set eof fileRef to 0' -e 'write imageData to fileRef' -e 'close access fileRef'`
.nothrow()
.quiet()
const file = Bun.file(tmpfile)
const buffer = await file.arrayBuffer()
return { data: Buffer.from(buffer).toString("base64"), mime: "image/png" }
} catch {
} finally {
await $`rm -f "${tmpfile}"`.nothrow().quiet()
}
}
if (os === "linux") {
const wayland = await $`wl-paste -t image/png`.nothrow().text()
if (wayland) {
return { data: Buffer.from(wayland).toString("base64url"), mime: "image/png" }
}
const x11 = await $`xclip -selection clipboard -t image/png -o`.nothrow().text()
if (x11) {
return { data: Buffer.from(x11).toString("base64url"), mime: "image/png" }
}
}
if (os === "win32") {
const script =
"Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
const base64 = await $`powershell -command "${script}"`.nothrow().text()
if (base64) {
const imageBuffer = Buffer.from(base64.trim(), "base64")
if (imageBuffer.length > 0) {
return { data: imageBuffer.toString("base64url"), mime: "image/png" }
}
}
}
const text = await clipboardy.read().catch(() => {})
if (text) {
return { data: text, mime: "text/plain" }
}
}
const getCopyMethod = lazy(() => {
const os = platform()
if (os === "darwin") {
console.log("clipboard: using osascript")
return async (text: string) => {
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
await $`osascript -e 'set the clipboard to "${escaped}"'`.nothrow().quiet()
}
}
if (os === "linux") {
if (process.env["WAYLAND_DISPLAY"]) {
console.log("clipboard: using wl-copy")
return async (text: string) => {
const proc = Bun.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
proc.stdin.write(text)
proc.stdin.end()
await proc.exited
}
}
if (Bun.which("xclip")) {
console.log("clipboard: using xclip")
return async (text: string) => {
const proc = Bun.spawn(["xclip", "-selection", "clipboard"], {
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
})
proc.stdin.write(text)
proc.stdin.end()
await proc.exited
}
}
if (Bun.which("xsel")) {
console.log("clipboard: using xsel")
return async (text: string) => {
const proc = Bun.spawn(["xsel", "--clipboard", "--input"], {
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
})
proc.stdin.write(text)
proc.stdin.end()
await proc.exited
}
}
}
if (os === "win32") {
console.log("clipboard: using powershell")
return async (text: string) => {
const escaped = text.replace(/"/g, '""')
await $`powershell -command "Set-Clipboard -Value \"${escaped}\""`.nothrow().quiet()
}
}
console.log("clipboard: no native support")
return async (text: string) => {
await clipboardy.write(text).catch(() => {})
}
})
export async function copy(text: string): Promise<void> {
await getCopyMethod()(text)
}
}

View File

@@ -0,0 +1,31 @@
import { defer } from "@/util/defer"
import { rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { CliRenderer } from "@opentui/core"
export namespace Editor {
export async function open(opts: { value: string; renderer: CliRenderer }): Promise<string | undefined> {
const editor = process.env["EDITOR"]
if (!editor) return
const filepath = join(tmpdir(), `${Date.now()}.md`)
await using _ = defer(async () => rm(filepath, { force: true }))
await Bun.write(filepath, opts.value)
opts.renderer.suspend()
opts.renderer.currentRenderBuffer.clear()
const parts = editor.split(" ")
const proc = Bun.spawn({
cmd: [...parts, filepath],
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
})
await proc.exited
const content = await Bun.file(filepath).text()
opts.renderer.resume()
opts.renderer.requestRender()
return content || undefined
}
}

View File

@@ -0,0 +1,48 @@
import { Installation } from "@/installation"
import { Server } from "@/server/server"
import { Log } from "@/util/log"
import { Instance } from "@/project/instance"
import { Rpc } from "@/util/rpc"
await Log.init({
print: process.argv.includes("--print-logs"),
dev: Installation.isLocal(),
level: (() => {
if (Installation.isLocal()) return "DEBUG"
return "INFO"
})(),
})
process.on("unhandledRejection", (e) => {
Log.Default.error("rejection", {
e: e instanceof Error ? e.message : e,
})
})
process.on("uncaughtException", (e) => {
Log.Default.error("exception", {
e: e instanceof Error ? e.message : e,
})
})
let server: Bun.Server<undefined>
export const rpc = {
async server(input: { port: number; hostname: string }) {
if (server) await server.stop(true)
try {
server = Server.listen(input)
return {
url: server.url.toString(),
}
} catch (e) {
console.error(e)
throw e
}
},
async shutdown() {
await Instance.disposeAll()
await server.stop(true)
},
}
Rpc.listen(rpc)

View File

@@ -0,0 +1,17 @@
import { Bus } from "@/bus"
import { Config } from "@/config/config"
import { Flag } from "@/flag/flag"
import { Installation } from "@/installation"
export async function upgrade() {
const config = await Config.global()
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(() => {})
}

View File

@@ -49,7 +49,7 @@ export namespace Config {
for (const [key, value] of Object.entries(auth)) {
if (value.type === "wellknown") {
process.env[value.key] = value.token
const wellknown = await fetch(`${key}/.well-known/opencode`).then((x) => x.json())
const wellknown = (await fetch(`${key}/.well-known/opencode`).then((x) => x.json())) as any
result = mergeDeep(
result,
await load(JSON.stringify(wellknown.config ?? {}), process.cwd()),
@@ -108,29 +108,13 @@ export namespace Config {
if (result.autoshare === true && !result.share) {
result.share = "auto"
}
if (result.keybinds?.messages_revert && !result.keybinds.messages_undo) {
result.keybinds.messages_undo = result.keybinds.messages_revert
}
// Handle migration from autoshare to share field
if (result.autoshare === true && !result.share) {
result.share = "auto"
}
if (result.keybinds?.messages_revert && !result.keybinds.messages_undo) {
result.keybinds.messages_undo = result.keybinds.messages_revert
}
if (result.keybinds?.switch_mode && !result.keybinds.switch_agent) {
result.keybinds.switch_agent = result.keybinds.switch_mode
}
if (result.keybinds?.switch_mode_reverse && !result.keybinds.switch_agent_reverse) {
result.keybinds.switch_agent_reverse = result.keybinds.switch_mode_reverse
}
if (result.keybinds?.switch_agent && !result.keybinds.agent_cycle) {
result.keybinds.agent_cycle = result.keybinds.switch_agent
}
if (result.keybinds?.switch_agent_reverse && !result.keybinds.agent_cycle_reverse) {
result.keybinds.agent_cycle_reverse = result.keybinds.switch_agent_reverse
}
if (!result.keybinds) result.keybinds = Info.shape.keybinds.parse({})
return {
config: result,
@@ -181,7 +165,7 @@ export namespace Config {
{
cwd: dir,
},
)
).catch(() => {})
}
const COMMAND_GLOB = new Bun.Glob("command/**/*.md")
@@ -401,17 +385,11 @@ export namespace Config {
.optional()
.default("ctrl+x")
.describe("Leader key for keybind combinations"),
app_help: z.string().optional().default("<leader>h").describe("Show help dialog"),
app_exit: z.string().optional().default("ctrl+c,<leader>q").describe("Exit the application"),
editor_open: z.string().optional().default("<leader>e").describe("Open external editor"),
theme_list: z.string().optional().default("<leader>t").describe("List available themes"),
project_init: z.string().optional().default("<leader>i").describe("Create/update AGENTS.md"),
tool_details: z.string().optional().default("<leader>d").describe("Toggle tool details"),
thinking_blocks: z
.string()
.optional()
.default("<leader>b")
.describe("Toggle thinking blocks"),
sidebar_toggle: z.string().optional().default("<leader>b").describe("Toggle sidebar"),
status_view: z.string().optional().default("<leader>s").describe("View status"),
session_export: z
.string()
.optional()
@@ -424,29 +402,23 @@ export namespace Config {
.optional()
.default("<leader>g")
.describe("Show session timeline"),
session_share: z.string().optional().default("<leader>s").describe("Share current session"),
session_share: z.string().optional().default("none").describe("Share current session"),
session_unshare: z.string().optional().default("none").describe("Unshare current session"),
session_interrupt: z.string().optional().default("esc").describe("Interrupt current session"),
session_interrupt: z
.string()
.optional()
.default("escape")
.describe("Interrupt current session"),
session_compact: z.string().optional().default("<leader>c").describe("Compact the session"),
session_child_cycle: z
.string()
.optional()
.default("ctrl+right")
.describe("Cycle to next child session"),
session_child_cycle_reverse: z
.string()
.optional()
.default("ctrl+left")
.describe("Cycle to previous child session"),
messages_page_up: z
.string()
.optional()
.default("pgup")
.default("pageup")
.describe("Scroll messages up by one page"),
messages_page_down: z
.string()
.optional()
.default("pgdown")
.default("pagedown")
.describe("Scroll messages down by one page"),
messages_half_page_up: z
.string()
@@ -458,22 +430,26 @@ export namespace Config {
.optional()
.default("ctrl+alt+d")
.describe("Scroll messages down by half page"),
messages_first: z.string().optional().default("ctrl+g").describe("Navigate to first message"),
messages_first: z
.string()
.optional()
.default("ctrl+g,home")
.describe("Navigate to first message"),
messages_last: z
.string()
.optional()
.default("ctrl+alt+g")
.default("ctrl+alt+g,end")
.describe("Navigate to last message"),
messages_copy: z.string().optional().default("<leader>y").describe("Copy message"),
messages_undo: z.string().optional().default("<leader>u").describe("Undo message"),
messages_redo: z.string().optional().default("<leader>r").describe("Redo message"),
model_list: z.string().optional().default("<leader>m").describe("List available models"),
model_cycle_recent: z.string().optional().default("f2").describe("Next recent model"),
model_cycle_recent_reverse: z
messages_toggle_conceal: z
.string()
.optional()
.default("shift+f2")
.describe("Previous recent model"),
.default("<leader>h")
.describe("Toggle code block concealment in messages"),
model_list: z.string().optional().default("<leader>m").describe("List available models"),
command_list: z.string().optional().default("ctrl+p").describe("List available commands"),
agent_list: z.string().optional().default("<leader>a").describe("List agents"),
agent_cycle: z.string().optional().default("tab").describe("Next agent"),
agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"),
@@ -485,59 +461,6 @@ export namespace Config {
.optional()
.default("shift+enter,ctrl+j")
.describe("Insert newline in input"),
// Deprecated commands
switch_mode: z
.string()
.optional()
.default("none")
.describe("@deprecated use agent_cycle. Next mode"),
switch_mode_reverse: z
.string()
.optional()
.default("none")
.describe("@deprecated use agent_cycle_reverse. Previous mode"),
switch_agent: z
.string()
.optional()
.default("tab")
.describe("@deprecated use agent_cycle. Next agent"),
switch_agent_reverse: z
.string()
.optional()
.default("shift+tab")
.describe("@deprecated use agent_cycle_reverse. Previous agent"),
file_list: z
.string()
.optional()
.default("none")
.describe("@deprecated Currently not available. List files"),
file_close: z.string().optional().default("none").describe("@deprecated Close file"),
file_search: z.string().optional().default("none").describe("@deprecated Search file"),
file_diff_toggle: z
.string()
.optional()
.default("none")
.describe("@deprecated Split/unified diff"),
messages_previous: z
.string()
.optional()
.default("none")
.describe("@deprecated Navigate to previous message"),
messages_next: z
.string()
.optional()
.default("none")
.describe("@deprecated Navigate to next message"),
messages_layout_toggle: z
.string()
.optional()
.default("none")
.describe("@deprecated Toggle layout"),
messages_revert: z
.string()
.optional()
.default("none")
.describe("@deprecated use messages_undo. Revert message"),
})
.strict()
.meta({
@@ -820,7 +743,10 @@ export namespace Config {
const errMsg = `bad file reference: "${match}"`
if (error.code === "ENOENT") {
throw new InvalidError(
{ path: configFilepath, message: errMsg + ` ${resolvedPath} does not exist` },
{
path: configFilepath,
message: errMsg + ` ${resolvedPath} does not exist`,
},
{ cause: error },
)
}
@@ -874,7 +800,10 @@ export namespace Config {
return data
}
throw new InvalidError({ path: configFilepath, issues: parsed.error.issues })
throw new InvalidError({
path: configFilepath,
issues: parsed.error.issues,
})
}
export const JsonError = NamedError.create(
"ConfigJsonError",

View File

@@ -284,7 +284,9 @@ export namespace File {
}
const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory
const nodes: Node[] = []
for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true })) {
for (const entry of await fs.promises.readdir(resolved, {
withFileTypes: true,
})) {
if (exclude.includes(entry.name)) continue
const fullPath = path.join(resolved, entry.name)
const relativePath = path.relative(Instance.directory, fullPath)

View File

@@ -1,6 +1,7 @@
import fs from "fs/promises"
import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir"
import path from "path"
import os from "os"
const app = "opencode"
@@ -11,6 +12,7 @@ const state = path.join(xdgState!, app)
export namespace Global {
export const Path = {
home: os.homedir(),
data,
bin: path.join(data, "bin"),
log: path.join(data, "log"),
@@ -38,7 +40,12 @@ if (version !== CACHE_VERSION) {
try {
const contents = await fs.readdir(Global.Path.cache)
await Promise.all(
contents.map((item) => fs.rm(path.join(Global.Path.cache, item), { recursive: true, force: true })),
contents.map((item) =>
fs.rm(path.join(Global.Path.cache, item), {
recursive: true,
force: true,
}),
),
)
} catch (e) {}
await Bun.file(path.join(Global.Path.cache, "version")).write(CACHE_VERSION)

View File

@@ -12,13 +12,14 @@ import { Installation } from "./installation"
import { NamedError } from "./util/error"
import { FormatError } from "./cli/error"
import { ServeCommand } from "./cli/cmd/serve"
import { TuiCommand } from "./cli/cmd/tui"
import { DebugCommand } from "./cli/cmd/debug"
import { StatsCommand } from "./cli/cmd/stats"
import { McpCommand } from "./cli/cmd/mcp"
import { GithubCommand } from "./cli/cmd/github"
import { ExportCommand } from "./cli/cmd/export"
import { AttachCommand } from "./cli/cmd/attach"
import { AttachCommand } from "./cli/cmd/tui/attach"
import { TuiThreadCommand } from "./cli/cmd/tui/thread"
import { TuiSpawnCommand } from "./cli/cmd/tui/spawn"
import { AcpCommand } from "./cli/cmd/acp"
import { EOL } from "os"
@@ -69,7 +70,8 @@ const cli = yargs(hideBin(process.argv))
.usage("\n" + UI.logo())
.command(AcpCommand)
.command(McpCommand)
.command(TuiCommand)
.command(TuiThreadCommand)
.command(TuiSpawnCommand)
.command(AttachCommand)
.command(RunCommand)
.command(GenerateCommand)

View File

@@ -139,7 +139,10 @@ export namespace LSPClient {
if (version !== undefined) {
const next = version + 1
files[input.path] = next
log.info("textDocument/didChange", { path: input.path, version: next })
log.info("textDocument/didChange", {
path: input.path,
version: next,
})
await connection.sendNotification("textDocument/didChange", {
textDocument: {
uri: `file://` + input.path,

View File

@@ -6,10 +6,15 @@ import z from "zod"
import { Config } from "../config/config"
import { spawn } from "child_process"
import { Instance } from "../project/instance"
import { Bus } from "../bus"
export namespace LSP {
const log = Log.create({ service: "lsp" })
export const Event = {
Updated: Bus.event("lsp.updated", z.object({})),
}
export const Range = z
.object({
start: z.object({
@@ -109,6 +114,33 @@ export namespace LSP {
return state()
}
export const Status = z
.object({
id: z.string(),
name: z.string(),
root: z.string(),
status: z.union([z.literal("connected"), z.literal("error")]),
})
.meta({
ref: "LSPStatus",
})
export type Status = z.infer<typeof Status>
export async function status() {
return state().then((x) => {
const result: Status[] = []
for (const client of x.clients) {
result.push({
id: client.serverID,
name: x.servers[client.serverID].id,
root: path.relative(Instance.directory, client.root),
status: "connected",
})
}
return result
})
}
async function getClients(file: string) {
const s = await state()
const extension = path.parse(file).ext || file
@@ -147,12 +179,15 @@ export namespace LSP {
}).catch((err) => {
s.broken.add(root + server.id)
handle.process.kill()
log.error(`Failed to initialize LSP client ${server.id}`, { error: err })
log.error(`Failed to initialize LSP client ${server.id}`, {
error: err,
})
return undefined
})
if (!client) continue
s.clients.push(client)
result.push(client)
Bus.publish(Event.Updated, {})
}
return result
}

View File

@@ -467,7 +467,7 @@ export namespace LSPServer {
return
}
const release = await releaseResponse.json()
const release = (await releaseResponse.json()) as any
const platform = process.platform
const arch = process.arch
@@ -660,7 +660,7 @@ export namespace LSPServer {
return
}
const release = await releaseResponse.json()
const release = (await releaseResponse.json()) as any
const platform = process.platform
let assetName = ""

View File

@@ -5,9 +5,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import { Config } from "../config/config"
import { Log } from "../util/log"
import { NamedError } from "../util/error"
import z from "zod"
import { Session } from "../session"
import { Bus } from "../bus"
import z from "zod/v4"
import { Instance } from "../project/instance"
import { withTimeout } from "@/util/timeout"
@@ -21,27 +19,61 @@ export namespace MCP {
}),
)
type Client = Awaited<ReturnType<typeof experimental_createMCPClient>>
export const Status = z
.discriminatedUnion("status", [
z
.object({
status: z.literal("connected"),
})
.meta({
ref: "MCPStatusConnected",
}),
z
.object({
status: z.literal("disabled"),
})
.meta({
ref: "MCPStatusDisabled",
}),
z
.object({
status: z.literal("failed"),
error: z.string(),
})
.meta({
ref: "MCPStatusFailed",
}),
])
.meta({
ref: "MCPStatus",
})
export type Status = z.infer<typeof Status>
type MCPClient = Awaited<ReturnType<typeof experimental_createMCPClient>>
const state = Instance.state(
async () => {
const cfg = await Config.get()
const config = cfg.mcp ?? {}
const clients: {
[name: string]: MCPClient
} = {}
const clients: Record<string, Client> = {}
const status: Record<string, Status> = {}
await Promise.all(
Object.entries(config).map(async ([key, mcp]) => {
const result = await create(key, mcp).catch(() => undefined)
if (!result) return
clients[key] = result.client
status[key] = result.status
if (result.mcpClient) {
clients[key] = result.mcpClient
}
}),
)
return {
status,
clients,
config,
}
},
async (state) => {
@@ -53,17 +85,22 @@ export namespace MCP {
const s = await state()
const result = await create(name, mcp)
if (!result) return
s.clients[name] = result.client
}
async function create(name: string, mcp: Config.Mcp) {
if (mcp.enabled === false) {
log.info("mcp server disabled", { name })
if (!result.mcpClient) {
s.status[name] = result.status
return
}
log.info("found", { name, type: mcp.type })
s.clients[name] = result.mcpClient
s.status[name] = result.status
}
async function create(key: string, mcp: Config.Mcp) {
if (mcp.enabled === false) {
log.info("mcp server disabled", { key })
return
}
log.info("found", { key, type: mcp.type })
let mcpClient: MCPClient | undefined
let status: Status | undefined
if (mcp.type === "remote") {
const transports = [
@@ -86,44 +123,37 @@ export namespace MCP {
]
let lastError: Error | undefined
for (const { name, transport } of transports) {
const client = await experimental_createMCPClient({
const result = await experimental_createMCPClient({
name: "opencode",
transport,
}).catch((error) => {
lastError = error instanceof Error ? error : new Error(String(error))
log.debug("transport connection failed", {
name,
transport: name,
url: mcp.url,
error: lastError.message,
})
.then((client) => {
log.info("connected", { key, transport: name })
mcpClient = client
status = { status: "connected" }
return true
})
return null
})
if (client) {
log.debug("transport connection succeeded", { name, transport: name })
mcpClient = client
break
}
}
if (!mcpClient) {
const errorMessage = lastError
? `MCP server ${name} failed to connect: ${lastError.message}`
: `MCP server ${name} failed to connect to ${mcp.url}`
log.error("remote mcp connection failed", { name, url: mcp.url, error: lastError?.message })
Bus.publish(Session.Event.Error, {
error: {
name: "UnknownError",
data: {
message: errorMessage,
},
},
})
.catch((error) => {
lastError = error instanceof Error ? error : new Error(String(error))
log.debug("transport connection failed", {
key,
transport: name,
url: mcp.url,
error: lastError.message,
})
status = {
status: "failed",
error: lastError.message,
}
return false
})
if (result) break
}
}
if (mcp.type === "local") {
const [cmd, ...args] = mcp.command
const client = await experimental_createMCPClient({
await experimental_createMCPClient({
name: "opencode",
transport: new StdioClientTransport({
stderr: "ignore",
@@ -135,63 +165,61 @@ export namespace MCP {
...mcp.environment,
},
}),
}).catch((error) => {
const errorMessage =
error instanceof Error
? `MCP server ${name} failed to start: ${error.message}`
: `MCP server ${name} failed to start`
log.error("local mcp startup failed", {
name,
command: mcp.command,
error: error instanceof Error ? error.message : String(error),
})
Bus.publish(Session.Event.Error, {
error: {
name: "UnknownError",
data: {
message: errorMessage,
},
},
})
return null
})
if (client) {
mcpClient = client
.then((client) => {
mcpClient = client
status = {
status: "connected",
}
})
.catch((error) => {
log.error("local mcp startup failed", {
key,
command: mcp.command,
error: error instanceof Error ? error.message : String(error),
})
status = {
status: "failed",
error: error instanceof Error ? error.message : String(error),
}
})
}
if (!status) {
status = {
status: "failed",
error: "Unknown error",
}
}
if (!mcpClient) {
log.warn("mcp client not initialized", { name })
return
return {
mcpClient: undefined,
status,
}
}
const result = await withTimeout(mcpClient.tools(), mcp.timeout ?? 5000).catch(() => { })
const result = await withTimeout(mcpClient.tools(), mcp.timeout ?? 5000).catch(() => {})
if (!result) {
log.warn("mcp client verification failed, dropping client", { name })
return
await mcpClient.close()
status = {
status: "failed",
error: "Failed to get tools",
}
return {
mcpClient: undefined,
status,
}
}
return {
client: mcpClient,
mcpClient,
status,
}
}
export async function status() {
return state().then((state) => {
const result: Record<string, "connected" | "failed" | "disabled"> = {}
for (const [key, client] of Object.entries(state.config)) {
if (client.enabled === false) {
result[key] = "disabled"
continue
}
if (state.clients[key]) {
result[key] = "connected"
continue
}
result[key] = "failed"
}
return result
})
return state().then((state) => state.status)
}
export async function clients() {

View File

@@ -41,7 +41,11 @@ export namespace Permission {
Updated: Bus.event("permission.updated", Info),
Replied: Bus.event(
"permission.replied",
z.object({ sessionID: z.string(), permissionID: z.string(), response: z.string() }),
z.object({
sessionID: z.string(),
permissionID: z.string(),
response: z.string(),
}),
),
}
@@ -141,16 +145,16 @@ export namespace Permission {
const match = pending[input.sessionID]?.[input.permissionID]
if (!match) return
delete pending[input.sessionID][input.permissionID]
if (input.response === "reject") {
match.reject(new RejectedError(input.sessionID, input.permissionID, match.info.callID, match.info.metadata))
return
}
match.resolve()
Bus.publish(Event.Replied, {
sessionID: input.sessionID,
permissionID: input.permissionID,
response: input.response,
})
if (input.response === "reject") {
match.reject(new RejectedError(input.sessionID, input.permissionID, match.info.callID, match.info.metadata))
return
}
match.resolve()
if (input.response === "always") {
approved[input.sessionID] = approved[input.sessionID] || {}
const approveKeys = toKeys(match.info.pattern, match.info.type)

View File

@@ -14,6 +14,7 @@ export namespace Plugin {
const state = Instance.state(async () => {
const client = createOpencodeClient({
baseUrl: "http://localhost:4096",
// @ts-ignore - fetch type incompatibility
fetch: async (...args) => Server.App().fetch(...args),
})
const config = await Config.get()

View File

@@ -1,3 +1,4 @@
import { Log } from "@/util/log"
import { Context } from "../util/context"
import { Project } from "./project"
import { State } from "./state"
@@ -42,6 +43,15 @@ export const Instance = {
return State.create(() => Instance.directory, init, dispose)
},
async dispose() {
Log.Default.info("disposing instance", { directory: Instance.directory })
await State.dispose(Instance.directory)
},
async disposeAll() {
for (const [_key, value] of cache) {
await context.provide(value, async () => {
await Instance.dispose()
})
}
cache.clear()
},
}

View File

@@ -9,7 +9,11 @@ export namespace State {
const log = Log.create({ service: "state" })
const recordsByKey = new Map<string, Map<any, Entry>>()
export function create<S>(root: () => string, init: () => S, dispose?: (state: Awaited<S>) => Promise<void>) {
export function create<S>(
root: () => string,
init: () => S,
dispose?: (state: Awaited<S>) => Promise<void>,
) {
return () => {
const key = root()
let entries = recordsByKey.get(key)
@@ -57,9 +61,8 @@ export namespace State {
tasks.push(task)
}
entries.delete(key)
await Promise.all(tasks)
disposalFinished = true
log.info("state disposal completed", { key })
}

View File

@@ -1,6 +1,12 @@
import { Log } from "../util/log"
import { Bus } from "../bus"
import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
import {
describeRoute,
generateSpecs,
validator,
resolver,
openAPIRouteHandler,
} from "hono-openapi"
import { Hono } from "hono"
import { cors } from "hono/cors"
import { stream, streamSSE } from "hono/streaming"
@@ -15,7 +21,7 @@ import { Config } from "../config/config"
import { File } from "../file"
import { LSP } from "../lsp"
import { MessageV2 } from "../session/message-v2"
import { callTui, TuiRoute } from "./tui"
import { TuiRoute } from "./tui"
import { Permission } from "../permission"
import { Instance } from "../project/instance"
import { Agent } from "../agent/agent"
@@ -35,6 +41,7 @@ import { InstanceBootstrap } from "../project/bootstrap"
import { MCP } from "../mcp"
import { Storage } from "../storage/storage"
import type { ContentfulStatusCode } from "hono/utils/http-status"
import { TuiEvent } from "@/cli/cmd/tui/event"
import { Snapshot } from "@/snapshot"
import { SessionSummary } from "@/session/summary"
@@ -248,7 +255,9 @@ export namespace Server {
id: t.id,
description: t.description,
// Handle both Zod schemas and plain JSON schemas
parameters: (t.parameters as any)?._def ? zodToJsonSchema(t.parameters as any) : t.parameters,
parameters: (t.parameters as any)?._def
? zodToJsonSchema(t.parameters as any)
: t.parameters,
})),
)
},
@@ -446,7 +455,11 @@ export namespace Server {
}),
),
async (c) => {
await Session.remove(c.req.valid("param").id)
const sessionID = c.req.valid("param").id
await Session.remove(sessionID)
await Bus.publish(TuiEvent.CommandExecute, {
command: "session.list",
})
return c.json(true)
},
)
@@ -1033,7 +1046,10 @@ export namespace Server {
const providers = await Provider.list().then((x) => mapValues(x, (item) => item.info))
return c.json({
providers: Object.values(providers),
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
default: mapValues(
providers,
(item) => Provider.sort(Object.values(item.models))[0].id,
),
})
},
)
@@ -1290,7 +1306,7 @@ export namespace Server {
description: "MCP server status",
content: {
"application/json": {
schema: resolver(z.any()),
schema: resolver(z.record(z.string(), MCP.Status)),
},
},
},
@@ -1300,6 +1316,26 @@ export namespace Server {
return c.json(await MCP.status())
},
)
.get(
"/lsp",
describeRoute({
description: "Get LSP server status",
operationId: "lsp.status",
responses: {
200: {
description: "LSP server status",
content: {
"application/json": {
schema: resolver(LSP.Status.array()),
},
},
},
},
}),
async (c) => {
return c.json(await LSP.status())
},
)
.post(
"/tui/append-prompt",
describeRoute({
@@ -1317,13 +1353,11 @@ export namespace Server {
...errors(400),
},
}),
validator(
"json",
z.object({
text: z.string(),
}),
),
async (c) => c.json(await callTui(c)),
validator("json", TuiEvent.PromptAppend.properties),
async (c) => {
await Bus.publish(TuiEvent.PromptAppend, c.req.valid("json"))
return c.json(true)
},
)
.post(
"/tui/open-help",
@@ -1341,7 +1375,10 @@ export namespace Server {
},
},
}),
async (c) => c.json(await callTui(c)),
async (c) => {
// TODO: open dialog
return c.json(true)
},
)
.post(
"/tui/open-sessions",
@@ -1359,7 +1396,12 @@ export namespace Server {
},
},
}),
async (c) => c.json(await callTui(c)),
async (c) => {
await Bus.publish(TuiEvent.CommandExecute, {
command: "session.list",
})
return c.json(true)
},
)
.post(
"/tui/open-themes",
@@ -1377,7 +1419,12 @@ export namespace Server {
},
},
}),
async (c) => c.json(await callTui(c)),
async (c) => {
await Bus.publish(TuiEvent.CommandExecute, {
command: "session.list",
})
return c.json(true)
},
)
.post(
"/tui/open-models",
@@ -1395,7 +1442,12 @@ export namespace Server {
},
},
}),
async (c) => c.json(await callTui(c)),
async (c) => {
await Bus.publish(TuiEvent.CommandExecute, {
command: "model.list",
})
return c.json(true)
},
)
.post(
"/tui/submit-prompt",
@@ -1413,7 +1465,12 @@ export namespace Server {
},
},
}),
async (c) => c.json(await callTui(c)),
async (c) => {
await Bus.publish(TuiEvent.CommandExecute, {
command: "prompt.submit",
})
return c.json(true)
},
)
.post(
"/tui/clear-prompt",
@@ -1431,7 +1488,12 @@ export namespace Server {
},
},
}),
async (c) => c.json(await callTui(c)),
async (c) => {
await Bus.publish(TuiEvent.CommandExecute, {
command: "prompt.clear",
})
return c.json(true)
},
)
.post(
"/tui/execute-command",
@@ -1450,13 +1512,27 @@ export namespace Server {
...errors(400),
},
}),
validator(
"json",
z.object({
command: z.string(),
}),
),
async (c) => c.json(await callTui(c)),
validator("json", z.object({ command: z.string() })),
async (c) => {
const command = c.req.valid("json").command
await Bus.publish(TuiEvent.CommandExecute, {
// @ts-expect-error
command: {
session_new: "session.new",
session_share: "session.share",
session_interrupt: "session.interrupt",
session_compact: "session.compact",
messages_page_up: "session.page.up",
messages_page_down: "session.page.down",
messages_half_page_up: "session.half.page.up",
messages_half_page_down: "session.half.page.down",
messages_first: "session.first",
messages_last: "session.last",
agent_cycle: "agent.cycle",
}[command],
})
return c.json(true)
},
)
.post(
"/tui/show-toast",
@@ -1474,15 +1550,52 @@ export namespace Server {
},
},
}),
validator("json", TuiEvent.ToastShow.properties),
async (c) => {
await Bus.publish(TuiEvent.ToastShow, c.req.valid("json"))
return c.json(true)
},
)
.post(
"/tui/publish",
describeRoute({
description: "Publish a TUI event",
operationId: "tui.publish",
responses: {
200: {
description: "Event published successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400),
},
}),
validator(
"json",
z.object({
title: z.string().optional(),
message: z.string(),
variant: z.enum(["info", "success", "warning", "error"]),
}),
z.union(
Object.values(TuiEvent).map((def) => {
return z
.object({
type: z.literal(def.type),
properties: def.properties,
})
.meta({
ref: "Event" + "." + def.type,
})
}),
),
),
async (c) => c.json(await callTui(c)),
async (c) => {
const evt = c.req.valid("json")
await Bus.publish(
Object.values(TuiEvent).find((def) => def.type === evt.type)!,
evt.properties,
)
return c.json(true)
},
)
.route("/tui/control", TuiRoute)
.put(

View File

@@ -119,6 +119,7 @@ export namespace SessionCompaction {
cwd: Instance.directory,
root: Instance.worktree,
},
summary: true,
cost: 0,
tokens: {
output: 0,

View File

@@ -182,6 +182,8 @@ export namespace MessageV2 {
export const ToolStatePending = z
.object({
status: z.literal("pending"),
input: z.record(z.string(), z.any()),
raw: z.string(),
})
.meta({
ref: "ToolStatePending",
@@ -192,7 +194,7 @@ export namespace MessageV2 {
export const ToolStateRunning = z
.object({
status: z.literal("running"),
input: z.any(),
input: z.record(z.string(), z.any()),
title: z.string().optional(),
metadata: z.record(z.string(), z.any()).optional(),
time: z.object({
@@ -433,6 +435,8 @@ export namespace MessageV2 {
if (part.toolInvocation.state === "partial-call") {
return {
status: "pending",
input: {},
raw: "",
}
}

View File

@@ -1054,6 +1054,8 @@ export namespace SessionPrompt {
callID: value.id,
state: {
status: "pending",
input: {},
raw: "",
},
})
toolcalls[value.id] = part as MessageV2.ToolPart
@@ -1302,16 +1304,16 @@ export namespace SessionPrompt {
part.state.status !== "completed" &&
part.state.status !== "error"
) {
Session.updatePart({
await Session.updatePart({
...part,
state: {
...part.state,
status: "error",
error: "Tool execution aborted",
time: {
start: Date.now(),
end: Date.now(),
},
input: {},
},
})
}
@@ -1815,6 +1817,12 @@ export namespace SessionPrompt {
content: x,
}),
),
{
role: "user" as const,
content: `
The following is the text to summarize:
`,
},
...MessageV2.toModelMessage([
{
info: {

View File

@@ -81,10 +81,15 @@ export namespace SessionSummary {
),
{
role: "user" as const,
content: textPart?.text ?? "",
content: `
The following is the text to summarize:
<text>
${textPart?.text ?? ""}
</text>
`,
},
],
headers:small.info.headers,
headers: small.info.headers,
model: small.language,
})
log.info("title", { title: result.text })
@@ -117,9 +122,9 @@ export namespace SessionSummary {
`,
},
],
headers: small.info.headers
})
summary = result.text
headers: small.info.headers,
}).catch(() => {})
if (result) summary = result.text
}
userMsg.summary.body = summary
log.info("body", { body: summary })

View File

@@ -108,7 +108,8 @@ export namespace SystemPrompt {
const found = Array.from(paths).map((p) =>
Bun.file(p)
.text()
.catch(() => ""),
.catch(() => "")
.then((x) => "Instructions from: " + p + "\n" + x),
)
return Promise.all(found).then((result) => result.filter(Boolean))
}

View File

@@ -2,47 +2,40 @@ import z from "zod"
import { spawn } from "child_process"
import { Tool } from "./tool"
import DESCRIPTION from "./bash.txt"
import { Permission } from "../permission"
import { Filesystem } from "../util/filesystem"
import { lazy } from "../util/lazy"
import { Log } from "../util/log"
import { Wildcard } from "../util/wildcard"
import { $ } from "bun"
import { Instance } from "../project/instance"
import { Agent } from "../agent/agent"
import { lazy } from "@/util/lazy"
import { Language } from "web-tree-sitter"
import { Agent } from "@/agent/agent"
import { $ } from "bun"
import { Filesystem } from "@/util/filesystem"
import { Wildcard } from "@/util/wildcard"
import { Permission } from "@/permission"
const MAX_OUTPUT_LENGTH = 30_000
const DEFAULT_TIMEOUT = 1 * 60 * 1000
const MAX_TIMEOUT = 10 * 60 * 1000
const SIGKILL_TIMEOUT_MS = 200
const log = Log.create({ service: "bash-tool" })
export const log = Log.create({ service: "bash-tool" })
const parser = lazy(async () => {
try {
const { default: Parser } = await import("tree-sitter")
const Bash = await import("tree-sitter-bash")
const p = new Parser()
p.setLanguage(Bash.language as any)
return p
} catch (e) {
const { default: Parser } = await import("web-tree-sitter")
const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, {
with: { type: "wasm" },
})
await Parser.init({
locateFile() {
return treeWasm
},
})
const { default: bashWasm } = await import("tree-sitter-bash/tree-sitter-bash.wasm" as string, {
with: { type: "wasm" },
})
const bashLanguage = await Parser.Language.load(bashWasm)
const p = new Parser()
p.setLanguage(bashLanguage)
return p
}
const { Parser } = await import("web-tree-sitter")
const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, {
with: { type: "wasm" },
})
await Parser.init({
locateFile() {
return treeWasm
},
})
const { default: bashWasm } = await import("tree-sitter-bash/tree-sitter-bash.wasm" as string, {
with: { type: "wasm" },
})
const bashLanguage = await Language.load(bashWasm)
const p = new Parser()
p.setLanguage(bashLanguage)
return p
})
export const BashTool = Tool.define("bash", {
@@ -64,10 +57,14 @@ export const BashTool = Tool.define("bash", {
}
const timeout = Math.min(params.timeout ?? DEFAULT_TIMEOUT, MAX_TIMEOUT)
const tree = await parser().then((p) => p.parse(params.command))
if (!tree) {
throw new Error("Failed to parse command")
}
const permissions = await Agent.get(ctx.agent).then((x) => x.permission.bash)
const askPatterns = new Set<string>()
for (const node of tree.rootNode.descendantsOfType("command")) {
if (!node) continue
const command = []
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i)

View File

@@ -14,8 +14,8 @@ import { Agent } from "../agent/agent"
export const WriteTool = Tool.define("write", {
description: DESCRIPTION,
parameters: z.object({
filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"),
content: z.string().describe("The content to write to the file"),
filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"),
}),
async execute(params, ctx) {
const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)

View File

@@ -0,0 +1,41 @@
export namespace Binary {
export function search<T>(array: T[], id: string, compare: (item: T) => string): { found: boolean; index: number } {
let left = 0
let right = array.length - 1
while (left <= right) {
const mid = Math.floor((left + right) / 2)
const midId = compare(array[mid])
if (midId === id) {
return { found: true, index: mid }
} else if (midId < id) {
left = mid + 1
} else {
right = mid - 1
}
}
return { found: false, index: left }
}
export function insert<T>(array: T[], item: T, compare: (item: T) => string): T[] {
const id = compare(item)
let left = 0
let right = array.length
while (left < right) {
const mid = Math.floor((left + right) / 2)
const midId = compare(array[mid])
if (midId < id) {
left = mid + 1
} else {
right = mid
}
}
array.splice(left, 0, item)
return array
}
}

View File

@@ -0,0 +1,20 @@
import { Log } from "./log"
export namespace EventLoop {
export async function wait() {
return new Promise<void>((resolve) => {
const check = () => {
const active = [...(process as any)._getActiveHandles(), ...(process as any)._getActiveRequests()]
Log.Default.info("eventloop", {
active,
})
if ((process as any)._getActiveHandles().length === 0 && (process as any)._getActiveRequests().length === 0) {
resolve()
} else {
setImmediate(check)
}
}
check()
})
}
}

View File

@@ -0,0 +1,3 @@
export function iife<T>(fn: () => T) {
return fn()
}

View File

@@ -0,0 +1,76 @@
import { isDeepEqual } from "remeda"
export namespace Keybind {
export type Info = {
ctrl: boolean
meta: boolean
shift: boolean
leader: boolean
name: string
}
export function match(a: Info, b: Info): boolean {
return isDeepEqual(a, b)
}
export function toString(info: Info): string {
const parts: string[] = []
if (info.ctrl) parts.push("ctrl")
if (info.meta) parts.push("alt")
if (info.shift) parts.push("shift")
if (info.name) {
if (info.name === "delete") parts.push("del")
else parts.push(info.name)
}
let result = parts.join("+")
if (info.leader) {
result = result ? `<leader> ${result}` : `<leader>`
}
return result
}
export function parse(key: string): Info[] {
if (key === "none") return []
return key.split(",").map((combo) => {
// Handle <leader> syntax by replacing with leader+
const normalized = combo.replace(/<leader>/g, "leader+")
const parts = normalized.toLowerCase().split("+")
const info: Info = {
ctrl: false,
meta: false,
shift: false,
leader: false,
name: "",
}
for (const part of parts) {
switch (part) {
case "ctrl":
info.ctrl = true
break
case "alt":
case "meta":
case "option":
info.meta = true
break
case "shift":
info.shift = true
break
case "leader":
info.leader = true
break
default:
info.name = part
break
}
}
return info
})
}
}

View File

@@ -0,0 +1,39 @@
export namespace Locale {
export function titlecase(str: string) {
return str.replace(/\b\w/g, (c) => c.toUpperCase())
}
export function time(input: number) {
const date = new Date(input)
return date.toLocaleTimeString()
}
export function number(num: number): string {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + "M"
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + "K"
}
return num.toString()
}
export function truncate(str: string, len: number): string {
if (str.length <= len) return str
return str.slice(0, len - 1) + "…"
}
export function truncateMiddle(str: string, maxLength: number = 35): string {
if (str.length <= maxLength) return str
const ellipsis = "…"
const keepStart = Math.ceil((maxLength - ellipsis.length) / 2)
const keepEnd = Math.floor((maxLength - ellipsis.length) / 2)
return str.slice(0, keepStart) + ellipsis + str.slice(-keepEnd)
}
export function pluralize(count: number, singular: string, plural: string): string {
const template = count === 1 ? singular : plural
return template.replace("{}", count.toString())
}
}

View File

@@ -0,0 +1,42 @@
export namespace Rpc {
type Definition = {
[method: string]: (input: any) => any
}
export function listen(rpc: Definition) {
onmessage = async (evt) => {
const parsed = JSON.parse(evt.data)
if (parsed.type === "rpc.request") {
const result = await rpc[parsed.method](parsed.input)
postMessage(JSON.stringify({ type: "rpc.result", result, id: parsed.id }))
}
}
}
export function client<T extends Definition>(target: {
postMessage: (data: string) => void | null
onmessage: ((this: Worker, ev: MessageEvent<any>) => any) | null
}) {
const pending = new Map<number, (result: any) => void>()
let id = 0
target.onmessage = async (evt) => {
const parsed = JSON.parse(evt.data)
if (parsed.type === "rpc.result") {
const resolve = pending.get(parsed.id)
if (resolve) {
resolve(parsed.result)
pending.delete(parsed.id)
}
}
}
return {
call<Method extends keyof T>(method: Method, input: Parameters<T[Method]>[0]): Promise<ReturnType<T[Method]>> {
const requestId = id++
return new Promise((resolve) => {
pending.set(requestId, resolve)
target.postMessage(JSON.stringify({ type: "rpc.request", method, input, id: requestId }))
})
},
}
}
}

View File

@@ -0,0 +1,12 @@
export function signal() {
let resolve: any
const promise = new Promise((r) => (resolve = r))
return {
trigger() {
return resolve()
},
wait() {
return promise
},
}
}

View File

@@ -11,7 +11,10 @@ type TmpDirOptions<T> = {
export async function tmpdir<T>(options?: TmpDirOptions<T>) {
const dirpath = path.join(os.tmpdir(), "opencode-test-" + Math.random().toString(36).slice(2))
await $`mkdir -p ${dirpath}`.quiet()
if (options?.git) await $`git init`.cwd(dirpath).quiet()
if (options?.git) {
await $`git init`.cwd(dirpath).quiet()
await $`git commit --allow-empty -m "root commit ${dirpath}"`.cwd(dirpath).quiet()
}
const extra = await options?.init?.(dirpath)
const result = {
[Symbol.asyncDispose]: async () => {

View File

@@ -0,0 +1,305 @@
import { describe, test, expect } from "bun:test"
import { Keybind } from "../src/util/keybind"
describe("Keybind.toString", () => {
test("should convert simple key to string", () => {
const info: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "f" }
expect(Keybind.toString(info)).toBe("f")
})
test("should convert ctrl modifier to string", () => {
const info: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" }
expect(Keybind.toString(info)).toBe("ctrl+x")
})
test("should convert leader key to string", () => {
const info: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: true, name: "f" }
expect(Keybind.toString(info)).toBe("<leader> f")
})
test("should convert multiple modifiers to string", () => {
const info: Keybind.Info = { ctrl: true, meta: true, shift: false, leader: false, name: "g" }
expect(Keybind.toString(info)).toBe("ctrl+alt+g")
})
test("should convert all modifiers to string", () => {
const info: Keybind.Info = { ctrl: true, meta: true, shift: true, leader: true, name: "h" }
expect(Keybind.toString(info)).toBe("<leader> ctrl+alt+shift+h")
})
test("should convert shift modifier to string", () => {
const info: Keybind.Info = { ctrl: false, meta: false, shift: true, leader: false, name: "enter" }
expect(Keybind.toString(info)).toBe("shift+enter")
})
test("should convert function key to string", () => {
const info: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "f2" }
expect(Keybind.toString(info)).toBe("f2")
})
test("should convert special key to string", () => {
const info: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "pgup" }
expect(Keybind.toString(info)).toBe("pgup")
})
test("should handle empty name", () => {
const info: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "" }
expect(Keybind.toString(info)).toBe("ctrl")
})
test("should handle only modifiers", () => {
const info: Keybind.Info = { ctrl: true, meta: true, shift: true, leader: true, name: "" }
expect(Keybind.toString(info)).toBe("<leader> ctrl+alt+shift")
})
test("should handle only leader with no other parts", () => {
const info: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: true, name: "" }
expect(Keybind.toString(info)).toBe("<leader>")
})
})
describe("Keybind.match", () => {
test("should match identical keybinds", () => {
const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" }
const b: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" }
expect(Keybind.match(a, b)).toBe(true)
})
test("should not match different key names", () => {
const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" }
const b: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "y" }
expect(Keybind.match(a, b)).toBe(false)
})
test("should not match different modifiers", () => {
const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" }
const b: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "x" }
expect(Keybind.match(a, b)).toBe(false)
})
test("should match leader keybinds", () => {
const a: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: true, name: "f" }
const b: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: true, name: "f" }
expect(Keybind.match(a, b)).toBe(true)
})
test("should not match leader vs non-leader", () => {
const a: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: true, name: "f" }
const b: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "f" }
expect(Keybind.match(a, b)).toBe(false)
})
test("should match complex keybinds", () => {
const a: Keybind.Info = { ctrl: true, meta: true, shift: false, leader: false, name: "g" }
const b: Keybind.Info = { ctrl: true, meta: true, shift: false, leader: false, name: "g" }
expect(Keybind.match(a, b)).toBe(true)
})
test("should not match with one modifier different", () => {
const a: Keybind.Info = { ctrl: true, meta: true, shift: false, leader: false, name: "g" }
const b: Keybind.Info = { ctrl: true, meta: true, shift: true, leader: false, name: "g" }
expect(Keybind.match(a, b)).toBe(false)
})
test("should match simple key without modifiers", () => {
const a: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "a" }
const b: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "a" }
expect(Keybind.match(a, b)).toBe(true)
})
})
describe("Keybind.parse", () => {
test("should parse simple key", () => {
const result = Keybind.parse("f")
expect(result).toEqual([
{
ctrl: false,
meta: false,
shift: false,
leader: false,
name: "f",
},
])
})
test("should parse leader key syntax", () => {
const result = Keybind.parse("<leader>f")
expect(result).toEqual([
{
ctrl: false,
meta: false,
shift: false,
leader: true,
name: "f",
},
])
})
test("should parse ctrl modifier", () => {
const result = Keybind.parse("ctrl+x")
expect(result).toEqual([
{
ctrl: true,
meta: false,
shift: false,
leader: false,
name: "x",
},
])
})
test("should parse multiple modifiers", () => {
const result = Keybind.parse("ctrl+alt+u")
expect(result).toEqual([
{
ctrl: true,
meta: true,
shift: false,
leader: false,
name: "u",
},
])
})
test("should parse shift modifier", () => {
const result = Keybind.parse("shift+f2")
expect(result).toEqual([
{
ctrl: false,
meta: false,
shift: true,
leader: false,
name: "f2",
},
])
})
test("should parse meta/alt modifier", () => {
const result = Keybind.parse("meta+g")
expect(result).toEqual([
{
ctrl: false,
meta: true,
shift: false,
leader: false,
name: "g",
},
])
})
test("should parse leader with modifier", () => {
const result = Keybind.parse("<leader>h")
expect(result).toEqual([
{
ctrl: false,
meta: false,
shift: false,
leader: true,
name: "h",
},
])
})
test("should parse multiple keybinds separated by comma", () => {
const result = Keybind.parse("ctrl+c,<leader>q")
expect(result).toEqual([
{
ctrl: true,
meta: false,
shift: false,
leader: false,
name: "c",
},
{
ctrl: false,
meta: false,
shift: false,
leader: true,
name: "q",
},
])
})
test("should parse shift+enter combination", () => {
const result = Keybind.parse("shift+enter")
expect(result).toEqual([
{
ctrl: false,
meta: false,
shift: true,
leader: false,
name: "enter",
},
])
})
test("should parse ctrl+j combination", () => {
const result = Keybind.parse("ctrl+j")
expect(result).toEqual([
{
ctrl: true,
meta: false,
shift: false,
leader: false,
name: "j",
},
])
})
test("should handle 'none' value", () => {
const result = Keybind.parse("none")
expect(result).toEqual([])
})
test("should handle special keys", () => {
const result = Keybind.parse("pgup")
expect(result).toEqual([
{
ctrl: false,
meta: false,
shift: false,
leader: false,
name: "pgup",
},
])
})
test("should handle function keys", () => {
const result = Keybind.parse("f2")
expect(result).toEqual([
{
ctrl: false,
meta: false,
shift: false,
leader: false,
name: "f2",
},
])
})
test("should handle complex multi-modifier combination", () => {
const result = Keybind.parse("ctrl+alt+g")
expect(result).toEqual([
{
ctrl: true,
meta: true,
shift: false,
leader: false,
name: "g",
},
])
})
test("should be case insensitive", () => {
const result = Keybind.parse("CTRL+X")
expect(result).toEqual([
{
ctrl: true,
meta: false,
shift: false,
leader: false,
name: "x",
},
])
})
})

View File

@@ -21,7 +21,9 @@ describe("tool.patch", () => {
await Instance.provide({
directory: "/tmp",
fn: async () => {
await expect(patchTool.execute({ patchText: "" }, ctx)).rejects.toThrow("patchText is required")
await expect(patchTool.execute({ patchText: "" }, ctx)).rejects.toThrow(
"patchText is required",
)
},
})
})
@@ -30,7 +32,9 @@ describe("tool.patch", () => {
await Instance.provide({
directory: "/tmp",
fn: async () => {
await expect(patchTool.execute({ patchText: "invalid patch" }, ctx)).rejects.toThrow("Failed to parse patch")
await expect(patchTool.execute({ patchText: "invalid patch" }, ctx)).rejects.toThrow(
"Failed to parse patch",
)
},
})
})
@@ -113,7 +117,9 @@ describe("tool.patch", () => {
// Verify file was created with correct content
const filePath = path.join(fixture.path, "config.js")
const content = await fs.readFile(filePath, "utf-8")
expect(content).toBe('const API_KEY = "test-key"\nconst DEBUG = false\nconst VERSION = "1.0"')
expect(content).toBe(
'const API_KEY = "test-key"\nconst DEBUG = false\nconst VERSION = "1.0"',
)
},
})
})

View File

@@ -24,4 +24,4 @@
"typescript": "catalog:",
"@typescript/native-preview": "catalog:"
}
}
}

View File

@@ -3,10 +3,9 @@ import { tool } from "./tool"
export const ExamplePlugin: Plugin = async (ctx) => {
return {
permission: {},
tool: {
mytool: tool({
description: "This is a custom tool tool",
description: "This is a custom tool",
args: {
foo: tool.schema.string().describe("foo"),
},
@@ -15,8 +14,5 @@ export const ExamplePlugin: Plugin = async (ctx) => {
},
}),
},
async "chat.params"(_input, output) {
output.topP = 1
},
}
}

View File

@@ -26,4 +26,4 @@
"publishConfig": {
"directory": "dist"
}
}
}

View File

@@ -10,6 +10,8 @@ import { createClient } from "@hey-api/openapi-ts"
await $`bun dev generate > ${dir}/openapi.json`.cwd(path.resolve(dir, "../../opencode"))
await $`rm -rf src/gen`
await createClient({
input: "./openapi.json",
output: {

View File

@@ -105,6 +105,8 @@ import type {
AppAgentsResponses,
McpStatusData,
McpStatusResponses,
LspStatusData,
LspStatusResponses,
TuiAppendPromptData,
TuiAppendPromptResponses,
TuiAppendPromptErrors,
@@ -125,6 +127,9 @@ import type {
TuiExecuteCommandErrors,
TuiShowToastData,
TuiShowToastResponses,
TuiPublishData,
TuiPublishResponses,
TuiPublishErrors,
TuiControlNextData,
TuiControlNextResponses,
TuiControlResponseData,
@@ -754,6 +759,20 @@ class Mcp extends _HeyApiClient {
}
}
class Lsp extends _HeyApiClient {
/**
* Get LSP server status
*/
public status<ThrowOnError extends boolean = false>(
options?: Options<LspStatusData, ThrowOnError>,
) {
return (options?.client ?? this._client).get<LspStatusResponses, unknown, ThrowOnError>({
url: "/lsp",
...options,
})
}
}
class Control extends _HeyApiClient {
/**
* Get the next TUI request from the queue
@@ -916,6 +935,26 @@ class Tui extends _HeyApiClient {
},
})
}
/**
* Publish a TUI event
*/
public publish<ThrowOnError extends boolean = false>(
options?: Options<TuiPublishData, ThrowOnError>,
) {
return (options?.client ?? this._client).post<
TuiPublishResponses,
TuiPublishErrors,
ThrowOnError
>({
url: "/tui/publish",
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
})
}
control = new Control({ client: this._client })
}
@@ -983,6 +1022,7 @@ export class OpencodeClient extends _HeyApiClient {
file = new File({ client: this._client })
app = new App({ client: this._client })
mcp = new Mcp({ client: this._client })
lsp = new Lsp({ client: this._client })
tui = new Tui({ client: this._client })
auth = new Auth({ client: this._client })
event = new Event({ client: this._client })

View File

@@ -18,10 +18,6 @@ export type KeybindsConfig = {
* Leader key for keybind combinations
*/
leader?: string
/**
* Show help dialog
*/
app_help?: string
/**
* Exit the application
*/
@@ -35,17 +31,13 @@ export type KeybindsConfig = {
*/
theme_list?: string
/**
* Create/update AGENTS.md
* Toggle sidebar
*/
project_init?: string
sidebar_toggle?: string
/**
* Toggle tool details
* View status
*/
tool_details?: string
/**
* Toggle thinking blocks
*/
thinking_blocks?: string
status_view?: string
/**
* Export session to editor
*/
@@ -78,14 +70,6 @@ export type KeybindsConfig = {
* Compact the session
*/
session_compact?: string
/**
* Cycle to next child session
*/
session_child_cycle?: string
/**
* Cycle to previous child session
*/
session_child_cycle_reverse?: string
/**
* Scroll messages up by one page
*/
@@ -127,13 +111,9 @@ export type KeybindsConfig = {
*/
model_list?: string
/**
* Next recent model
* List available commands
*/
model_cycle_recent?: string
/**
* Previous recent model
*/
model_cycle_recent_reverse?: string
command_list?: string
/**
* List agents
*/
@@ -162,54 +142,6 @@ export type KeybindsConfig = {
* Insert newline in input
*/
input_newline?: string
/**
* @deprecated use agent_cycle. Next mode
*/
switch_mode?: string
/**
* @deprecated use agent_cycle_reverse. Previous mode
*/
switch_mode_reverse?: string
/**
* @deprecated use agent_cycle. Next agent
*/
switch_agent?: string
/**
* @deprecated use agent_cycle_reverse. Previous agent
*/
switch_agent_reverse?: string
/**
* @deprecated Currently not available. List files
*/
file_list?: string
/**
* @deprecated Close file
*/
file_close?: string
/**
* @deprecated Search file
*/
file_search?: string
/**
* @deprecated Split/unified diff
*/
file_diff_toggle?: string
/**
* @deprecated Navigate to previous message
*/
messages_previous?: string
/**
* @deprecated Navigate to next message
*/
messages_next?: string
/**
* @deprecated Toggle layout
*/
messages_layout_toggle?: string
/**
* @deprecated use messages_undo. Revert message
*/
messages_revert?: string
}
export type AgentConfig = {
@@ -781,11 +713,17 @@ export type FilePart = {
export type ToolStatePending = {
status: "pending"
input: {
[key: string]: unknown
}
raw: string
}
export type ToolStateRunning = {
status: "running"
input: unknown
input: {
[key: string]: unknown
}
title?: string
metadata?: {
[key: string]: unknown
@@ -1086,6 +1024,72 @@ export type Agent = {
}
}
export type McpStatusConnected = {
status: "connected"
}
export type McpStatusDisabled = {
status: "disabled"
}
export type McpStatusFailed = {
status: "failed"
error: string
}
export type McpStatus = McpStatusConnected | McpStatusDisabled | McpStatusFailed
export type LspStatus = {
id: string
name: string
root: string
status: "connected" | "error"
}
export type EventTuiPromptAppend = {
type: "tui.prompt.append"
properties: {
text: string
}
}
export type EventTuiCommandExecute = {
type: "tui.command.execute"
properties: {
command:
| (
| "session.list"
| "session.new"
| "session.share"
| "session.interrupt"
| "session.compact"
| "session.page.up"
| "session.page.down"
| "session.half.page.up"
| "session.half.page.down"
| "session.first"
| "session.last"
| "prompt.clear"
| "prompt.submit"
| "agent.cycle"
)
| string
}
}
export type EventTuiToastShow = {
type: "tui.toast.show"
properties: {
title?: string
message: string
variant: "info" | "success" | "warning" | "error"
/**
* Duration in milliseconds
*/
duration?: number
}
}
export type OAuth = {
type: "oauth"
refresh: string
@@ -1121,6 +1125,13 @@ export type EventLspClientDiagnostics = {
}
}
export type EventLspUpdated = {
type: "lsp.updated"
properties: {
[key: string]: unknown
}
}
export type EventMessageUpdated = {
type: "message.updated"
properties: {
@@ -1261,16 +1272,10 @@ export type EventServerConnected = {
}
}
export type EventIdeInstalled = {
type: "ide.installed"
properties: {
ide: string
}
}
export type Event =
| EventInstallationUpdated
| EventLspClientDiagnostics
| EventLspUpdated
| EventMessageUpdated
| EventMessageRemoved
| EventMessagePartUpdated
@@ -1286,8 +1291,10 @@ export type Event =
| EventSessionUpdated
| EventSessionDeleted
| EventSessionError
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow
| EventServerConnected
| EventIdeInstalled
export type ProjectListData = {
body?: never
@@ -2455,9 +2462,31 @@ export type McpStatusResponses = {
/**
* MCP server status
*/
200: unknown
200: {
[key: string]: McpStatus
}
}
export type McpStatusResponse = McpStatusResponses[keyof McpStatusResponses]
export type LspStatusData = {
body?: never
path?: never
query?: {
directory?: string
}
url: "/lsp"
}
export type LspStatusResponses = {
/**
* LSP server status
*/
200: Array<LspStatus>
}
export type LspStatusResponse = LspStatusResponses[keyof LspStatusResponses]
export type TuiAppendPromptData = {
body?: {
text: string
@@ -2629,6 +2658,10 @@ export type TuiShowToastData = {
title?: string
message: string
variant: "info" | "success" | "warning" | "error"
/**
* Duration in milliseconds
*/
duration?: number
}
path?: never
query?: {
@@ -2646,6 +2679,33 @@ export type TuiShowToastResponses = {
export type TuiShowToastResponse = TuiShowToastResponses[keyof TuiShowToastResponses]
export type TuiPublishData = {
body?: EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow
path?: never
query?: {
directory?: string
}
url: "/tui/publish"
}
export type TuiPublishErrors = {
/**
* Bad request
*/
400: BadRequestError
}
export type TuiPublishError = TuiPublishErrors[keyof TuiPublishErrors]
export type TuiPublishResponses = {
/**
* Event published successfully
*/
200: boolean
}
export type TuiPublishResponse = TuiPublishResponses[keyof TuiPublishResponses]
export type TuiControlNextData = {
body?: never
path?: never

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