diff options
| author | Dax <[email protected]> | 2025-10-31 15:07:36 -0400 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-10-31 15:07:36 -0400 |
| commit | 96bdeb3c7b04e95ecabaa0253deddd2a22e14afe (patch) | |
| tree | c37898ee62e8c4f84f5d9abd4c644b1ec7066963 /packages | |
| parent | 81c617770d8595978b497a9cf3c0a5316b108352 (diff) | |
| download | opencode-96bdeb3c7b04e95ecabaa0253deddd2a22e14afe.tar.gz opencode-96bdeb3c7b04e95ecabaa0253deddd2a22e14afe.zip | |
OpenTUI is here (#2685)
Diffstat (limited to 'packages')
96 files changed, 7822 insertions, 473 deletions
diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 3d59ee5d5..9e3ab4497 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -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", diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 8bdf7f73b..e9df61911 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -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": { diff --git a/packages/desktop/src/context/sync.tsx b/packages/desktop/src/context/sync.tsx index c60206b0b..06fc05677 100644 --- a/packages/desktop/src/context/sync.tsx +++ b/packages/desktop/src/context/sync.tsx @@ -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" diff --git a/packages/function/src/api.ts b/packages/function/src/api.ts index 572ac3cab..6f00dae9a 100644 --- a/packages/function/src/api.ts +++ b/packages/function/src/api.ts @@ -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) { diff --git a/packages/opencode/bunfig.toml b/packages/opencode/bunfig.toml index 786a37744..d7b987cbb 100644 --- a/packages/opencode/bunfig.toml +++ b/packages/opencode/bunfig.toml @@ -1,2 +1,4 @@ +preload = ["@opentui/solid/preload"] + [test] preload = ["./test/preload.ts"] diff --git a/packages/opencode/package.json b/packages/opencode/package.json index a36117eaa..fa27c49ee 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -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:", diff --git a/packages/opencode/parsers-config.ts b/packages/opencode/parsers-config.ts new file mode 100644 index 000000000..cfa00454b --- /dev/null +++ b/packages/opencode/parsers-config.ts @@ -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", + ], + }, + }, + ], +} diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index b3d6d57dd..1d3a3face 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -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( diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts index 833198cb0..ffa9a91f3 100755 --- a/packages/opencode/script/publish.ts +++ b/packages/opencode/script/publish.ts @@ -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, diff --git a/packages/opencode/src/bun/index.ts b/packages/opencode/src/bun/index.ts index 8874a27ca..5f1847275 100644 --- a/packages/opencode/src/bun/index.ts +++ b/packages/opencode/src/bun/index.ts @@ -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, diff --git a/packages/opencode/src/cli/cmd/attach.ts b/packages/opencode/src/cli/cmd/attach.ts deleted file mode 100644 index 868585b05..000000000 --- a/packages/opencode/src/cli/cmd/attach.ts +++ /dev/null @@ -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 - }, -}) diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index e0980e137..763d82b3f 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -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, diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index d8fc2f3ac..cd3ceb94b 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -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 diff --git a/packages/opencode/src/cli/cmd/opentui/opentui.ts b/packages/opencode/src/cli/cmd/opentui/opentui.ts deleted file mode 100644 index e69de29bb..000000000 --- a/packages/opencode/src/cli/cmd/opentui/opentui.ts +++ /dev/null diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx new file mode 100644 index 000000000..75ea3fb25 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -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> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts new file mode 100644 index 000000000..38f1b6719 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/attach.ts @@ -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) + }, +}) diff --git a/packages/opencode/src/cli/cmd/tui/component/border.tsx b/packages/opencode/src/cli/cmd/tui/component/border.tsx new file mode 100644 index 000000000..9cbb96068 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/border.tsx @@ -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: "", + }, +} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx new file mode 100644 index 000000000..65aaeb22b --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx @@ -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() + }} + /> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx new file mode 100644 index 000000000..91150da9c --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx @@ -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 }))} + /> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx new file mode 100644 index 000000000..04f2f6523 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -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 }) + }} + /> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx new file mode 100644 index 000000000..605eb2bff --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -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) + }, + }, + ]} + /> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx new file mode 100644 index 000000000..732aa4573 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx @@ -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> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-tag.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-tag.tsx new file mode 100644 index 000000000..78eeded24 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-tag.tsx @@ -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() + }} + /> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx new file mode 100644 index 000000000..9f7a9203d --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx @@ -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) + }} + /> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/logo.tsx b/packages/opencode/src/cli/cmd/tui/component/logo.tsx new file mode 100644 index 000000000..59db5fe7d --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/logo.tsx @@ -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> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx new file mode 100644 index 000000000..0ab1cdb13 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -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> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx new file mode 100644 index 000000000..4b02d558a --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx @@ -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 + }), + ) + }, + } + }, +}) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx new file mode 100644 index 000000000..20015c307 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -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> + </> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/context/exit.tsx b/packages/opencode/src/cli/cmd/tui/context/exit.tsx new file mode 100644 index 000000000..7d7feaa28 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/exit.tsx @@ -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) + } + }, +}) diff --git a/packages/opencode/src/cli/cmd/tui/context/helper.tsx b/packages/opencode/src/cli/cmd/tui/context/helper.tsx new file mode 100644 index 000000000..6be88e775 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/helper.tsx @@ -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 + }, + } +} diff --git a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx new file mode 100644 index 000000000..1ed23ddbe --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx @@ -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 + }, +}) diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx new file mode 100644 index 000000000..25ec00b32 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -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 + }, +}) diff --git a/packages/opencode/src/cli/cmd/tui/context/route.tsx b/packages/opencode/src/cli/cmd/tui/context/route.tsx new file mode 100644 index 000000000..ef230dc98 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/route.tsx @@ -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 }> +} diff --git a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx new file mode 100644 index 000000000..655c68022 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx @@ -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 } + }, +}) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx new file mode 100644 index 000000000..765fb6196 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -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 + }, +}) diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx new file mode 100644 index 000000000..894b87b09 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -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 + }, + } + }, +}) diff --git a/packages/opencode/src/cli/cmd/tui/event.ts b/packages/opencode/src/cli/cmd/tui/event.ts new file mode 100644 index 000000000..005d0d54a --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/event.ts @@ -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"), + }), + ), +} diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx new file mode 100644 index 000000000..f24915113 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -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> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx new file mode 100644 index 000000000..ee2b77afc --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx @@ -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() + }, + }, + ]} + /> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx new file mode 100644 index 000000000..f5976cdf0 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx @@ -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()} /> +} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx new file mode 100644 index 000000000..31d25baa3 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx @@ -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> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx new file mode 100644 index 000000000..baa63d370 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -0,0 +1,1270 @@ +import { + createContext, + createEffect, + createMemo, + createSignal, + For, + Match, + Show, + Switch, + useContext, + type Component, +} from "solid-js" +import { Dynamic } from "solid-js/web" +import path from "path" +import { useRouteData } from "@tui/context/route" +import { useSync } from "@tui/context/sync" +import { SplitBorder } from "@tui/component/border" +import { SyntaxTheme, useTheme } from "@tui/context/theme" +import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers } from "@opentui/core" +import { Prompt, type PromptRef } from "@tui/component/prompt" +import type { + AssistantMessage, + Part, + ToolPart, + UserMessage, + TextPart, + ReasoningPart, +} from "@opencode-ai/sdk" +import { useLocal } from "@tui/context/local" +import { Locale } from "@/util/locale" +import type { Tool } from "@/tool/tool" +import type { ReadTool } from "@/tool/read" +import type { WriteTool } from "@/tool/write" +import { BashTool } from "@/tool/bash" +import type { GlobTool } from "@/tool/glob" +import { TodoWriteTool } from "@/tool/todo" +import type { GrepTool } from "@/tool/grep" +import type { ListTool } from "@/tool/ls" +import type { EditTool } from "@/tool/edit" +import type { PatchTool } from "@/tool/patch" +import type { WebFetchTool } from "@/tool/webfetch" +import type { TaskTool } from "@/tool/task" +import { + useKeyboard, + useRenderer, + useTerminalDimensions, + type BoxProps, + type JSX, +} from "@opentui/solid" +import { useSDK } from "@tui/context/sdk" +import { useCommandDialog } from "@tui/component/dialog-command" +import { Shimmer } from "@tui/ui/shimmer" +import { useKeybind } from "@tui/context/keybind" +import { Header } from "./header" +import { parsePatch } from "diff" +import { useDialog } from "../../ui/dialog" +import { DialogMessage } from "./dialog-message" +import type { PromptInfo } from "../../component/prompt/history" +import { iife } from "@/util/iife" +import { DialogConfirm } from "@tui/ui/dialog-confirm" +import { DialogTimeline } from "./dialog-timeline" +import { Sidebar } from "./sidebar" +import { LANGUAGE_EXTENSIONS } from "@/lsp/language" +import parsers from "../../../../../../parsers-config.ts" +import { Toast } from "../../ui/toast" + +addDefaultParsers(parsers.parsers) + +const context = createContext<{ + width: number + conceal: () => boolean +}>() + +function use() { + const ctx = useContext(context) + if (!ctx) throw new Error("useContext must be used within a Session component") + return ctx +} + +export function Session() { + 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 permissions = createMemo(() => sync.data.permission[route.sessionID] ?? []) + + const pending = createMemo(() => { + return messages().findLast((x) => x.role === "assistant" && !x.time?.completed)?.id + }) + + const dimensions = useTerminalDimensions() + const [sidebar, setSidebar] = createSignal<"show" | "hide" | "auto">("auto") + const [conceal, setConceal] = createSignal(true) + + const wide = createMemo(() => dimensions().width > 120) + const sidebarVisible = createMemo(() => sidebar() === "show" || (sidebar() === "auto" && wide())) + const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4) + + createEffect(() => sync.session.sync(route.sessionID)) + + const sdk = useSDK() + + let scroll: ScrollBoxRenderable + let prompt: PromptRef + const keybind = useKeybind() + + createEffect(() => { + dialog.allClosedEvent.listen(() => { + prompt.focus() + }) + }) + + useKeyboard((evt) => { + if (dialog.stack.length > 0) return + + const first = permissions()[0] + if (first) { + const response = iife(() => { + if (evt.name === "return") return "once" + if (evt.name === "a") return "always" + if (evt.name === "d") return "reject" + return + }) + if (response) { + sdk.client.postSessionIdPermissionsPermissionId({ + path: { + permissionID: first.id, + id: route.sessionID, + }, + body: { + response: response, + }, + }) + } + } + }) + + function toBottom() { + setTimeout(() => { + scroll.scrollTo(scroll.scrollHeight) + }, 50) + } + + // snap to bottom when revert position changes + createEffect((old) => { + if (old !== session()?.revert?.messageID) toBottom() + return session()?.revert?.messageID + }) + + const local = useLocal() + + const command = useCommandDialog() + command.register(() => [ + { + title: "Jump to message", + value: "session.timeline", + keybind: "session_timeline", + category: "Session", + onSelect: (dialog) => { + dialog.replace(() => ( + <DialogTimeline + onMove={(messageID) => { + const child = scroll.getChildren().find((child) => { + return child.id === messageID + }) + if (child) scroll.scrollBy(child.y - scroll.y - 1) + }} + sessionID={route.sessionID} + /> + )) + }, + }, + { + title: "Compact session", + value: "session.compact", + keybind: "session_compact", + category: "Session", + onSelect: (dialog) => { + sdk.client.session.summarize({ + path: { + id: route.sessionID, + }, + body: { + modelID: local.model.current().modelID, + providerID: local.model.current().providerID, + }, + }) + dialog.clear() + }, + }, + { + title: "Share session", + value: "session.share", + keybind: "session_share", + disabled: !!session()?.share?.url, + category: "Session", + onSelect: (dialog) => { + sdk.client.session.share({ + path: { + id: route.sessionID, + }, + }) + dialog.clear() + }, + }, + { + title: "Unshare session", + value: "session.unshare", + keybind: "session_unshare", + disabled: !session()?.share?.url, + category: "Session", + onSelect: (dialog) => { + sdk.client.session.unshare({ + path: { + id: route.sessionID, + }, + }) + dialog.clear() + }, + }, + { + title: "Undo previous message", + value: "session.undo", + keybind: "messages_undo", + category: "Session", + onSelect: (dialog) => { + const revert = session().revert?.messageID + const message = messages().findLast((x) => (!revert || x.id < revert) && x.role === "user") + if (!message) return + sdk.client.session.revert({ + path: { + id: route.sessionID, + }, + body: { + messageID: message.id, + }, + }) + const parts = sync.data.part[message.id] + prompt.set( + parts.reduce( + (agg, part) => { + if (part.type === "text") agg.input += part.text + if (part.type === "file") agg.parts.push(part) + return agg + }, + { input: "", parts: [] as PromptInfo["parts"] }, + ), + ) + dialog.clear() + }, + }, + { + title: "Redo", + value: "session.redo", + keybind: "messages_redo", + disabled: !session()?.revert?.messageID, + category: "Session", + onSelect: (dialog) => { + dialog.clear() + const messageID = session().revert?.messageID + if (!messageID) return + const message = messages().find((x) => x.role === "user" && x.id > messageID) + if (!message) { + sdk.client.session.unrevert({ + path: { + id: route.sessionID, + }, + }) + prompt.set({ input: "", parts: [] }) + return + } + sdk.client.session.revert({ + path: { + id: route.sessionID, + }, + body: { + messageID: message.id, + }, + }) + }, + }, + { + title: "Toggle sidebar", + value: "session.sidebar.toggle", + keybind: "sidebar_toggle", + category: "Session", + onSelect: (dialog) => { + setSidebar((prev) => { + if (prev === "auto") return sidebarVisible() ? "hide" : "show" + if (prev === "show") return "hide" + return "show" + }) + dialog.clear() + }, + }, + { + title: "Toggle code concealment", + value: "session.toggle.conceal", + keybind: "messages_toggle_conceal" as any, + category: "Session", + onSelect: (dialog) => { + setConceal((prev) => !prev) + dialog.clear() + }, + }, + { + title: "Page up", + value: "session.page.up", + keybind: "messages_page_up", + category: "Session", + disabled: true, + onSelect: (dialog) => { + scroll.scrollBy(-scroll.height / 2) + dialog.clear() + }, + }, + { + title: "Page down", + value: "session.page.down", + keybind: "messages_page_down", + category: "Session", + disabled: true, + onSelect: (dialog) => { + scroll.scrollBy(scroll.height / 2) + dialog.clear() + }, + }, + { + title: "Half page up", + value: "session.half.page.up", + keybind: "messages_half_page_up", + category: "Session", + disabled: true, + onSelect: (dialog) => { + scroll.scrollBy(-scroll.height / 4) + dialog.clear() + }, + }, + { + title: "Half page down", + value: "session.half.page.down", + keybind: "messages_half_page_down", + category: "Session", + disabled: true, + onSelect: (dialog) => { + scroll.scrollBy(scroll.height / 4) + dialog.clear() + }, + }, + { + title: "First message", + value: "session.first", + keybind: "messages_first", + category: "Session", + disabled: true, + onSelect: (dialog) => { + scroll.scrollTo(0) + dialog.clear() + }, + }, + { + title: "Last message", + value: "session.last", + keybind: "messages_last", + category: "Session", + disabled: true, + onSelect: (dialog) => { + scroll.scrollTo(scroll.scrollHeight) + dialog.clear() + }, + }, + ]) + + const revert = createMemo(() => { + const s = session() + if (!s) return + const messageID = s.revert?.messageID + if (!messageID) return + const reverted = messages().filter((x) => x.id >= messageID && x.role === "user") + + const diffFiles = (() => { + const diffText = s.revert?.diff || "" + if (!diffText) return [] + + const patches = parsePatch(diffText) + return patches.map((patch) => { + const filename = patch.newFileName || patch.oldFileName || "unknown" + const cleanFilename = filename.replace(/^[ab]\//, "") + return { + filename: cleanFilename, + additions: patch.hunks.reduce( + (sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("+")).length, + 0, + ), + deletions: patch.hunks.reduce( + (sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("-")).length, + 0, + ), + } + }) + })() + + return { + messageID, + reverted, + diff: s.revert!.diff, + diffFiles, + } + }) + + const dialog = useDialog() + const renderer = useRenderer() + + return ( + <context.Provider + value={{ + get width() { + return contentWidth() + }, + conceal, + }} + > + <box + flexDirection="row" + paddingBottom={1} + paddingTop={1} + paddingLeft={2} + paddingRight={2} + gap={2} + > + <box flexGrow={1} gap={1}> + <Show when={session()}> + <Show when={!sidebarVisible()}> + <Header /> + </Show> + <scrollbox + ref={(r) => (scroll = r)} + scrollbarOptions={{ visible: false }} + stickyScroll={true} + stickyStart="bottom" + flexGrow={1} + > + <For each={messages()}> + {(message, index) => ( + <Switch> + <Match when={message.id === revert()?.messageID}> + {(function () { + const command = useCommandDialog() + const [hover, setHover] = createSignal(false) + const dialog = useDialog() + + const handleUnrevert = async () => { + const confirmed = await DialogConfirm.show( + dialog, + "Confirm Redo", + "Are you sure you want to restore the reverted messages?", + ) + if (confirmed) { + command.trigger("session.redo") + } + } + + return ( + <box + onMouseOver={() => setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={handleUnrevert} + marginTop={1} + flexShrink={0} + border={["left"]} + customBorderChars={SplitBorder.customBorderChars} + borderColor={theme.backgroundPanel} + > + <box + paddingTop={1} + paddingBottom={1} + paddingLeft={2} + backgroundColor={ + hover() ? theme.backgroundElement : theme.backgroundPanel + } + > + <text fg={theme.textMuted}> + {revert()!.reverted.length} message reverted + </text> + <text fg={theme.textMuted}> + <span style={{ fg: theme.text }}> + {keybind.print("messages_redo")} + </span>{" "} + or /redo to restore + </text> + <Show when={revert()!.diffFiles?.length}> + <box marginTop={1}> + <For each={revert()!.diffFiles}> + {(file) => ( + <text> + {file.filename} + <Show when={file.additions > 0}> + <span style={{ fg: theme.diffAdded }}> + {" "} + +{file.additions} + </span> + </Show> + <Show when={file.deletions > 0}> + <span style={{ fg: theme.diffRemoved }}> + {" "} + -{file.deletions} + </span> + </Show> + </text> + )} + </For> + </box> + </Show> + </box> + </box> + ) + })()} + </Match> + <Match when={revert()?.messageID && message.id >= revert()!.messageID}> + <></> + </Match> + <Match when={message.role === "user"}> + <UserMessage + index={index()} + onMouseUp={() => { + if (renderer.getSelection()?.getSelectedText()) return + dialog.replace(() => ( + <DialogMessage messageID={message.id} sessionID={route.sessionID} /> + )) + }} + message={message as UserMessage} + parts={sync.data.part[message.id] ?? []} + pending={pending()} + /> + </Match> + <Match when={message.role === "assistant"}> + <AssistantMessage + last={index() === messages().length - 1} + message={message as AssistantMessage} + parts={sync.data.part[message.id] ?? []} + /> + </Match> + </Switch> + )} + </For> + </scrollbox> + <box flexShrink={0}> + <Prompt + ref={(r) => (prompt = r)} + disabled={permissions().length > 0} + onSubmit={() => { + toBottom() + }} + sessionID={route.sessionID} + /> + </box> + </Show> + <Toast /> + </box> + <Show when={sidebarVisible()}> + <Sidebar sessionID={route.sessionID} /> + </Show> + </box> + </context.Provider> + ) +} + +const MIME_BADGE: Record<string, string> = { + "text/plain": "txt", + "image/png": "img", + "image/jpeg": "img", + "image/gif": "img", + "image/webp": "img", + "application/pdf": "pdf", + "application/x-directory": "dir", +} + +function UserMessage(props: { + message: UserMessage + parts: Part[] + onMouseUp: () => void + index: number + pending?: string +}) { + const text = createMemo( + () => props.parts.flatMap((x) => (x.type === "text" && !x.synthetic ? [x] : []))[0], + ) + const files = createMemo(() => props.parts.flatMap((x) => (x.type === "file" ? [x] : []))) + const sync = useSync() + const { theme } = useTheme() + const [hover, setHover] = createSignal(false) + const queued = createMemo(() => props.pending && props.message.id > props.pending) + const color = createMemo(() => (queued() ? theme.accent : theme.secondary)) + + return ( + <Show when={text()}> + <box + id={props.message.id} + onMouseOver={() => { + setHover(true) + }} + onMouseOut={() => { + setHover(false) + }} + onMouseUp={props.onMouseUp} + border={["left"]} + paddingTop={1} + paddingBottom={1} + paddingLeft={2} + marginTop={props.index === 0 ? 0 : 1} + backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel} + customBorderChars={SplitBorder.customBorderChars} + borderColor={color()} + flexShrink={0} + > + <text>{text()?.text}</text> + <Show when={files().length}> + <box flexDirection="row" paddingBottom={1} paddingTop={1} gap={1} flexWrap="wrap"> + <For each={files()}> + {(file) => { + const bg = createMemo(() => { + if (file.mime.startsWith("image/")) return theme.accent + if (file.mime === "application/pdf") return theme.primary + return theme.secondary + }) + return ( + <text> + <span style={{ bg: bg(), fg: theme.background }}> + {" "} + {MIME_BADGE[file.mime] ?? file.mime}{" "} + </span> + <span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> + {" "} + {file.filename}{" "} + </span> + </text> + ) + }} + </For> + </box> + </Show> + <text> + {sync.data.config.username ?? "You"}{" "} + <Show + when={queued()} + fallback={ + <span style={{ fg: theme.textMuted }}> + ({Locale.time(props.message.time.created)}) + </span> + } + > + <span style={{ bg: theme.accent, fg: theme.backgroundPanel, bold: true }}> + {" "} + QUEUED{" "} + </span> + </Show> + </text> + </box> + </Show> + ) +} + +function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; last: boolean }) { + const local = useLocal() + const { theme } = useTheme() + return ( + <> + <For each={props.parts}> + {(part) => { + const component = createMemo(() => PART_MAPPING[part.type as keyof typeof PART_MAPPING]) + return ( + <Show when={component()}> + <Dynamic component={component()} part={part as any} message={props.message} /> + </Show> + ) + }} + </For> + <Show when={props.message.error}> + <box + border={["left"]} + paddingTop={1} + paddingBottom={1} + paddingLeft={2} + marginTop={1} + backgroundColor={theme.backgroundPanel} + customBorderChars={SplitBorder.customBorderChars} + borderColor={theme.error} + > + <text fg={theme.textMuted}>{props.message.error?.data.message}</text> + </box> + </Show> + <Show + when={ + !props.message.time.completed || + (props.last && + props.parts.some((item) => item.type === "step-finish" && item.reason === "tool-calls")) + } + > + <box + paddingLeft={2} + marginTop={1} + flexDirection="row" + gap={1} + border={["left"]} + customBorderChars={SplitBorder.customBorderChars} + borderColor={theme.backgroundElement} + > + <text fg={local.agent.color(props.message.mode)}> + {Locale.titlecase(props.message.mode)} + </text> + <Shimmer text={`${props.message.modelID}`} color={theme.text} /> + </box> + </Show> + <Show + when={ + props.message.time.completed && + props.parts.some((item) => item.type === "step-finish" && item.reason !== "tool-calls") + } + > + <box paddingLeft={3}> + <text marginTop={1}> + <span style={{ fg: local.agent.color(props.message.mode) }}> + {Locale.titlecase(props.message.mode)} + </span>{" "} + <span style={{ fg: theme.textMuted }}>{props.message.modelID}</span> + </text> + </box> + </Show> + </> + ) +} + +const PART_MAPPING = { + text: TextPart, + tool: ToolPart, + reasoning: ReasoningPart, +} + +function ReasoningPart(props: { part: ReasoningPart; message: AssistantMessage }) { + const { theme } = useTheme() + return ( + <Show when={props.part.text.trim()}> + <box + id={"text-" + props.part.id} + marginTop={1} + flexShrink={0} + border={["left"]} + customBorderChars={SplitBorder.customBorderChars} + borderColor={theme.backgroundPanel} + > + <box + paddingTop={1} + paddingBottom={1} + paddingLeft={2} + backgroundColor={theme.backgroundPanel} + > + <text>{props.part.text.trim()}</text> + </box> + </box> + </Show> + ) +} + +function TextPart(props: { part: TextPart; message: AssistantMessage }) { + const ctx = use() + return ( + <Show when={props.part.text.trim()}> + <box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0}> + <code + filetype="markdown" + drawUnstyledText={false} + syntaxStyle={SyntaxTheme} + content={props.part.text.trim()} + conceal={ctx.conceal()} + /> + </box> + </Show> + ) +} + +// Pending messages moved to individual tool pending functions + +function ToolPart(props: { part: ToolPart; message: AssistantMessage }) { + const { theme } = useTheme() + const sync = useSync() + const [margin, setMargin] = createSignal(0) + const component = createMemo(() => { + const render = ToolRegistry.render(props.part.tool) ?? GenericTool + + const metadata = props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {}) + const input = props.part.state.input + const container = ToolRegistry.container(props.part.tool) + const permissions = sync.data.permission[props.message.sessionID] ?? [] + const permissionIndex = permissions.findIndex((x) => x.callID === props.part.callID) + const permission = permissions[permissionIndex] + + const style: BoxProps = + container === "block" || permission + ? { + border: permissionIndex === 0 ? (["left", "right"] as const) : (["left"] as const), + paddingTop: 1, + paddingBottom: 1, + paddingLeft: 2, + marginTop: 1, + gap: 1, + backgroundColor: theme.backgroundPanel, + customBorderChars: SplitBorder.customBorderChars, + borderColor: permissionIndex === 0 ? theme.warning : theme.background, + } + : { + paddingLeft: 3, + } + + return ( + <box + marginTop={margin()} + {...style} + renderBefore={function () { + const el = this as BoxRenderable + const parent = el.parent + if (!parent) { + return + } + if (el.height > 1) { + setMargin(1) + return + } + const children = parent.getChildren() + const index = children.indexOf(el) + const previous = children[index - 1] + if (!previous) { + setMargin(0) + return + } + if (previous.height > 1 || previous.id.startsWith("text-")) { + setMargin(1) + return + } + }} + > + <Dynamic + component={render} + input={input} + tool={props.part.tool} + metadata={metadata} + permission={permission?.metadata ?? {}} + output={props.part.state.status === "completed" ? props.part.state.output : undefined} + /> + {props.part.state.status === "error" && ( + <box paddingLeft={2}> + <text fg={theme.error}>{props.part.state.error.replace("Error: ", "")}</text> + </box> + )} + {permission && ( + <box gap={1}> + <text fg={theme.text}>Permission required to run this tool:</text> + <box flexDirection="row" gap={2}> + <text> + <b>enter</b> + <span style={{ fg: theme.textMuted }}> accept</span> + </text> + <text> + <b>a</b> + <span style={{ fg: theme.textMuted }}> accept always</span> + </text> + <text> + <b>d</b> + <span style={{ fg: theme.textMuted }}> deny</span> + </text> + </box> + </box> + )} + </box> + ) + }) + + return <Show when={component()}>{component()}</Show> +} + +type ToolProps<T extends Tool.Info> = { + input: Partial<Tool.InferParameters<T>> + metadata: Partial<Tool.InferMetadata<T>> + permission: Record<string, any> + tool: string + output?: string +} +function GenericTool(props: ToolProps<any>) { + return ( + <ToolTitle icon="⚙" fallback="Writing command..." when={true}> + {props.tool} {input(props.input)} + </ToolTitle> + ) +} + +const ToolRegistry = (() => { + const state: Record< + string, + { name: string; container: "inline" | "block"; render?: Component<ToolProps<any>> } + > = {} + function register<T extends Tool.Info>(input: { + name: string + container: "inline" | "block" + render?: Component<ToolProps<T>> + }) { + state[input.name] = input + return input + } + return { + register, + container(name: string) { + return state[name]?.container + }, + render(name: string) { + return state[name]?.render + }, + } +})() + +function ToolTitle(props: { fallback: string; when: any; icon: string; children: JSX.Element }) { + const { theme } = useTheme() + return ( + <text paddingLeft={3} fg={props.when ? theme.textMuted : theme.text}> + <Show fallback={<>~ {props.fallback}</>} when={props.when}> + <span style={{ bold: true }}>{props.icon}</span> {props.children} + </Show> + </text> + ) +} + +ToolRegistry.register<typeof BashTool>({ + name: "bash", + container: "block", + render(props) { + const output = createMemo(() => Bun.stripANSI(props.metadata.output?.trim() ?? "")) + const { theme } = useTheme() + return ( + <> + <ToolTitle icon="#" fallback="Writing command..." when={props.input.command}> + {props.input.description || "Shell"} + </ToolTitle> + <Show when={props.input.command}> + <text fg={theme.text}>$ {props.input.command}</text> + </Show> + <Show when={output()}> + <box> + <text fg={theme.text}>{output()}</text> + </box> + </Show> + </> + ) + }, +}) + +ToolRegistry.register<typeof ReadTool>({ + name: "read", + container: "inline", + render(props) { + return ( + <> + <ToolTitle icon="→" fallback="Reading file..." when={props.input.filePath}> + Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])} + </ToolTitle> + </> + ) + }, +}) + +ToolRegistry.register<typeof WriteTool>({ + name: "write", + container: "block", + render(props) { + const { theme } = useTheme() + const lines = createMemo(() => { + return props.input.content?.split("\n") ?? [] + }) + const code = createMemo(() => { + if (!props.input.content) return "" + const text = props.input.content + return text + }) + + const numbers = createMemo(() => { + const pad = lines().length.toString().length + return lines() + .map((_, index) => index + 1) + .map((x) => x.toString().padStart(pad, " ")) + }) + + return ( + <> + <ToolTitle icon="←" fallback="Preparing write..." when={props.input.filePath}> + Wrote {props.input.filePath} + </ToolTitle> + <box flexDirection="row"> + <box flexShrink={0}> + <For each={numbers()}> + {(value) => <text style={{ fg: theme.textMuted }}>{value}</text>} + </For> + </box> + <box paddingLeft={1} flexGrow={1}> + <code + filetype={filetype(props.input.filePath!)} + syntaxStyle={SyntaxTheme} + content={code()} + /> + </box> + </box> + </> + ) + }, +}) + +ToolRegistry.register<typeof GlobTool>({ + name: "glob", + container: "inline", + render(props) { + return ( + <> + <ToolTitle icon="✱" fallback="Finding files..." when={props.input.pattern}> + Glob "{props.input.pattern}"{" "} + <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show> + <Show when={props.metadata.count}>({props.metadata.count} matches)</Show> + </ToolTitle> + </> + ) + }, +}) + +ToolRegistry.register<typeof GrepTool>({ + name: "grep", + container: "inline", + render(props) { + return ( + <ToolTitle icon="✱" fallback="Searching content..." when={props.input.pattern}> + Grep "{props.input.pattern}"{" "} + <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show> + <Show when={props.metadata.matches}>({props.metadata.matches} matches)</Show> + </ToolTitle> + ) + }, +}) + +ToolRegistry.register<typeof ListTool>({ + name: "list", + container: "inline", + render(props) { + const dir = createMemo(() => { + if (props.input.path) { + return normalizePath(props.input.path) + } + return "" + }) + return ( + <> + <ToolTitle icon="→" fallback="Listing directory..." when={props.input.path !== undefined}> + List {dir()} + </ToolTitle> + </> + ) + }, +}) + +ToolRegistry.register<typeof TaskTool>({ + name: "task", + container: "block", + render(props) { + const { theme } = useTheme() + return ( + <> + <ToolTitle icon="%" fallback="Delegating..." when={props.input.description}> + Task {props.input.description} + </ToolTitle> + <Show when={props.metadata.summary?.length}> + <box> + <For each={props.metadata.summary ?? []}> + {(task) => ( + <text style={{ fg: theme.textMuted }}> + ∟ {task.tool} {task.state.status === "completed" ? task.state.title : ""} + </text> + )} + </For> + </box> + </Show> + </> + ) + }, +}) + +ToolRegistry.register<typeof WebFetchTool>({ + name: "webfetch", + container: "inline", + render(props) { + return ( + <ToolTitle icon="%" fallback="Fetching from the web..." when={(props.input as any).url}> + WebFetch {(props.input as any).url} + </ToolTitle> + ) + }, +}) + +ToolRegistry.register<typeof EditTool>({ + name: "edit", + container: "block", + render(props) { + const ctx = use() + + const style = createMemo(() => (ctx.width > 120 ? "split" : "stacked")) + + const diff = createMemo(() => { + const diff = props.metadata.diff ?? props.permission["diff"] + if (!diff) return null + const patches = parsePatch(diff) + if (patches.length === 0) return null + + const patch = patches[0] + const oldLines: string[] = [] + const newLines: string[] = [] + + for (const hunk of patch.hunks) { + let i = 0 + while (i < hunk.lines.length) { + const line = hunk.lines[i] + + if (line.startsWith("-")) { + const removedLines: string[] = [] + while (i < hunk.lines.length && hunk.lines[i].startsWith("-")) { + removedLines.push("- " + hunk.lines[i].slice(1)) + i++ + } + + const addedLines: string[] = [] + while (i < hunk.lines.length && hunk.lines[i].startsWith("+")) { + addedLines.push("+ " + hunk.lines[i].slice(1)) + i++ + } + + const maxLen = Math.max(removedLines.length, addedLines.length) + for (let j = 0; j < maxLen; j++) { + oldLines.push(removedLines[j] ?? "") + newLines.push(addedLines[j] ?? "") + } + } else if (line.startsWith("+")) { + const addedLines: string[] = [] + while (i < hunk.lines.length && hunk.lines[i].startsWith("+")) { + addedLines.push("+ " + hunk.lines[i].slice(1)) + i++ + } + + for (const added of addedLines) { + oldLines.push("") + newLines.push(added) + } + } else { + oldLines.push(" " + line.slice(1)) + newLines.push(" " + line.slice(1)) + i++ + } + } + } + + return { + oldContent: oldLines.join("\n"), + newContent: newLines.join("\n"), + } + }) + + const code = createMemo(() => { + if (!props.metadata.diff) return "" + const text = props.metadata.diff.split("\n").slice(5).join("\n") + return text.trim() + }) + + const ft = createMemo(() => filetype(props.input.filePath)) + + return ( + <> + <ToolTitle icon="←" fallback="Preparing edit..." when={props.input.filePath}> + Edit {normalizePath(props.input.filePath!)}{" "} + {input({ + replaceAll: props.input.replaceAll, + })} + </ToolTitle> + <Switch> + <Match when={props.permission["diff"]}> + <text>{props.permission["diff"]?.trim()}</text> + </Match> + <Match when={diff() && style() === "split"}> + <box paddingLeft={1} flexDirection="row" gap={2}> + <box flexGrow={1} flexBasis={0}> + <code filetype={ft()} syntaxStyle={SyntaxTheme} content={diff()!.oldContent} /> + </box> + <box flexGrow={1} flexBasis={0}> + <code filetype={ft()} syntaxStyle={SyntaxTheme} content={diff()!.newContent} /> + </box> + </box> + </Match> + <Match when={code()}> + <box paddingLeft={1}> + <code filetype={ft()} syntaxStyle={SyntaxTheme} content={code()} /> + </box> + </Match> + </Switch> + </> + ) + }, +}) + +ToolRegistry.register<typeof PatchTool>({ + name: "patch", + container: "block", + render(props) { + return ( + <> + <ToolTitle icon="%" fallback="Preparing patch..." when={true}> + Patch + </ToolTitle> + <Show when={props.output}> + <box> + <text>{props.output?.trim()}</text> + </box> + </Show> + </> + ) + }, +}) + +ToolRegistry.register<typeof TodoWriteTool>({ + name: "todowrite", + container: "block", + render(props) { + const { theme } = useTheme() + return ( + <box> + <For each={props.input.todos ?? []}> + {(todo) => ( + <text style={{ fg: todo.status === "in_progress" ? theme.success : theme.textMuted }}> + [{todo.status === "completed" ? "✓" : " "}] {todo.content} + </text> + )} + </For> + </box> + ) + }, +}) + +function normalizePath(input?: string) { + if (!input) return "" + if (path.isAbsolute(input)) { + return path.relative(process.cwd(), input) || "." + } + return input +} + +function input(input: Record<string, any>, omit?: string[]): string { + const primitives = Object.entries(input).filter(([key, value]) => { + if (omit?.includes(key)) return false + return typeof value === "string" || typeof value === "number" || typeof value === "boolean" + }) + if (primitives.length === 0) return "" + return `[${primitives.map(([key, value]) => `${key}=${value}`).join(", ")}]` +} + +function filetype(input?: string) { + if (!input) return "none" + const ext = path.extname(input) + const language = LANGUAGE_EXTENSIONS[ext] + if (["typescriptreact", "javascriptreact", "javascript"].includes(language)) return "typescript" + return language +} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx new file mode 100644 index 000000000..380d82964 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -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> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/spawn.ts b/packages/opencode/src/cli/cmd/tui/spawn.ts new file mode 100644 index 000000000..162d8793b --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/spawn.ts @@ -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) + }, +}) diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts new file mode 100644 index 000000000..cadeac17e --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -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) + }, + }) + }) + }, +}) diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx new file mode 100644 index 000000000..6bb59d6c7 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx @@ -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(), + ) + }) +} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx new file mode 100644 index 000000000..dd5b238b1 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx @@ -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), + ) + }) +} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx new file mode 100644 index 000000000..f08e0dabf --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx @@ -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> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx new file mode 100644 index 000000000..668ffb8d3 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -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> + </> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx new file mode 100644 index 000000000..ec2233c69 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx @@ -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 +} diff --git a/packages/opencode/src/cli/cmd/tui/ui/shimmer.tsx b/packages/opencode/src/cli/cmd/tui/ui/shimmer.tsx new file mode 100644 index 000000000..6c5629b8a --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/ui/shimmer.tsx @@ -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> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/ui/toast.tsx b/packages/opencode/src/cli/cmd/tui/ui/toast.tsx new file mode 100644 index 000000000..f742635e0 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/ui/toast.tsx @@ -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 +} diff --git a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts new file mode 100644 index 000000000..a2d83c912 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts @@ -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) + } +} diff --git a/packages/opencode/src/cli/cmd/tui/util/editor.ts b/packages/opencode/src/cli/cmd/tui/util/editor.ts new file mode 100644 index 000000000..25a17fc7a --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/editor.ts @@ -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 + } +} diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts new file mode 100644 index 000000000..d268449c1 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -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) diff --git a/packages/opencode/src/cli/upgrade.ts b/packages/opencode/src/cli/upgrade.ts new file mode 100644 index 000000000..73332f955 --- /dev/null +++ b/packages/opencode/src/cli/upgrade.ts @@ -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(() => {}) +} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 55b5f0115..fec949606 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -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_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 + session_interrupt: z .string() .optional() - .default("ctrl+left") - .describe("Cycle to previous child session"), + .default("escape") + .describe("Interrupt current session"), + session_compact: z.string().optional().default("<leader>c").describe("Compact the 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", diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 49eac54ac..cb405c181 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -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) diff --git a/packages/opencode/src/global/index.ts b/packages/opencode/src/global/index.ts index 4ebea7c66..2fbe30707 100644 --- a/packages/opencode/src/global/index.ts +++ b/packages/opencode/src/global/index.ts @@ -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) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 45ccd3cad..26c9f543d 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -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) diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 2d36f454b..1a6e2cb71 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -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, diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index cccc8e774..67dd838ac 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -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 } diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index da4508790..d0fb11e94 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -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 = "" diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index d492a9365..d80d34330 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -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 + if (!result.mcpClient) { + s.status[name] = result.status + return + } + s.clients[name] = result.mcpClient + s.status[name] = result.status } - async function create(name: string, mcp: Config.Mcp) { + async function create(key: string, mcp: Config.Mcp) { if (mcp.enabled === false) { - log.info("mcp server disabled", { name }) + log.info("mcp server disabled", { key }) return } - log.info("found", { name, type: mcp.type }) - + 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, - }) - 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, - }, - }, }) + .then((client) => { + log.info("connected", { key, transport: name }) + mcpClient = client + status = { status: "connected" } + return true + }) + .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), + }) + .then((client) => { + mcpClient = client + status = { + status: "connected", + } }) - Bus.publish(Session.Event.Error, { - error: { - name: "UnknownError", - data: { - message: errorMessage, - }, - }, + .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), + } }) - return null - }) - if (client) { - mcpClient = client + } + + 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() { diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index a36da6e81..eb541049d 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -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) diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index d8ecaf6ad..0d66b469f 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -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() diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 01ea87a3c..3b99a5fa1 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -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() + }, } diff --git a/packages/opencode/src/project/state.ts b/packages/opencode/src/project/state.ts index 6377833eb..908ae316b 100644 --- a/packages/opencode/src/project/state.ts +++ b/packages/opencode/src/project/state.ts @@ -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 }) } diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 550312c44..deb3ed493 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -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( diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 116a7e39d..cc6351675 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -119,6 +119,7 @@ export namespace SessionCompaction { cwd: Instance.directory, root: Instance.worktree, }, + summary: true, cost: 0, tokens: { output: 0, diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index e1a5c844f..3b28afe0f 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -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: "", } } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index b9208f550..2f1505869 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -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: { diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 12b040f6b..10b844d61 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -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 }) diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index b7555a644..3173dcac5 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -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)) } diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index e77bbf8ff..e7b7d7382 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -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) diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index fb7e2fe03..a4b00100f 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -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) diff --git a/packages/opencode/src/util/binary.ts b/packages/opencode/src/util/binary.ts new file mode 100644 index 000000000..3d8f61851 --- /dev/null +++ b/packages/opencode/src/util/binary.ts @@ -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 + } +} diff --git a/packages/opencode/src/util/eventloop.ts b/packages/opencode/src/util/eventloop.ts new file mode 100644 index 000000000..87f6eef41 --- /dev/null +++ b/packages/opencode/src/util/eventloop.ts @@ -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() + }) + } +} diff --git a/packages/opencode/src/util/iife.ts b/packages/opencode/src/util/iife.ts new file mode 100644 index 000000000..ca9ae6c10 --- /dev/null +++ b/packages/opencode/src/util/iife.ts @@ -0,0 +1,3 @@ +export function iife<T>(fn: () => T) { + return fn() +} diff --git a/packages/opencode/src/util/keybind.ts b/packages/opencode/src/util/keybind.ts new file mode 100644 index 000000000..96619416f --- /dev/null +++ b/packages/opencode/src/util/keybind.ts @@ -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 + }) + } +} diff --git a/packages/opencode/src/util/locale.ts b/packages/opencode/src/util/locale.ts new file mode 100644 index 000000000..ab2623271 --- /dev/null +++ b/packages/opencode/src/util/locale.ts @@ -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()) + } +} diff --git a/packages/opencode/src/util/rpc.ts b/packages/opencode/src/util/rpc.ts new file mode 100644 index 000000000..57c695c48 --- /dev/null +++ b/packages/opencode/src/util/rpc.ts @@ -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 })) + }) + }, + } + } +} diff --git a/packages/opencode/src/util/signal.ts b/packages/opencode/src/util/signal.ts new file mode 100644 index 000000000..bc633ecc6 --- /dev/null +++ b/packages/opencode/src/util/signal.ts @@ -0,0 +1,12 @@ +export function signal() { + let resolve: any + const promise = new Promise((r) => (resolve = r)) + return { + trigger() { + return resolve() + }, + wait() { + return promise + }, + } +} diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index 0d3e0c917..b7793d261 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -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 () => { diff --git a/packages/opencode/test/keybind.test.ts b/packages/opencode/test/keybind.test.ts new file mode 100644 index 000000000..7ef36f2c5 --- /dev/null +++ b/packages/opencode/test/keybind.test.ts @@ -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", + }, + ]) + }) +}) diff --git a/packages/opencode/test/tool/patch.test.ts b/packages/opencode/test/tool/patch.test.ts index 649119dce..a34d7718d 100644 --- a/packages/opencode/test/tool/patch.test.ts +++ b/packages/opencode/test/tool/patch.test.ts @@ -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"', + ) }, }) }) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 81d90cbab..87d472e55 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -24,4 +24,4 @@ "typescript": "catalog:", "@typescript/native-preview": "catalog:" } -}
\ No newline at end of file +} diff --git a/packages/plugin/src/example.ts b/packages/plugin/src/example.ts index 1e4557a68..94745a37b 100644 --- a/packages/plugin/src/example.ts +++ b/packages/plugin/src/example.ts @@ -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 - }, } } diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 50c9394f3..ee1558f6f 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -26,4 +26,4 @@ "publishConfig": { "directory": "dist" } -}
\ No newline at end of file +} diff --git a/packages/sdk/js/script/build.ts b/packages/sdk/js/script/build.ts index db7e56f90..c1fe0f423 100755 --- a/packages/sdk/js/script/build.ts +++ b/packages/sdk/js/script/build.ts @@ -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: { diff --git a/packages/sdk/js/src/gen/sdk.gen.ts b/packages/sdk/js/src/gen/sdk.gen.ts index 5eb12b0f5..1dcdd8067 100644 --- a/packages/sdk/js/src/gen/sdk.gen.ts +++ b/packages/sdk/js/src/gen/sdk.gen.ts @@ -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 }) diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index ae492c9af..046dccf68 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -19,10 +19,6 @@ export type KeybindsConfig = { */ leader?: string /** - * Show help dialog - */ - app_help?: string - /** * Exit the application */ app_exit?: string @@ -35,17 +31,13 @@ export type KeybindsConfig = { */ theme_list?: string /** - * Create/update AGENTS.md - */ - project_init?: string - /** - * Toggle tool details + * Toggle sidebar */ - tool_details?: string + sidebar_toggle?: string /** - * Toggle thinking blocks + * View status */ - thinking_blocks?: string + status_view?: string /** * Export session to editor */ @@ -79,14 +71,6 @@ export type KeybindsConfig = { */ 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 */ messages_page_up?: string @@ -127,13 +111,9 @@ export type KeybindsConfig = { */ model_list?: string /** - * Next recent model - */ - model_cycle_recent?: string - /** - * Previous recent model + * List available commands */ - 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 diff --git a/packages/sdk/js/tsconfig.json b/packages/sdk/js/tsconfig.json index 117381878..1e77472c4 100644 --- a/packages/sdk/js/tsconfig.json +++ b/packages/sdk/js/tsconfig.json @@ -8,5 +8,6 @@ "moduleResolution": "nodenext", "lib": ["es2022", "dom", "dom.iterable"] }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/gen"] } diff --git a/packages/tui/internal/theme/themes/vesper.json b/packages/tui/internal/theme/themes/vesper.json index cb19ff178..758c8f20c 100644 --- a/packages/tui/internal/theme/themes/vesper.json +++ b/packages/tui/internal/theme/themes/vesper.json @@ -3,7 +3,7 @@ "defs": { "vesperBg": "#101010", "vesperFg": "#FFF", - "vesperComment": "#8b8b8b94", + "vesperComment": "#8b8b8b", "vesperKeyword": "#A0A0A0", "vesperFunction": "#FFC799", "vesperString": "#99FFE4", diff --git a/packages/web/src/components/share/content-diff.tsx b/packages/web/src/components/share/content-diff.tsx index 45249e0cd..9ccd554d0 100644 --- a/packages/web/src/components/share/content-diff.tsx +++ b/packages/web/src/components/share/content-diff.tsx @@ -110,7 +110,10 @@ export function ContentDiff(props: Props) { }) const mobileRows = createMemo(() => { - const mobileBlocks: { type: "removed" | "added" | "unchanged"; lines: string[] }[] = [] + const mobileBlocks: { + type: "removed" | "added" | "unchanged" + lines: string[] + }[] = [] const currentRows = rows() let i = 0 diff --git a/packages/web/src/components/share/part.tsx b/packages/web/src/components/share/part.tsx index ddef206ba..f7a6a9304 100644 --- a/packages/web/src/components/share/part.tsx +++ b/packages/web/src/components/share/part.tsx @@ -174,6 +174,12 @@ export function Part(props: PartProps) { <div data-slot="filename">{props.part.filename}</div> </div> )} + {props.message.role === "user" && props.part.type === "file" && ( + <div data-component="attachment"> + <div data-slot="copy">Attachment</div> + <div data-slot="filename">{props.part.filename}</div> + </div> + )} {props.part.type === "step-start" && props.message.role === "assistant" && ( <div data-component="step-start"> <div data-slot="provider">{props.message.providerID}</div> |
