From 96bdeb3c7b04e95ecabaa0253deddd2a22e14afe Mon Sep 17 00:00:00 2001 From: Dax Date: Fri, 31 Oct 2025 15:07:36 -0400 Subject: OpenTUI is here (#2685) --- packages/console/app/package.json | 6 +- packages/console/core/package.json | 2 +- packages/desktop/src/context/sync.tsx | 14 +- packages/function/src/api.ts | 20 +- packages/opencode/bunfig.toml | 2 + packages/opencode/package.json | 20 +- packages/opencode/parsers-config.ts | 207 ++++ packages/opencode/script/build.ts | 36 +- packages/opencode/script/publish.ts | 4 +- packages/opencode/src/bun/index.ts | 5 +- packages/opencode/src/cli/cmd/attach.ts | 65 - packages/opencode/src/cli/cmd/auth.ts | 2 +- packages/opencode/src/cli/cmd/github.ts | 2 +- packages/opencode/src/cli/cmd/opentui/opentui.ts | 0 packages/opencode/src/cli/cmd/tui/app.tsx | 327 +++++ packages/opencode/src/cli/cmd/tui/attach.ts | 22 + .../opencode/src/cli/cmd/tui/component/border.tsx | 16 + .../src/cli/cmd/tui/component/dialog-agent.tsx | 31 + .../src/cli/cmd/tui/component/dialog-command.tsx | 96 ++ .../src/cli/cmd/tui/component/dialog-model.tsx | 74 ++ .../cli/cmd/tui/component/dialog-session-list.tsx | 80 ++ .../src/cli/cmd/tui/component/dialog-status.tsx | 78 ++ .../src/cli/cmd/tui/component/dialog-tag.tsx | 46 + .../cli/cmd/tui/component/dialog-theme-list.tsx | 52 + .../opencode/src/cli/cmd/tui/component/logo.tsx | 29 + .../cli/cmd/tui/component/prompt/autocomplete.tsx | 403 +++++++ .../src/cli/cmd/tui/component/prompt/history.tsx | 78 ++ .../src/cli/cmd/tui/component/prompt/index.tsx | 703 +++++++++++ packages/opencode/src/cli/cmd/tui/context/exit.tsx | 14 + .../opencode/src/cli/cmd/tui/context/helper.tsx | 25 + .../opencode/src/cli/cmd/tui/context/keybind.tsx | 103 ++ .../opencode/src/cli/cmd/tui/context/local.tsx | 276 +++++ .../opencode/src/cli/cmd/tui/context/route.tsx | 46 + packages/opencode/src/cli/cmd/tui/context/sdk.tsx | 37 + packages/opencode/src/cli/cmd/tui/context/sync.tsx | 270 +++++ .../opencode/src/cli/cmd/tui/context/theme.tsx | 658 ++++++++++ packages/opencode/src/cli/cmd/tui/event.ts | 39 + packages/opencode/src/cli/cmd/tui/routes/home.tsx | 83 ++ .../cli/cmd/tui/routes/session/dialog-message.tsx | 56 + .../cli/cmd/tui/routes/session/dialog-timeline.tsx | 37 + .../src/cli/cmd/tui/routes/session/header.tsx | 81 ++ .../src/cli/cmd/tui/routes/session/index.tsx | 1270 ++++++++++++++++++++ .../src/cli/cmd/tui/routes/session/sidebar.tsx | 175 +++ packages/opencode/src/cli/cmd/tui/spawn.ts | 57 + packages/opencode/src/cli/cmd/tui/thread.ts | 105 ++ .../opencode/src/cli/cmd/tui/ui/dialog-alert.tsx | 55 + .../opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx | 79 ++ .../opencode/src/cli/cmd/tui/ui/dialog-help.tsx | 39 + .../opencode/src/cli/cmd/tui/ui/dialog-select.tsx | 275 +++++ packages/opencode/src/cli/cmd/tui/ui/dialog.tsx | 171 +++ packages/opencode/src/cli/cmd/tui/ui/shimmer.tsx | 56 + packages/opencode/src/cli/cmd/tui/ui/toast.tsx | 83 ++ .../opencode/src/cli/cmd/tui/util/clipboard.ts | 127 ++ packages/opencode/src/cli/cmd/tui/util/editor.ts | 31 + packages/opencode/src/cli/cmd/tui/worker.ts | 48 + packages/opencode/src/cli/upgrade.ts | 17 + packages/opencode/src/config/config.ts | 135 +-- packages/opencode/src/file/index.ts | 4 +- packages/opencode/src/global/index.ts | 9 +- packages/opencode/src/index.ts | 8 +- packages/opencode/src/lsp/client.ts | 5 +- packages/opencode/src/lsp/index.ts | 37 +- packages/opencode/src/lsp/server.ts | 4 +- packages/opencode/src/mcp/index.ts | 198 +-- packages/opencode/src/permission/index.ts | 16 +- packages/opencode/src/plugin/index.ts | 1 + packages/opencode/src/project/instance.ts | 10 + packages/opencode/src/project/state.ts | 9 +- packages/opencode/src/server/server.ts | 177 ++- packages/opencode/src/session/compaction.ts | 1 + packages/opencode/src/session/message-v2.ts | 6 +- packages/opencode/src/session/prompt.ts | 12 +- packages/opencode/src/session/summary.ts | 15 +- packages/opencode/src/session/system.ts | 3 +- packages/opencode/src/tool/bash.ts | 59 +- packages/opencode/src/tool/write.ts | 2 +- packages/opencode/src/util/binary.ts | 41 + packages/opencode/src/util/eventloop.ts | 20 + packages/opencode/src/util/iife.ts | 3 + packages/opencode/src/util/keybind.ts | 76 ++ packages/opencode/src/util/locale.ts | 39 + packages/opencode/src/util/rpc.ts | 42 + packages/opencode/src/util/signal.ts | 12 + packages/opencode/test/fixture/fixture.ts | 5 +- packages/opencode/test/keybind.test.ts | 305 +++++ packages/opencode/test/tool/patch.test.ts | 12 +- packages/plugin/package.json | 2 +- packages/plugin/src/example.ts | 6 +- packages/sdk/js/package.json | 2 +- packages/sdk/js/script/build.ts | 2 + packages/sdk/js/src/gen/sdk.gen.ts | 40 + packages/sdk/js/src/gen/types.gen.ts | 228 ++-- packages/sdk/js/tsconfig.json | 3 +- packages/tui/internal/theme/themes/vesper.json | 2 +- packages/web/src/components/share/content-diff.tsx | 5 +- packages/web/src/components/share/part.tsx | 6 + 96 files changed, 7822 insertions(+), 473 deletions(-) create mode 100644 packages/opencode/parsers-config.ts delete mode 100644 packages/opencode/src/cli/cmd/attach.ts delete mode 100644 packages/opencode/src/cli/cmd/opentui/opentui.ts create mode 100644 packages/opencode/src/cli/cmd/tui/app.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/attach.ts create mode 100644 packages/opencode/src/cli/cmd/tui/component/border.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/component/dialog-tag.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/component/logo.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/context/exit.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/context/helper.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/context/keybind.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/context/local.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/context/route.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/context/sdk.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/context/sync.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/context/theme.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/event.ts create mode 100644 packages/opencode/src/cli/cmd/tui/routes/home.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/routes/session/header.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/routes/session/index.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/spawn.ts create mode 100644 packages/opencode/src/cli/cmd/tui/thread.ts create mode 100644 packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/ui/dialog.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/ui/shimmer.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/ui/toast.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/util/clipboard.ts create mode 100644 packages/opencode/src/cli/cmd/tui/util/editor.ts create mode 100644 packages/opencode/src/cli/cmd/tui/worker.ts create mode 100644 packages/opencode/src/cli/upgrade.ts create mode 100644 packages/opencode/src/util/binary.ts create mode 100644 packages/opencode/src/util/eventloop.ts create mode 100644 packages/opencode/src/util/iife.ts create mode 100644 packages/opencode/src/util/keybind.ts create mode 100644 packages/opencode/src/util/locale.ts create mode 100644 packages/opencode/src/util/rpc.ts create mode 100644 packages/opencode/src/util/signal.ts create mode 100644 packages/opencode/test/keybind.test.ts (limited to 'packages') 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 = { - 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 = {} @@ -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 ", - 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 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 +}) { + // promise to prevent immediate exit + return new Promise((resolve) => { + const routeData: Route | undefined = input.sessionID + ? { + type: "session", + sessionID: input.sessionID, + } + : undefined + + const onExit = async () => { + await input.onExit?.() + resolve() + } + + render( + () => { + return ( + Something went wrong}> + + + + + + + + + + + + + + + + + + + + + + + + + ) + }, + { + 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(() => ) + }, + }, + { + 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(() => ) + }, + }, + { + title: "Switch agent", + value: "agent.list", + keybind: "agent_list", + category: "Agent", + onSelect: () => { + dialog.replace(() => ) + }, + }, + { + 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(() => ) + }, + category: "System", + }, + { + title: "Switch theme", + value: "theme.switch", + onSelect: () => { + dialog.replace(() => ) + }, + category: "System", + }, + { + title: "Help", + value: "help.show", + onSelect: () => { + dialog.replace(() => ) + }, + 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 ( + { + 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" }) + } + }} + > + + + + + + + + + + + + + + open + code + v{Installation.VERSION} + + + {process.cwd().replace(Global.Path.home, "~")} + + + + + tab + + {""} + + {local.agent.current().name.toUpperCase()} + AGENT + + + + + ) +} 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 ", + 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 ( + { + 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 +const ctx = createContext() + +export type CommandOption = DialogSelectOption & { + keybind?: keyof KeybindsConfig +} + +function init() { + const [registrations, setRegistrations] = createSignal[]>([]) + 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(() => ) + return + } + }) + + return {props.children} +} + +function DialogCommand(props: { options: CommandOption[] }) { + const keybind = useKeybind() + return ( + ({ ...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>() + + 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 ( + { + 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() + + 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 ( + { + 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 ( + + + Status + esc + + 0}> + + {Object.keys(sync.data.mcp).length} MCP Servers + + {([key, item]) => ( + + + • + + + {key}{" "} + + + Connected + {(val) => val().error} + Disabled in configuration + + + + + )} + + + + {sync.data.lsp.length > 0 && ( + + {sync.data.lsp.length} LSP Servers + + {(item) => ( + + + • + + + {item.id} {item.root} + + + )} + + + )} + + ) +} 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 ( + { + 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 + + 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 ( + { + 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 ( + + + {(line, index) => ( + + {line} + + {LOGO_RIGHT[index()]} + + + )} + + + {Installation.VERSION} + + + ) +} 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 ( + + + + No matching items + + } + > + {(option, index) => ( + + + {option.display} + + + + {option.description} + + + + )} + + + + ) +} 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 + | Omit + | (Omit & { + 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 + }>({ + 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) => { + 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() + 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 = { + 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 = r)} + anchor={() => anchor} + input={() => input} + setPrompt={(cb) => { + setStore("prompt", produce(cb)) + }} + setExtmark={(partIndex, extmarkId) => { + setStore("extmarkToPartIndex", (map: Map) => { + const newMap = new Map(map) + newMap.set(extmarkId, partIndex) + return newMap + }) + }} + value={store.prompt.input} + fileStyleId={fileStyleId} + agentStyleId={agentStyleId} + promptPartTypeId={() => promptPartTypeId} + /> + (anchor = r)}> + + + + {store.mode === "normal" ? ">" : "!"} + + + +