feat: add opentui interface for opencode

- Add comprehensive TUI interface with React Ink components
- Implement session management, model selection, and command dialogs
- Add theme system with customizable colors and styling
- Integrate with existing opencode server and SDK
- Add todo management and file browsing capabilities
- Include proper TypeScript support and error handling
This commit is contained in:
Dax Raad
2025-09-19 17:21:04 -04:00
parent f1cbdf441c
commit 02848a350c
69 changed files with 3240 additions and 349 deletions
+1 -1
View File
@@ -11,8 +11,8 @@
},
"dependencies": {
"@ibm/plex": "6.4.1",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*",
"@openauthjs/openauth": "catalog:",
"@solidjs/meta": "^0.29.4",
"@solidjs/router": "^0.15.0",
"@solidjs/start": "^1.1.0",
+1 -1
View File
@@ -11,7 +11,7 @@
"drizzle-orm": "0.41.0",
"postgres": "3.4.7",
"stripe": "18.0.0",
"ulid": "3.0.0"
"ulid": "catalog:"
},
"exports": {
"./*": "./src/*"
+16 -4
View File
@@ -234,10 +234,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 })
})
@@ -270,10 +276,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) {
+2
View File
@@ -0,0 +1,2 @@
preload = ["@opentui/solid/preload"]
+12 -4
View File
@@ -4,10 +4,11 @@
"name": "opencode",
"type": "module",
"private": true,
"randomField": "xyz789",
"scripts": {
"typecheck": "tsc --noEmit",
"build": "./script/build.ts",
"dev": "bun run --conditions=development ./src/index.ts"
"dev": "bun run --conditions=development --conditions=browser ./src/index.ts",
"build": "./script/build.ts"
},
"bin": {
"opencode": "./bin/opencode"
@@ -32,15 +33,18 @@
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.15.1",
"@openauthjs/openauth": "0.4.3",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opentui/core": "0.0.0-20250918-7ff2578a",
"@opentui/solid": "0.0.0-20250918-7ff2578a",
"@standard-schema/spec": "1.0.0",
"@zip.js/zip.js": "2.7.62",
"ai": "catalog:",
"chokidar": "4.0.3",
"decimal.js": "10.5.0",
"diff": "8.0.2",
"fuzzysort": "3.1.0",
"gray-matter": "4.0.3",
"hono": "catalog:",
"hono-openapi": "1.0.7",
@@ -48,11 +52,15 @@
"jsonc-parser": "3.3.1",
"minimatch": "10.0.3",
"open": "10.1.2",
"partial-json": "0.1.7",
"remeda": "catalog:",
"solid-js": "catalog:",
"tree-sitter": "0.22.4",
"tree-sitter-bash": "0.23.3",
"tree-sitter-highlight": "1.0.1",
"tree-sitter-typescript": "0.23.2",
"turndown": "7.2.0",
"ulid": "3.0.1",
"ulid": "catalog:",
"vscode-jsonrpc": "8.2.1",
"web-tree-sitter": "0.22.6",
"xdg-basedir": "5.1.0",
@@ -1,147 +0,0 @@
import z from "zod/v4"
import { Auth } from "./index"
import { NamedError } from "../util/error"
export namespace AuthGithubCopilot {
const CLIENT_ID = "Iv1.b507a08c87ecfe98"
const DEVICE_CODE_URL = "https://github.com/login/device/code"
const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"
const COPILOT_API_KEY_URL = "https://api.github.com/copilot_internal/v2/token"
interface DeviceCodeResponse {
device_code: string
user_code: string
verification_uri: string
expires_in: number
interval: number
}
interface AccessTokenResponse {
access_token?: string
error?: string
error_description?: string
}
interface CopilotTokenResponse {
token: string
expires_at: number
refresh_in: number
endpoints: {
api: string
}
}
export async function authorize() {
const deviceResponse = await fetch(DEVICE_CODE_URL, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": "GitHubCopilotChat/0.26.7",
},
body: JSON.stringify({
client_id: CLIENT_ID,
scope: "read:user",
}),
})
const deviceData: DeviceCodeResponse = await deviceResponse.json()
return {
device: deviceData.device_code,
user: deviceData.user_code,
verification: deviceData.verification_uri,
interval: deviceData.interval || 5,
expiry: deviceData.expires_in,
}
}
export async function poll(device_code: string) {
const response = await fetch(ACCESS_TOKEN_URL, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": "GitHubCopilotChat/0.26.7",
},
body: JSON.stringify({
client_id: CLIENT_ID,
device_code,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
}),
})
if (!response.ok) return "failed"
const data: AccessTokenResponse = await response.json()
if (data.access_token) {
// Store the GitHub OAuth token
await Auth.set("github-copilot", {
type: "oauth",
refresh: data.access_token,
access: "",
expires: 0,
})
return "complete"
}
if (data.error === "authorization_pending") return "pending"
if (data.error) return "failed"
return "pending"
}
export async function access() {
const info = await Auth.get("github-copilot")
if (!info || info.type !== "oauth") return
if (info.access && info.expires > Date.now()) return info.access
// Get new Copilot API token
const response = await fetch(COPILOT_API_KEY_URL, {
headers: {
Accept: "application/json",
Authorization: `Bearer ${info.refresh}`,
"User-Agent": "GitHubCopilotChat/0.26.7",
"Editor-Version": "vscode/1.99.3",
"Editor-Plugin-Version": "copilot-chat/0.26.7",
},
})
if (!response.ok) return
const tokenData: CopilotTokenResponse = await response.json()
// Store the Copilot API token
await Auth.set("github-copilot", {
type: "oauth",
refresh: info.refresh,
access: tokenData.token,
expires: tokenData.expires_at * 1000,
})
return tokenData.token
}
export const DeviceCodeError = NamedError.create("DeviceCodeError", z.object({}))
export const TokenExchangeError = NamedError.create(
"TokenExchangeError",
z.object({
message: z.string(),
}),
)
export const AuthenticationError = NamedError.create(
"AuthenticationError",
z.object({
message: z.string(),
}),
)
export const CopilotTokenError = NamedError.create(
"CopilotTokenError",
z.object({
message: z.string(),
}),
)
}
+4 -1
View File
@@ -74,7 +74,10 @@ export namespace BunProc {
// - If .npmrc files exist, Bun will use them automatically
// - If no .npmrc files exist, Bun will default to https://registry.npmjs.org
// - No need to pass --registry flag
log.info("installing package using Bun's default registry resolution", { pkg, version })
log.info("installing package using Bun's default registry resolution", {
pkg,
version,
})
await BunProc.run(args, {
cwd: Global.Path.cache,
+1 -1
View File
@@ -80,7 +80,7 @@ export const AuthLoginCommand = cmd({
UI.empty()
prompts.intro("Add credential")
if (args.url) {
const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json())
const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json() as any)
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
const proc = Bun.spawn({
cmd: wellknown.auth.command,
+2 -2
View File
@@ -1,5 +1,4 @@
import path from "path"
import { $ } from "bun"
import { exec } from "child_process"
import * as prompts from "@clack/prompts"
import { map, pipe, sortBy, values } from "remeda"
@@ -7,6 +6,7 @@ import { UI } from "../ui"
import { cmd } from "./cmd"
import { ModelsDev } from "../../provider/models"
import { Instance } from "../../project/instance"
import { $ } from "bun"
const WORKFLOW_FILE = ".github/workflows/opencode.yml"
@@ -196,7 +196,7 @@ export const GithubInstallCommand = cmd({
`https://api.opencode.ai/get_github_app_installation?owner=${app.owner}&repo=${app.repo}`,
)
.then((res) => res.json())
.then((data) => data.installation)
.then((data: any) => data.installation)
}
}
@@ -0,0 +1,19 @@
import { Theme } from "../context/theme"
export const SplitBorder = {
border: ["left" as const, "right" as const],
borderColor: Theme.border,
customBorderChars: {
topLeft: "",
bottomLeft: "",
vertical: "┃",
topRight: "",
bottomRight: "",
horizontal: "",
bottomT: "",
topT: "",
cross: "",
leftT: "",
rightT: "",
},
}
@@ -0,0 +1,52 @@
import { useDialog } from "../ui/dialog"
import { DialogModel } from "./dialog-model"
import { DialogSelect } from "../ui/dialog-select"
import { useRoute } from "../context/route"
import { DialogSessionList } from "./dialog-session-list"
export function DialogCommand() {
const dialog = useDialog()
const route = useRoute()
return (
<DialogSelect
title="Commands"
options={[
{
title: "Switch model",
value: "switch-model",
category: "Agent",
onSelect: () => {
dialog.replace(() => <DialogModel />)
},
},
{
title: "Switch session",
value: "switch-session",
category: "Session",
onSelect: () => {
dialog.replace(() => <DialogSessionList />)
},
},
{
title: "New session",
value: "new-session",
category: "Session",
onSelect: () => {
route.navigate({
type: "home",
})
dialog.clear()
},
},
{
title: "Share session",
value: "share-session",
category: "Session",
onSelect: () => {
console.log("share session")
},
},
]}
/>
)
}
@@ -0,0 +1,60 @@
import { createMemo } from "solid-js"
import { useLocal } from "../context/local"
import { useSync } from "../context/sync"
import { map, pipe, flatMap, entries, filter, isDeepEqual } from "remeda"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
export function DialogModel() {
const local = useLocal()
const sync = useSync()
const dialog = useDialog()
const options = createMemo(() => [
...local.model.recent().map((item) => {
const provider = sync.data.provider.find((x) => x.id === item.providerID)!
const model = provider.models[item.modelID]
return {
key: item,
value: {
providerID: provider.id,
modelID: model.id,
},
title: model.name ?? item.modelID,
description: provider.name,
category: "Recent",
}
}),
...pipe(
sync.data.provider,
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) => !local.model.recent().find((y) => isDeepEqual(y, x.value))),
),
),
),
])
return (
<DialogSelect
title="Select model"
current={local.model.current()}
options={options()}
onSelect={(option) => {
local.model.set(option.value, { recent: true })
dialog.clear()
}}
/>
)
}
@@ -0,0 +1,44 @@
import { useDialog } from "../ui/dialog"
import { DialogSelect } from "../ui/dialog-select"
import { useRoute } from "../context/route"
import { useSync } from "../context/sync"
import { createMemo, onMount } from "solid-js"
export function DialogSessionList() {
const dialog = useDialog()
const sync = useSync()
const route = useRoute()
const options = createMemo(() => {
const today = new Date().toDateString()
return sync.data.session.map((x) => {
let category = new Date(x.time.created).toDateString()
if (category === today) {
category = "Today"
}
return {
title: x.title,
value: x.id,
category,
}
})
})
onMount(() => {
dialog.setSize("large")
})
return (
<DialogSelect
title="Sessions"
options={options()}
onSelect={(option) => {
route.navigate({
type: "session",
sessionID: option.value,
})
dialog.clear()
}}
/>
)
}
@@ -0,0 +1,46 @@
import { createMemo, createResource } from "solid-js"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useSDK } from "../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.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()
}}
/>
)
}
@@ -0,0 +1,352 @@
import { InputRenderable, TextAttributes, BoxRenderable, type ParsedKey } from "@opentui/core"
import { createEffect, createMemo, createResource, For, Match, onMount, Switch } from "solid-js"
import { useLocal } from "../context/local"
import { Theme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { SplitBorder } from "./border"
import { useSDK } from "../context/sdk"
import { useRoute } from "../context/route"
import { useSync } from "../context/sync"
import { Identifier } from "../../../../id/id"
import { createStore, produce } from "solid-js/store"
import type { FilePart } from "@opencode-ai/sdk"
import { Instance } from "../../../../project/instance"
import fuzzysort from "fuzzysort"
export type PromptProps = {
sessionID?: string
}
type Prompt = {
input: string
parts: Omit<FilePart, "id" | "messageID" | "sessionID">[]
}
export function Prompt(props: PromptProps) {
let input: InputRenderable
let anchor: BoxRenderable
let autocomplete: AutocompleteRef
const dialog = useDialog()
const local = useLocal()
const sdk = useSDK()
const route = useRoute()
const sync = useSync()
const [store, setStore] = createStore<Prompt>({
input: "",
parts: [],
})
const messages = createMemo(() => {
if (!props.sessionID) return []
return sync.data.message[props.sessionID] ?? []
})
const working = createMemo(() => {
const last = messages()[messages().length - 1]
if (!last) return false
if (last.role === "user") return true
return !last.time.completed
})
createEffect(() => {
if (dialog.stack.length === 0 && input) input.focus()
if (dialog.stack.length > 0) input.blur()
})
return (
<>
<Autocomplete
ref={(r) => (autocomplete = r)}
anchor={() => anchor}
input={() => input}
setPrompt={(cb) => {
setStore(produce(cb))
input.cursorPosition = store.input.length
}}
value={store.input}
/>
<box ref={(r) => (anchor = r)}>
<box flexDirection="row" {...SplitBorder}>
<box backgroundColor={Theme.backgroundElement} width={3} justifyContent="center" alignItems="center">
<text attributes={TextAttributes.BOLD} fg={Theme.primary}>
{">"}
</text>
</box>
<box paddingTop={1} paddingBottom={2} backgroundColor={Theme.backgroundElement} flexGrow={1}>
<input
onInput={(value) => {
let diff = value.length - store.input.length
setStore(
produce((draft) => {
draft.input = value
for (let i = 0; i < draft.parts.length; i++) {
const part = draft.parts[i]
if (!part.source) continue
if (part.source.text.start >= input.cursorPosition) {
part.source.text.start += diff
part.source.text.end += diff
}
const sliced = draft.input.slice(part.source.text.start, part.source.text.end)
if (sliced != part.source.text.value && diff < 0) {
diff -= part.source.text.value.length
draft.input =
draft.input.slice(0, part.source.text.start) + draft.input.slice(part.source.text.end)
draft.parts.splice(i, 1)
input.cursorPosition = Math.max(0, part.source.text.start - 1)
i--
}
}
}),
)
autocomplete.onInput(value)
}}
value={store.input}
onKeyDown={(e) => {
autocomplete.onKeyDown(e)
const old = input.cursorPosition
setTimeout(() => {
const position = input.cursorPosition
const direction = Math.sign(old - position)
for (const part of store.parts) {
if (part.source && part.source.type === "file") {
if (position >= part.source.text.start && position < part.source.text.end) {
if (direction === 1) {
input.cursorPosition = Math.max(0, part.source.text.start - 1)
}
if (direction === -1) {
input.cursorPosition = part.source.text.end
}
}
}
}
}, 0)
}}
onSubmit={async () => {
if (autocomplete.visible) return
if (!store.input) return
const sessionID = props.sessionID
? props.sessionID
: await (async () => {
const sessionID = await sdk.session.create({}).then((x) => x.data!.id)
route.navigate({
type: "session",
sessionID,
})
return sessionID
})()
const messageID = Identifier.ascending("message")
const input = store.input
const parts = store.parts
setStore({
input: "",
parts: [],
})
await sdk.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: input,
},
...parts.map((x) => ({
id: Identifier.ascending("part"),
...x,
})),
],
},
})
}}
ref={(r) => (input = r)}
onMouseDown={(r) => r.target?.focus()}
focusedBackgroundColor={Theme.backgroundElement}
cursorColor={Theme.primary}
backgroundColor={Theme.backgroundElement}
/>
</box>
<box backgroundColor={Theme.backgroundElement} width={1} justifyContent="center" alignItems="center"></box>
</box>
<box paddingLeft={2} paddingRight={1} flexDirection="row" justifyContent="space-between">
<Switch>
<Match when={working()}>
<text>working...</text>
</Match>
<Match when={true}>
<text>
enter <span style={{ fg: Theme.textMuted }}>send</span>
</text>
</Match>
</Switch>
<text>
<span style={{ fg: Theme.textMuted }}>{local.model.parsed().provider}</span>{" "}
<span style={{ bold: true }}>{local.model.parsed().model}</span>
</text>
</box>
</box>
</>
)
}
type AutocompleteRef = {
onInput: (value: string) => void
onKeyDown: (e: ParsedKey) => void
visible: boolean
}
function Autocomplete(props: {
value: string
setPrompt: (input: (prompt: Prompt) => void) => void
anchor: () => BoxRenderable
input: () => InputRenderable
ref: (ref: AutocompleteRef) => void
}) {
const sdk = useSDK()
const [store, setStore] = createStore({
index: 0,
selected: 0,
visible: false,
position: { x: 0, y: 0, width: 0 },
})
const filter = createMemo(() => {
if (!store.visible) return ""
return props.value.substring(store.index + 1)
})
const [files] = createResource(
() => [filter()],
async () => {
if (!store.visible) return []
const result = await sdk.find.files({
query: {
query: filter(),
},
})
if (result.error) return []
return result.data ?? []
},
{
initialValue: [],
},
)
const options = createMemo(() => {
const mixed = [...files().map((x) => ({ type: "file", value: x }))]
const result = fuzzysort.go(filter(), mixed, {
keys: ["value"],
})
return result.map((arr) => arr.obj)
})
createEffect(() => {
filter()
setStore("selected", 0)
})
function move(direction: -1 | 1) {
if (!store.visible) return
let next = store.selected + direction
if (next < 0) next = files().length - 1
if (next >= files().length) next = 0
setStore("selected", next)
}
function show() {
setStore({
visible: true,
index: props.input().cursorPosition,
position: {
x: props.anchor().x,
y: props.anchor().y,
width: props.anchor().width,
},
})
}
function hide() {
setStore("visible", false)
}
onMount(() => {
props.ref({
get visible() {
return store.visible
},
onInput(value: string) {
if (value.length <= store.index) hide()
},
onKeyDown(e: ParsedKey) {
if (store.visible) {
if (e.name === "up") move(-1)
if (e.name === "down") move(1)
if (e.name === "escape") hide()
if (e.name === "return") {
const file = files()[store.selected]
if (!file) return
const part: Prompt["parts"][number] = {
type: "file",
mime: "text/plain",
filename: file,
url: `file://${Instance.directory}/${file}`,
source: {
type: "file",
text: {
start: store.index,
end: store.index + file.length + 1,
value: "@" + file,
},
path: file,
},
}
props.setPrompt((draft) => {
const append = "@" + file + " "
if (store.index === 0) draft.input = append
if (store.index > 0) draft.input = draft.input.slice(0, store.index) + append
draft.parts.push(part)
})
setTimeout(() => hide(), 0)
}
}
if (!store.visible && e.name === "@") {
const last = props.value.at(-1)
if (last === " " || last === undefined) {
show()
}
}
},
})
})
return (
<box
visible={store.visible}
position="absolute"
top={store.position.y - 10}
left={store.position.x}
width={store.position.width}
zIndex={100}
{...SplitBorder}
>
<box backgroundColor={Theme.backgroundElement} height={10}>
<For each={options()}>
{(option, index) => (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={index() === store.selected ? Theme.primary : undefined}
>
<text fg={index() === store.selected ? Theme.background : Theme.text}>{option.value}</text>
</box>
)}
</For>
</box>
</box>
)
}
@@ -0,0 +1,144 @@
import { createStore } from "solid-js/store"
import { batch, createContext, createEffect, createMemo, useContext, type ParentProps } from "solid-js"
import { useSync } from "./sync"
import { Theme } from "./theme"
import { uniqueBy } from "remeda"
import path from "path"
import { Global } from "../../../../global"
function init() {
const sync = useSync()
const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent"))
const agent = (() => {
const [store, setStore] = createStore<{
current: string
}>({
current: agents()[0].name,
})
return {
current() {
return agents().find((x) => x.name === store.current)!
},
move(direction: 1 | -1) {
let next = agents().findIndex((x) => x.name === store.current) + direction
if (next < 0) next = agents().length - 1
if (next >= agents().length) next = 0
const value = agents()[next]
setStore("current", value.name)
if (value.model)
model.set({
providerID: value.model.providerID,
modelID: value.model.modelID,
})
},
color(name: string) {
const index = agents().findIndex((x) => x.name === name)
const colors = [Theme.secondary, Theme.accent, Theme.success, Theme.warning, Theme.primary, Theme.error]
return colors[index % colors.length]
},
}
})()
const model = (() => {
const [store, setStore] = createStore<{
model: Record<
string,
{
providerID: string
modelID: string
}
>
recent: {
providerID: string
modelID: string
}[]
}>({
model: {},
recent: [],
})
const file = Bun.file(path.join(Global.Path.state, "model.json"))
file
.json()
.then((x) => {
setStore("recent", x.recent)
})
.catch(() => {})
createEffect(() => {
Bun.write(
file,
JSON.stringify({
recent: store.recent,
}),
)
})
const fallback = createMemo(() => {
if (store.recent.length) return store.recent[0]
const provider = sync.data.provider[0]
const model = Object.values(provider.models)[0]
return {
providerID: provider.id,
modelID: model.id,
}
})
const current = createMemo(() => {
const a = agent.current()
return store.model[agent.current().name] ?? (a.model ? a.model : fallback())
})
return {
current,
recent() {
return store.recent
},
parsed: createMemo(() => {
const value = current()
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(() => {
setStore("model", agent.current().name, model)
if (options?.recent) {
const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
if (uniq.length > 5) uniq.pop()
setStore("recent", uniq)
}
})
},
}
})()
const result = {
model,
agent,
}
return result
}
type LocalContext = ReturnType<typeof init>
const ctx = createContext<LocalContext>()
export function LocalProvider(props: ParentProps) {
const value = init()
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
}
export function useLocal() {
const value = useContext(ctx)
if (!value) {
throw new Error("useLocal must be used within a LocalProvider")
}
return value
}
@@ -0,0 +1,54 @@
import { createStore } from "solid-js/store"
import { createContext, useContext, type ParentProps } from "solid-js"
type Route =
| {
type: "home"
}
| {
type: "session"
sessionID: string
}
function init() {
const [store, setStore] = createStore<Route>(
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 init>
const ctx = createContext<RouteContext>()
export function RouteProvider(props: ParentProps) {
const value = init()
// @ts-ignore
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
}
export function useRoute() {
const value = useContext(ctx)
if (!value) {
throw new Error("useRoute must be used within a RouteProvider")
}
return value
}
export function useRouteData<T extends Route["type"]>(type: T) {
const route = useRoute()
return route.data as Extract<Route, { type: typeof type }>
}
@@ -0,0 +1,32 @@
import { createContext, useContext, type ParentProps } from "solid-js"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { Server } from "../../../../server/server"
function init() {
const client = createOpencodeClient({
baseUrl: "http://localhost:4096",
// @ts-ignore
fetch: async (a) => {
// @ts-ignore
return Server.App().fetch(a)
},
})
return client
}
type SDKContext = ReturnType<typeof init>
const ctx = createContext<SDKContext>()
export function SDKProvider(props: ParentProps) {
const value = init()
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
}
export function useSDK() {
const value = useContext(ctx)
if (!value) {
throw new Error("useSDK must be used within a SDKProvider")
}
return value
}
@@ -0,0 +1,156 @@
import type { Message, Agent, Provider, Session, Part, Config, Todo } from "@opencode-ai/sdk"
import { createStore, produce, reconcile } from "solid-js/store"
import { useSDK } from "./sdk"
import { createContext, Show, useContext, type ParentProps } from "solid-js"
import { Binary } from "../../../../util/binary"
function init() {
const [store, setStore] = createStore<{
ready: boolean
provider: Provider[]
agent: Agent[]
config: Config
session: Session[]
todo: {
[sessionID: string]: Todo[]
}
message: {
[sessionID: string]: Message[]
}
part: {
[messageID: string]: Part[]
}
}>({
config: {},
ready: false,
agent: [],
provider: [],
session: [],
todo: {},
message: {},
part: {},
})
const sdk = useSDK()
sdk.event.subscribe().then(async (events) => {
for await (const event of events.stream) {
switch (event.type) {
case "todo.updated":
setStore("todo", event.properties.sessionID, event.properties.todos)
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.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
}
}
}
})
Promise.all([
sdk.config.providers().then((x) => setStore("provider", x.data!.providers)),
sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
sdk.session.list().then((x) => setStore("session", x.data ?? [])),
sdk.config.get().then((x) => setStore("config", x.data!)),
]).then(() => setStore("ready", true))
return {
data: store,
set: setStore,
session: {
get(sessionID: string) {
const match = Binary.search(store.session, sessionID, (s) => s.id)
if (match.found) return store.session[match.index]
return undefined
},
async sync(sessionID: string) {
const [session, messages, todo] = await Promise.all([
sdk.session.get({ path: { id: sessionID } }),
sdk.session.messages({ path: { id: sessionID } }),
sdk.session.todo({ path: { id: sessionID } }),
])
setStore(
produce((draft) => {
const match = Binary.search(draft.session, sessionID, (s) => s.id)
draft.session[match.index] = 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
}
}),
)
},
},
}
}
type SyncContext = ReturnType<typeof init>
const ctx = createContext<SyncContext>()
export function SyncProvider(props: ParentProps) {
const value = init()
return (
<Show when={value.data.ready}>
<ctx.Provider value={value}>{props.children}</ctx.Provider>
</Show>
)
}
export function useSync() {
const value = useContext(ctx)
if (!value) {
throw new Error("useSync must be used within a SyncProvider")
}
return value
}
@@ -0,0 +1,260 @@
const OPENCODE_THEME = {
primary: {
dark: "#fab283",
light: "#3b7dd8",
},
secondary: {
dark: "#5c9cf5",
light: "#7b5bb6",
},
accent: {
dark: "#9d7cd8",
light: "#d68c27",
},
error: {
dark: "#e06c75",
light: "#d1383d",
},
warning: {
dark: "#f5a742",
light: "#d68c27",
},
success: {
dark: "#7fd88f",
light: "#3d9a57",
},
info: {
dark: "#56b6c2",
light: "#318795",
},
text: {
dark: "#eeeeee",
light: "#1a1a1a",
},
textMuted: {
dark: "#808080",
light: "#8a8a8a",
},
background: {
dark: "#0a0a0a",
light: "#ffffff",
},
backgroundPanel: {
dark: "#141414",
light: "#fafafa",
},
backgroundElement: {
dark: "#1e1e1e",
light: "#f5f5f5",
},
border: {
dark: "#484848",
light: "#b8b8b8",
},
borderActive: {
dark: "#606060",
light: "#a0a0a0",
},
borderSubtle: {
dark: "#3c3c3c",
light: "#d4d4d4",
},
diffAdded: {
dark: "#4fd6be",
light: "#1e725c",
},
diffRemoved: {
dark: "#c53b53",
light: "#c53b53",
},
diffContext: {
dark: "#828bb8",
light: "#7086b5",
},
diffHunkHeader: {
dark: "#828bb8",
light: "#7086b5",
},
diffHighlightAdded: {
dark: "#b8db87",
light: "#4db380",
},
diffHighlightRemoved: {
dark: "#e26a75",
light: "#f52a65",
},
diffAddedBg: {
dark: "#20303b",
light: "#d5e5d5",
},
diffRemovedBg: {
dark: "#37222c",
light: "#f7d8db",
},
diffContextBg: {
dark: "#141414",
light: "#fafafa",
},
diffLineNumber: {
dark: "#1e1e1e",
light: "#f5f5f5",
},
diffAddedLineNumberBg: {
dark: "#1b2b34",
light: "#c5d5c5",
},
diffRemovedLineNumberBg: {
dark: "#2d1f26",
light: "#e7c8cb",
},
markdownText: {
dark: "#eeeeee",
light: "#1a1a1a",
},
markdownHeading: {
dark: "#9d7cd8",
light: "#d68c27",
},
markdownLink: {
dark: "#fab283",
light: "#3b7dd8",
},
markdownLinkText: {
dark: "#56b6c2",
light: "#318795",
},
markdownCode: {
dark: "#7fd88f",
light: "#3d9a57",
},
markdownBlockQuote: {
dark: "#e5c07b",
light: "#b0851f",
},
markdownEmph: {
dark: "#e5c07b",
light: "#b0851f",
},
markdownStrong: {
dark: "#f5a742",
light: "#d68c27",
},
markdownHorizontalRule: {
dark: "#808080",
light: "#8a8a8a",
},
markdownListItem: {
dark: "#fab283",
light: "#3b7dd8",
},
markdownListEnumeration: {
dark: "#56b6c2",
light: "#318795",
},
markdownImage: {
dark: "#fab283",
light: "#3b7dd8",
},
markdownImageText: {
dark: "#56b6c2",
light: "#318795",
},
markdownCodeBlock: {
dark: "#eeeeee",
light: "#1a1a1a",
},
syntaxComment: {
dark: "#808080",
light: "#8a8a8a",
},
syntaxKeyword: {
dark: "#9d7cd8",
light: "#d68c27",
},
syntaxFunction: {
dark: "#fab283",
light: "#3b7dd8",
},
syntaxVariable: {
dark: "#e06c75",
light: "#d1383d",
},
syntaxString: {
dark: "#7fd88f",
light: "#3d9a57",
},
syntaxNumber: {
dark: "#f5a742",
light: "#d68c27",
},
syntaxType: {
dark: "#e5c07b",
light: "#b0851f",
},
syntaxOperator: {
dark: "#56b6c2",
light: "#318795",
},
syntaxPunctuation: {
dark: "#eeeeee",
light: "#1a1a1a",
},
} as const
type Theme = {
primary: string
secondary: string
accent: string
error: string
warning: string
success: string
info: string
text: string
textMuted: string
background: string
backgroundPanel: string
backgroundElement: string
border: string
borderActive: string
borderSubtle: string
diffAdded: string
diffRemoved: string
diffContext: string
diffHunkHeader: string
diffHighlightAdded: string
diffHighlightRemoved: string
diffAddedBg: string
diffRemovedBg: string
diffContextBg: string
diffLineNumber: string
diffAddedLineNumberBg: string
diffRemovedLineNumberBg: string
markdownText: string
markdownHeading: {}
markdownLink: string
markdownLinkText: string
markdownCode: string
markdownBlockQuote: string
markdownEmph: string
markdownStrong: string
markdownHorizontalRule: string
markdownListItem: string
markdownListEnumeration: {}
markdownImage: string
markdownImageText: string
markdownCodeBlock: string
syntaxComment: string
syntaxKeyword: string
syntaxFunction: string
syntaxVariable: string
syntaxString: string
syntaxNumber: string
syntaxType: string
syntaxOperator: string
syntaxPunctuation: string
}
export const Theme = Object.entries(OPENCODE_THEME).reduce((acc, [key, value]) => {
acc[key as keyof Theme] = value.dark
return acc
}, {} as Theme)
@@ -0,0 +1,58 @@
import { Installation } from "../../../installation"
import { Theme } from "./context/theme"
import { TextAttributes } from "@opentui/core"
import { Prompt } from "./component/prompt"
export function Home() {
return (
<box flexGrow={1} justifyContent="center" alignItems="center">
<box>
<Logo />
<box paddingTop={2}>
<HelpRow slash="new">new session</HelpRow>
<HelpRow slash="help">show help</HelpRow>
<HelpRow slash="share">share session</HelpRow>
<HelpRow slash="models">list models</HelpRow>
<HelpRow slash="agents">list agents</HelpRow>
</box>
</box>
<box paddingTop={3} minWidth={75}>
<Prompt />
</box>
</box>
)
}
function HelpRow(props: { children: string; slash: string }) {
return (
<text>
<span style={{ bold: true, fg: Theme.primary }}>/{props.slash.padEnd(10, " ")}</span>
<span>{props.children.padEnd(15, " ")} </span>
<span style={{ fg: Theme.textMuted }}>ctrl+x n</span>
</text>
)
}
function Logo() {
return (
<box>
<box flexDirection="row">
<text fg={Theme.textMuted}>{"█▀▀█ █▀▀█ █▀▀ █▀▀▄"}</text>
<text fg={Theme.text} attributes={TextAttributes.BOLD}>
{" █▀▀ █▀▀█ █▀▀▄ █▀▀"}
</text>
</box>
<box flexDirection="row">
<text fg={Theme.textMuted}>{`█░░█ █░░█ █▀▀ █░░█`}</text>
<text fg={Theme.text}>{` █░░ █░░█ █░░█ █▀▀`}</text>
</box>
<box flexDirection="row">
<text fg={Theme.textMuted}>{`▀▀▀▀ █▀▀▀ ▀▀▀ ▀ ▀`}</text>
<text fg={Theme.text}>{` ▀▀▀ ▀▀▀▀ ▀▀▀ ▀▀▀`}</text>
</box>
<box flexDirection="row" justifyContent="flex-end">
<text fg={Theme.textMuted}>{Installation.VERSION}</text>
</box>
</box>
)
}
@@ -0,0 +1,121 @@
import { cmd } from "../cmd"
import { render, useKeyHandler, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { TextAttributes } from "@opentui/core"
import { RouteProvider, useRoute } from "./context/route"
import { Home } from "./home"
import { Switch, Match, createEffect } from "solid-js"
import { Theme } from "./context/theme"
import { Installation } from "../../../installation"
import { Global } from "../../../global"
import { DialogProvider, useDialog } from "./ui/dialog"
import { bootstrap } from "../../bootstrap"
import { SDKProvider } from "./context/sdk"
import { SyncProvider } from "./context/sync"
import { LocalProvider, useLocal } from "./context/local"
import { DialogModel } from "./component/dialog-model"
import { DialogCommand } from "./component/dialog-command"
import { Session } from "./session"
export const OpentuiCommand = cmd({
command: "opentui",
describe: "print hello",
handler: async () => {
await bootstrap(process.cwd(), async () => {
await render(
() => (
<RouteProvider>
<SDKProvider>
<SyncProvider>
<LocalProvider>
<DialogProvider>
<App />
</DialogProvider>
</LocalProvider>
</SyncProvider>
</SDKProvider>
</RouteProvider>
),
{
targetFps: 60,
gatherStats: false,
},
)
})
},
})
function App() {
const route = useRoute()
const dimensions = useTerminalDimensions()
const renderer = useRenderer()
const dialog = useDialog()
const local = useLocal()
useKeyHandler(async (evt) => {
if (evt.name === "tab") {
local.agent.move(evt.shift ? -1 : 1)
return
}
if (evt.ctrl && evt.name === "p") {
dialog.replace(() => <DialogCommand />)
return
}
if (evt.meta && evt.name === "t") {
renderer.toggleDebugOverlay()
return
}
if (evt.meta && evt.name === "d") {
renderer.console.toggle()
return
}
if (evt.meta && evt.name === "m") {
dialog.replace(() => <DialogModel />)
return
}
})
createEffect(() => {
console.log(JSON.stringify(route.data))
})
return (
<box width={dimensions().width} height={dimensions().height} backgroundColor={Theme.background}>
<box flexDirection="column" flexGrow={1}>
<Switch>
<Match when={route.data.type === "home"}>
<Home />
</Match>
<Match when={route.data.type === "session"}>
<Session />
</Match>
</Switch>
</box>
<box height={1} backgroundColor={Theme.backgroundPanel} flexDirection="row" justifyContent="space-between">
<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">
<text paddingRight={1} fg={Theme.textMuted}>
tab
</text>
<text fg={local.agent.color(local.agent.current().name)}></text>
<text bg={local.agent.color(local.agent.current().name)} fg={Theme.background}>
{" "}
<span style={{ bold: true }}>{local.agent.current().name.toUpperCase()}</span>
<span> AGENT </span>
</text>
</box>
</box>
</box>
)
}
@@ -0,0 +1,457 @@
import { createEffect, createMemo, For, Match, Show, Switch, type Component } from "solid-js"
import { Dynamic } from "solid-js/web"
import path from "path"
import { useRouteData } from "./context/route"
import { useSync } from "./context/sync"
import { SplitBorder } from "./component/border"
import { Theme } from "./context/theme"
import { hastToStyledText, RGBA, ScrollBoxRenderable, SyntaxStyle } from "@opentui/core"
import { Prompt } from "./component/prompt"
import type { AssistantMessage, Part, ToolPart, UserMessage } from "@opencode-ai/sdk"
import type { TextPart } from "ai"
import { useLocal } from "./context/local"
import { Locale } from "../../../util/locale"
import type { Tool } from "../../../tool/tool"
import { highlightHast, Language } from "tree-sitter-highlight"
import type { ReadTool } from "../../../tool/read"
import type { WriteTool } from "../../../tool/write"
import { BashTool } from "../../../tool/bash"
import type { GlobTool } from "../../../tool/glob"
import { Instance } from "../../../project/instance"
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, type JSX } from "@opentui/solid"
export function Session() {
const route = useRouteData("session")
const sync = useSync()
const session = createMemo(() => sync.session.get(route.sessionID)!)
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
const todo = createMemo(() => sync.data.todo[route.sessionID] ?? [])
let scroll: ScrollBoxRenderable
createEffect(() => sync.session.sync(route.sessionID))
useKeyboard((evt) => {
if (evt.name === "pageup") scroll.scrollBy(-scroll.height)
if (evt.name === "pagedown") scroll.scrollBy(scroll.height)
})
return (
<box paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={2} flexGrow={1} maxHeight="100%">
<Show when={session()}>
<box paddingLeft={1} paddingRight={1} {...SplitBorder} borderColor={Theme.backgroundElement}>
<text>
<span style={{ bold: true, fg: Theme.accent }}>#</span>{" "}
<span style={{ bold: true }}>{session().title}</span>
</text>
<box flexDirection="row">
<Switch>
<Match when={session().share?.url}>
<text fg={Theme.textMuted}>{session().share!.url}</text>
</Match>
<Match when={true}>
<text>
/share <span style={{ fg: Theme.textMuted }}>to create a shareable link</span>
</text>
</Match>
</Switch>
</box>
</box>
<scrollbox
ref={(r: any) => (scroll = r)}
scrollbarOptions={{ visible: false }}
stickyScroll={true}
stickyStart="bottom"
paddingTop={1}
paddingBottom={1}
contentOptions={{
gap: 1,
}}
>
<For each={messages()}>
{(message) => (
<Switch>
<Match when={message.role === "user"}>
<UserMessage message={message as UserMessage} parts={sync.data.part[message.id] ?? []} />
</Match>
<Match when={message.role === "assistant"}>
<AssistantMessage message={message as AssistantMessage} parts={sync.data.part[message.id] ?? []} />
</Match>
</Switch>
)}
</For>
</scrollbox>
<Show when={todo().length > 0}>
<box paddingBottom={1}>
<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 flexShrink={0}>
<Prompt sessionID={route.sessionID} />
</box>
</Show>
</box>
)
}
function UserMessage(props: { message: UserMessage; parts: Part[] }) {
const text = createMemo(() => props.parts.flatMap((x) => (x.type === "text" && !x.synthetic ? [x] : []))[0])
const sync = useSync()
return (
<box
border={["left"]}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={Theme.backgroundPanel}
customBorderChars={SplitBorder.customBorderChars}
borderColor={Theme.secondary}
>
<text>{text()?.text}</text>
<text>
{sync.data.config.username ?? "You"}{" "}
<span style={{ fg: Theme.textMuted }}>({Locale.time(props.message.time.created)})</span>
</text>
</box>
)
}
function AssistantMessage(props: { message: AssistantMessage; parts: Part[] }) {
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>
)
}
const PART_MAPPING = {
text: TextPart,
tool: ToolPart,
}
function TextPart(props: { part: TextPart; message: AssistantMessage }) {
const sync = useSync()
const agent = createMemo(() => sync.data.agent.find((x) => x.name === props.message.mode)!)
const local = useLocal()
return (
<box paddingLeft={3}>
<text>{props.part.text.trim()}</text>
<text>
<span style={{ fg: local.agent.color(agent().name) }}>{Locale.titlecase(agent().name)}</span>{" "}
<span style={{ fg: Theme.textMuted }}>{props.message.providerID + "/" + props.message.modelID}</span>
</text>
</box>
)
}
// Pending messages moved to individual tool pending functions
function ToolPart(props: { part: ToolPart; message: AssistantMessage }) {
const component = createMemo(() => {
const ready = ToolRegistry.ready(props.part.tool)
if (!ready) return
const metadata = props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {})
const input = props.part.state.input
return (
<Dynamic
component={ready}
input={input}
metadata={metadata}
output={props.part.state.status === "completed" ? props.part.state.output : undefined}
/>
)
})
return (
<Show when={component()}>
<box paddingLeft={3}>{component()}</box>
</Show>
)
}
type ToolProps<T extends Tool.Info> = {
input: Partial<Tool.InferParameters<T>>
metadata: Partial<Tool.InferMetadata<T>>
output?: string
}
const ToolRegistry = (() => {
const state: Record<string, { name: string; ready?: Component<ToolProps<any>> }> = {}
function register<T extends Tool.Info>(input: { name: string; ready?: Component<ToolProps<T>> }) {
state[input.name] = input
return input
}
return {
register,
ready(name: string) {
return state[name]?.ready
},
}
})()
function ToolTitle(props: { fallback: string; when: any; icon: string; children: JSX.Element }) {
return (
<text 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",
ready(props) {
return (
<>
<ToolTitle icon="#" fallback="Writing command..." when={props.input.command}>
{props.input.description}
</ToolTitle>
<Show when={props.input.command}>
<box>
<text fg={Theme.textMuted}>$ {props.input.command}</text>
<box>
<text fg={Theme.textMuted}>{props.output?.trim()}</text>
</box>
</box>
</Show>
</>
)
},
})
const syntax = new SyntaxStyle({
keyword: { fg: RGBA.fromHex(Theme.syntaxKeyword), bold: true },
string: { fg: RGBA.fromHex(Theme.syntaxString) },
comment: { fg: RGBA.fromHex(Theme.syntaxComment), italic: true },
number: { fg: RGBA.fromHex(Theme.syntaxNumber) },
function: { fg: RGBA.fromHex(Theme.syntaxFunction) },
type: { fg: RGBA.fromHex(Theme.syntaxType) },
operator: { fg: RGBA.fromHex(Theme.syntaxOperator) },
variable: { fg: RGBA.fromHex(Theme.syntaxVariable) },
bracket: { fg: RGBA.fromHex(Theme.syntaxPunctuation) },
punctuation: { fg: RGBA.fromHex(Theme.syntaxPunctuation) },
default: { fg: RGBA.fromHex(Theme.syntaxVariable) },
})
ToolRegistry.register<typeof ReadTool>({
name: "read",
ready(props) {
return (
<>
<ToolTitle icon="→" fallback="Reading file..." when={props.input.filePath}>
Read {normalizePath(props.input.filePath!)}
</ToolTitle>
</>
)
},
})
ToolRegistry.register<typeof WriteTool>({
name: "write",
ready(props) {
const lines = createMemo(() => {
return props.input.content?.split("\n") ?? []
})
const code = createMemo(() => {
if (!props.input.content) return ""
const text = props.input.content
const hast = highlightHast(text, Language.TS)
const styled = hastToStyledText(hast as any, syntax)
return styled
})
const numbers = createMemo(() => {
const pad = lines().length.toString().length
return lines()
.map((_, index) => index + 1)
.map((x) => x.toString().padStart(pad, " "))
})
return (
<box gap={1}>
<ToolTitle icon="←" fallback="Preparing write..." when={props.input.filePath}>
Wrote {props.input.filePath}
</ToolTitle>
<box flexDirection="row">
<box>
<For each={numbers()}>{(value) => <text style={{ fg: Theme.textMuted }}>{value}</text>}</For>
</box>
<box paddingLeft={1}>
<text>{code()}</text>
</box>
</box>
</box>
)
},
})
ToolRegistry.register<typeof GlobTool>({
name: "glob",
ready(props) {
return (
<>
<ToolTitle icon="✱" fallback="Finding files..." when={props.input.pattern}>
Glob "{props.input.pattern}" <Show when={props.metadata.count}>({props.metadata.count} matches)</Show>
</ToolTitle>
</>
)
},
})
ToolRegistry.register<typeof GrepTool>({
name: "grep",
ready(props) {
return (
<>
<ToolTitle icon="%" fallback="Searching content..." when={props.input.pattern}>
Grep "{props.input.pattern}" <Show when={props.metadata.matches}>({props.metadata.matches} matches)</Show>
</ToolTitle>
</>
)
},
})
ToolRegistry.register<typeof ListTool>({
name: "list",
ready(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",
ready(props) {
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",
ready(props) {
return (
<>
<ToolTitle icon="%" fallback="Fetching from the web..." when={(props.input as any).url}>
WebFetch {(props.input as any).url}
</ToolTitle>
<Show when={props.output}>
<box>
<text>{props.output?.trim()}</text>
</box>
</Show>
</>
)
},
})
ToolRegistry.register<typeof EditTool>({
name: "edit",
ready(props) {
const code = createMemo(() => {
if (!props.metadata.diff) return "[no diff]"
const text = props.metadata.diff.split("\n").slice(5).join("\n")
const hast = highlightHast(text, Language.TS)
const styled = hastToStyledText(hast as any, syntax)
return styled
})
return (
<box gap={1}>
<ToolTitle icon="←" fallback="Preparing edit..." when={props.input.filePath}>
Edit {normalizePath(props.input.filePath!)}
</ToolTitle>
<box>
<text>{code()}</text>
</box>
</box>
)
},
})
ToolRegistry.register<typeof PatchTool>({
name: "patch",
ready(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",
ready() {
return (
<>
<ToolTitle icon="%" fallback="Planning..." when={true}>
TodoWrite
</ToolTitle>
</>
)
},
})
function normalizePath(input: string) {
if (path.isAbsolute(input)) {
return path.relative(Instance.directory, input) || "."
}
return input
}
@@ -0,0 +1,184 @@
import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
import { Theme } from "../context/theme"
import { entries, flatMap, groupBy, pipe } from "remeda"
import { batch, createEffect, createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useKeyboard } from "@opentui/solid"
import * as fuzzysort from "fuzzysort"
import { isDeepEqual } from "remeda"
export interface DialogSelectProps<T> {
title: string
options: DialogSelectOption<T>[]
onFilter?: (query: string) => void
onSelect?: (option: DialogSelectOption<T>) => void
current?: T
}
export interface DialogSelectOption<T> {
value: T
title: string
description?: string
category?: string
onSelect?: () => void
}
export function DialogSelect<T>(props: DialogSelectProps<T>) {
const [store, setStore] = createStore({
selected: 0,
filter: "",
})
let input: InputRenderable
const grouped = createMemo(() => {
const needle = store.filter.toLowerCase()
const result = pipe(
props.options,
(x) => (!needle ? x : fuzzysort.go(needle, x, { keys: ["title", "category"] }).map((x) => x.obj)),
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 selected = createMemo(() => flat()[store.selected])
createEffect(() => {
store.filter
setStore("selected", 0)
scroll.scrollTo(0)
})
function move(direction: -1 | 1) {
let next = store.selected + direction
if (next < 0) next = flat().length - 1
if (next >= flat().length) next = 0
setStore("selected", next)
const target = scroll.findDescendantById(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)
}
}
}
useKeyboard((evt) => {
if (evt.name === "up") move(-1)
if (evt.name === "down") move(1)
if (evt.name === "return") {
const option = selected()
if (option.onSelect) option.onSelect()
props.onSelect?.(option)
}
})
let scroll: ScrollBoxRenderable
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={10}
>
<For each={grouped()}>
{([category, options], index) => (
<box flexShrink={0}>
<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) => {
return (
<Option
id={JSON.stringify(option.value)}
title={option.title}
description={option.description !== category ? option.description : undefined}
active={isDeepEqual(option.value, selected()?.value)}
current={isDeepEqual(option.value, props.current)}
/>
)
}}
</For>
</box>
)}
</For>
</scrollbox>
<box paddingRight={2} paddingLeft={3} paddingBottom={1} flexDirection="row">
<text fg={Theme.text} attributes={TextAttributes.BOLD}>
n
</text>
<text fg={Theme.textMuted}> new</text>
<text fg={Theme.text} attributes={TextAttributes.BOLD}>
{" "}r
</text>
<text fg={Theme.textMuted}> rename</text>
</box>
</box>
)
}
function Option(props: { id: string; title: string; description?: string; active?: boolean; current?: boolean }) {
return (
<box
// @ts-expect-error
id={props.id}
flexDirection="row"
backgroundColor={props.active ? Theme.primary : RGBA.fromInts(0, 0, 0, 0)}
paddingLeft={1}
paddingRight={1}
>
<text
fg={props.active ? Theme.background : props.current ? Theme.primary : Theme.text}
attributes={props.active ? TextAttributes.BOLD : undefined}
>
{props.title}
</text>
<text fg={props.active ? Theme.background : Theme.textMuted}> {props.description}</text>
</box>
)
}
@@ -0,0 +1,119 @@
import { useKeyHandler, useTerminalDimensions } from "@opentui/solid"
import { createContext, For, Show, useContext, type JSX, type ParentProps } from "solid-js"
import { Theme } from "../context/theme"
import { RGBA } from "@opentui/core"
import { createStore, produce } from "solid-js/store"
const Border = {
topLeft: "┃",
topRight: "┃",
bottomLeft: "┃",
bottomRight: "┃",
horizontal: "",
vertical: "┃",
topT: "+",
bottomT: "+",
leftT: "+",
rightT: "+",
cross: "+",
}
export function Dialog(
props: ParentProps<{
size?: "medium" | "large"
}>,
) {
const dimensions = useTerminalDimensions()
return (
<box
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
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 JSX.Element[],
size: "medium" as "medium" | "large",
})
useKeyHandler((evt) => {
if (evt.name === "escape") {
setStore("stack", store.stack.slice(0, -1))
}
})
return {
push(input: JSX.Element) {
setStore(
"stack",
produce((val) => val.push(input)),
)
},
clear() {
setStore("size", "medium")
setStore("stack", [])
},
replace(input: JSX.Element) {
setStore("size", "medium")
setStore("stack", [input])
},
get stack() {
return store.stack
},
get size() {
return store.size
},
setSize(size: "medium" | "large") {
setStore("size", size)
},
}
}
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">
<For each={value.stack}>
{(item, index) => (
<Show when={index() === 0}>
<Dialog size={value.size}>{item}</Dialog>
</Show>
)}
</For>
</box>
</ctx.Provider>
)
}
export function useDialog() {
const value = useContext(ctx)
if (!value) {
throw new Error("useDialog must be used within a DialogProvider")
}
return value
}
+9 -3
View File
@@ -42,7 +42,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()))
}
}
@@ -593,7 +593,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 },
)
}
@@ -647,7 +650,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",
+3 -1
View File
@@ -181,7 +181,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)
+8 -1
View File
@@ -1,6 +1,7 @@
import fs from "fs/promises"
import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir"
import path from "path"
import os from "os"
const app = "opencode"
@@ -11,6 +12,7 @@ const state = path.join(xdgState!, app)
export namespace Global {
export const Path = {
home: os.homedir(),
data,
bin: path.join(data, "bin"),
log: path.join(data, "log"),
@@ -38,7 +40,12 @@ if (version !== CACHE_VERSION) {
try {
const contents = await fs.readdir(Global.Path.cache)
await Promise.all(
contents.map((item) => fs.rm(path.join(Global.Path.cache, item), { recursive: true, force: true })),
contents.map((item) =>
fs.rm(path.join(Global.Path.cache, item), {
recursive: true,
force: true,
}),
),
)
} catch (e) {}
await Bun.file(path.join(Global.Path.cache, "version")).write(CACHE_VERSION)
+2 -2
View File
@@ -17,8 +17,8 @@ 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 { OpentuiCommand } from "./cli/cmd/opentui/opentui"
import { ExportCommand } from "./cli/cmd/export"
import { AttachCommand } from "./cli/cmd/attach"
const cancel = new AbortController()
@@ -72,7 +72,7 @@ const cli = yargs(hideBin(process.argv))
.usage("\n" + UI.logo())
.command(McpCommand)
.command(TuiCommand)
.command(AttachCommand)
.command(OpentuiCommand)
.command(RunCommand)
.command(GenerateCommand)
.command(DebugCommand)
+1 -1
View File
@@ -141,7 +141,7 @@ export namespace Installation {
export async function latest() {
return fetch("https://api.github.com/repos/sst/opencode/releases/latest")
.then((res) => res.json())
.then((data) => {
.then((data: any) => {
if (typeof data.tag_name !== "string") {
log.error("GitHub API error", data)
throw new Error("failed to fetch latest version")
+4 -1
View File
@@ -139,7 +139,10 @@ export namespace LSPClient {
if (version !== undefined) {
const next = version + 1
files[input.path] = next
log.info("textDocument/didChange", { path: input.path, version: next })
log.info("textDocument/didChange", {
path: input.path,
version: next,
})
await connection.sendNotification("textDocument/didChange", {
textDocument: {
uri: `file://` + input.path,
+3 -1
View File
@@ -139,7 +139,9 @@ 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
+2 -2
View File
@@ -410,7 +410,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
@@ -595,7 +595,7 @@ export namespace LSPServer {
return
}
const release = await releaseResponse.json()
const release = (await releaseResponse.json()) as any
const platform = process.platform
let assetName = ""
+9 -2
View File
@@ -67,7 +67,10 @@ export namespace MCP {
return null
})
if (client) {
log.debug("transport connection succeeded", { key, transport: name })
log.debug("transport connection succeeded", {
key,
transport: name,
})
clients[key] = client
break
}
@@ -76,7 +79,11 @@ export namespace MCP {
const errorMessage = lastError
? `MCP server ${key} failed to connect: ${lastError.message}`
: `MCP server ${key} failed to connect to ${mcp.url}`
log.error("remote mcp connection failed", { key, url: mcp.url, error: lastError?.message })
log.error("remote mcp connection failed", {
key,
url: mcp.url,
error: lastError?.message,
})
Bus.publish(Session.Event.Error, {
error: {
name: "UnknownError",
+5 -1
View File
@@ -41,7 +41,11 @@ export namespace Permission {
Updated: Bus.event("permission.updated", Info),
Replied: Bus.event(
"permission.replied",
z.object({ sessionID: z.string(), permissionID: z.string(), response: z.string() }),
z.object({
sessionID: z.string(),
permissionID: z.string(),
response: z.string(),
}),
),
}
+1
View File
@@ -14,6 +14,7 @@ export namespace Plugin {
const state = Instance.state(async () => {
const client = createOpencodeClient({
baseUrl: "http://localhost:4096",
// @ts-expect-error
fetch: async (...args) => Server.App().fetch(...args),
})
const config = await Config.get()
+29
View File
@@ -29,6 +29,7 @@ import { SessionPrompt } from "../session/prompt"
import { SessionCompaction } from "../session/compaction"
import { SessionRevert } from "../session/revert"
import { lazy } from "../util/lazy"
import { Todo } from "../session/todo"
import { InstanceBootstrap } from "../project/bootstrap"
const ERRORS = {
@@ -319,6 +320,34 @@ export namespace Server {
return c.json(session)
},
)
.get(
"/session/:id/todo",
describeRoute({
description: "Get the todo list for a session",
operationId: "session.todo",
responses: {
200: {
description: "Todo list",
content: {
"application/json": {
schema: resolver(Todo.Info.array()),
},
},
},
},
}),
validator(
"param",
z.object({
id: z.string().meta({ description: "Session ID" }),
}),
),
async (c) => {
const sessionID = c.req.valid("param").id
const todos = await Todo.get(sessionID)
return c.json(todos)
},
)
.post(
"/session",
describeRoute({
+5 -1
View File
@@ -20,6 +20,8 @@ export namespace MessageV2 {
export const ToolStatePending = z
.object({
status: z.literal("pending"),
raw: z.string(),
input: z.record(z.string(), z.any()),
})
.meta({
ref: "ToolStatePending",
@@ -30,7 +32,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({
@@ -391,6 +393,8 @@ export namespace MessageV2 {
if (part.toolInvocation.state === "partial-call") {
return {
status: "pending",
input: {},
raw: "",
}
}
+2
View File
@@ -928,6 +928,8 @@ export namespace SessionPrompt {
callID: value.id,
state: {
status: "pending",
input: {},
raw: "",
},
})
toolcalls[value.id] = part as MessageV2.ToolPart
+34
View File
@@ -0,0 +1,34 @@
import z from "zod/v4"
import { Bus } from "../bus"
import { Storage } from "../storage/storage"
export namespace Todo {
export const Info = z
.object({
content: z.string().describe("Brief description of the task"),
status: z.string().describe("Current status of the task: pending, in_progress, completed, cancelled"),
priority: z.string().describe("Priority level of the task: high, medium, low"),
id: z.string().describe("Unique identifier for the todo item"),
})
.meta({ ref: "Todo" })
export type Info = z.infer<typeof Info>
export const Event = {
Updated: Bus.event(
"todo.updated",
z.object({
sessionID: z.string(),
todos: z.array(Info),
}),
),
}
export async function update(input: { sessionID: string; todos: Info[] }) {
await Storage.write(["todo", input.sessionID], input.todos)
Bus.publish(Event.Updated, input)
}
export async function get(sessionID: string) {
return Storage.read<Info[]>(["todo", sessionID]) ?? []
}
}
+4 -1
View File
@@ -14,7 +14,10 @@ export namespace Storage {
const MIGRATIONS: Migration[] = [
async (dir) => {
const project = path.resolve(dir, "../project")
for await (const projectDir of new Bun.Glob("*").scan({ cwd: project, onlyFiles: false })) {
for await (const projectDir of new Bun.Glob("*").scan({
cwd: project,
onlyFiles: false,
})) {
log.info(`migrating project ${projectDir}`)
let projectID = projectDir
const fullProjectDir = path.join(project, projectDir)
+5 -1
View File
@@ -44,7 +44,11 @@ export const ListTool = Tool.define("list", {
const searchPath = path.resolve(Instance.directory, params.path || ".")
const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || [])
const files = await Ripgrep.files({ cwd: searchPath, glob: ignoreGlobs, limit: LIMIT })
const files = await Ripgrep.files({
cwd: searchPath,
glob: ignoreGlobs,
limit: LIMIT,
})
// Build directory structure
const dirs = new Set<string>()
+7 -20
View File
@@ -1,31 +1,18 @@
import z from "zod/v4"
import { Tool } from "./tool"
import DESCRIPTION_WRITE from "./todowrite.txt"
import { Instance } from "../project/instance"
const TodoInfo = z.object({
content: z.string().describe("Brief description of the task"),
status: z.string().describe("Current status of the task: pending, in_progress, completed, cancelled"),
priority: z.string().describe("Priority level of the task: high, medium, low"),
id: z.string().describe("Unique identifier for the todo item"),
})
type TodoInfo = z.infer<typeof TodoInfo>
const state = Instance.state(() => {
const todos: {
[sessionId: string]: TodoInfo[]
} = {}
return todos
})
import { Todo } from "../session/todo"
export const TodoWriteTool = Tool.define("todowrite", {
description: DESCRIPTION_WRITE,
parameters: z.object({
todos: z.array(TodoInfo).describe("The updated todo list"),
todos: z.array(Todo.Info).describe("The updated todo list"),
}),
async execute(params, opts) {
const todos = state()
todos[opts.sessionID] = params.todos
await Todo.update({
sessionID: opts.sessionID,
todos: params.todos,
})
return {
title: `${params.todos.filter((x) => x.status !== "completed").length} todos`,
output: JSON.stringify(params.todos, null, 2),
@@ -40,7 +27,7 @@ export const TodoReadTool = Tool.define("todoread", {
description: "Use this tool to read your todo list",
parameters: z.object({}),
async execute(_params, opts) {
const todos = state()[opts.sessionID] ?? []
const todos = await Todo.get(opts.sessionID)
return {
title: `${todos.filter((x) => x.status !== "completed").length} todos`,
metadata: {
+3
View File
@@ -29,6 +29,9 @@ export namespace Tool {
}>
}
export type InferParameters<T extends Info> = T extends Info<infer P> ? z.infer<P> : never
export type InferMetadata<T extends Info> = T extends Info<any, infer M> ? M : never
export function define<Parameters extends z.ZodType, Result extends Metadata>(
id: string,
init: Info<Parameters, Result>["init"] | Awaited<ReturnType<Info<Parameters, Result>["init"]>>,
+1 -1
View File
@@ -14,8 +14,8 @@ import { Agent } from "../agent/agent"
export const WriteTool = Tool.define("write", {
description: DESCRIPTION,
parameters: z.object({
filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"),
content: z.string().describe("The content to write to the file"),
filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"),
}),
async execute(params, ctx) {
const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
@@ -0,0 +1,74 @@
import { lazy } from "../util/lazy"
export namespace TreeSitter {
const Parser = lazy(async () => {
try {
return NativeParser()
} catch (e) {
return WasmParser()
}
})
const NativeParser = lazy(async () => {
const { default: Parser } = await import("tree-sitter")
return Parser
})
const WasmParser = lazy(async () => {
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
},
})
return Parser
})
export async function parser() {
const p = await Parser()
const result = new p()
return result
}
const Languages: Record<string, { native: () => any; wasm: () => any }> = {
bash: {
native: () => import("tree-sitter-bash"),
wasm: () =>
import("tree-sitter-bash/tree-sitter-bash.wasm" as string, {
with: { type: "wasm" },
}),
},
typescript: {
native: () => import("tree-sitter-typescript"),
wasm: () =>
import("tree-sitter-typescript/tree-sitter-typescript.wasm" as string, {
with: { type: "wasm" },
}),
},
}
const Extensions = {
".ts": "typescript",
".tsx": "typescript",
".js": "typescript",
".jsx": "typescript",
".sh": "bash",
}
export async function language(extension: keyof typeof Extensions) {
const language = Extensions[extension]
if (!language) return undefined
const { native, wasm } = Languages[language]
try {
const { language } = await native()
return language
} catch (e) {
const { default: mod } = await wasm()
const language = await WasmParser().then((p) => p.Language.load(mod))
return language
}
}
}
+41
View File
@@ -0,0 +1,41 @@
export namespace Binary {
export function search<T>(array: T[], id: string, compare: (item: T) => string): { found: boolean; index: number } {
let left = 0
let right = array.length - 1
while (left <= right) {
const mid = Math.floor((left + right) / 2)
const midId = compare(array[mid])
if (midId === id) {
return { found: true, index: mid }
} else if (midId < id) {
left = mid + 1
} else {
right = mid - 1
}
}
return { found: false, index: left }
}
export function insert<T>(array: T[], item: T, compare: (item: T) => string): T[] {
const id = compare(item)
let left = 0
let right = array.length
while (left < right) {
const mid = Math.floor((left + right) / 2)
const midId = compare(array[mid])
if (midId < id) {
left = mid + 1
} else {
right = mid
}
}
array.splice(left, 0, item)
return array
}
}
+10
View File
@@ -0,0 +1,10 @@
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()
}
}
+4 -2
View File
@@ -2,7 +2,9 @@
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"customConditions": ["development"]
"jsx": "preserve",
"jsxImportSource": "@opentui/solid",
"lib": ["ESNext", "DOM.Iterable"],
"customConditions": ["development", "browser"]
}
}
+1 -5
View File
@@ -3,10 +3,9 @@ import { tool } from "./tool"
export const ExamplePlugin: Plugin = async (ctx) => {
return {
permission: {},
tool: {
mytool: tool({
description: "This is a custom tool tool",
description: "This is a custom tool",
args: {
foo: tool.schema.string().describe("foo"),
},
@@ -15,8 +14,5 @@ export const ExamplePlugin: Plugin = async (ctx) => {
},
}),
},
async "chat.params"(_input, output) {
output.topP = 1
},
}
}
+11
View File
@@ -0,0 +1,11 @@
import { createOpencodeClient, createOpencodeServer } from "../src/index"
const client = createOpencodeClient({
baseUrl: "http://localhost:4096",
})
await client.event.subscribe().then(async (event) => {
for await (const e of event.stream) {
console.log(e)
}
})
+1 -2
View File
@@ -29,10 +29,9 @@
],
"devDependencies": {
"typescript": "catalog:",
"@hey-api/openapi-ts": "0.80.1",
"@tsconfig/node22": "catalog:"
},
"dependencies": {
"@hey-api/openapi-ts": "0.81.0"
"@hey-api/openapi-ts": "0.82.5"
}
}
+2
View File
@@ -10,6 +10,8 @@ import { createClient } from "@hey-api/openapi-ts"
await $`bun dev generate > ${dir}/openapi.json`.cwd(path.resolve(dir, "../../opencode"))
await $`rm -rf src/gen`
await createClient({
input: "./openapi.json",
output: {
+68 -30
View File
@@ -1,6 +1,8 @@
// This file is auto-generated by @hey-api/openapi-ts
import { createSseClient } from "../core/serverSentEvents.gen.js"
import type { HttpMethod } from "../core/types.gen.js"
import { getValidRequestBody } from "../core/utils.gen.js"
import type { Client, Config, RequestOptions, ResolvedRequestOptions } from "./types.gen.js"
import {
buildUrl,
@@ -49,12 +51,12 @@ export const createClient = (config: Config = {}): Client => {
await opts.requestValidator(opts)
}
if (opts.body && opts.bodySerializer) {
if (opts.body !== undefined && opts.bodySerializer) {
opts.serializedBody = opts.bodySerializer(opts.body)
}
// remove Content-Type header if body is empty to avoid sending invalid requests
if (opts.serializedBody === undefined || opts.serializedBody === "") {
if (opts.body === undefined || opts.serializedBody === "") {
opts.headers.delete("Content-Type")
}
@@ -69,7 +71,7 @@ export const createClient = (config: Config = {}): Client => {
const requestInit: ReqInit = {
redirect: "follow",
...opts,
body: opts.serializedBody,
body: getValidRequestBody(opts),
}
let request = new Request(url, requestInit)
@@ -97,18 +99,36 @@ export const createClient = (config: Config = {}): Client => {
}
if (response.ok) {
const parseAs =
(opts.parseAs === "auto" ? getParseAs(response.headers.get("Content-Type")) : opts.parseAs) ?? "json"
if (response.status === 204 || response.headers.get("Content-Length") === "0") {
let emptyData: any
switch (parseAs) {
case "arrayBuffer":
case "blob":
case "text":
emptyData = await response[parseAs]()
break
case "formData":
emptyData = new FormData()
break
case "stream":
emptyData = response.body
break
case "json":
default:
emptyData = {}
break
}
return opts.responseStyle === "data"
? {}
? emptyData
: {
data: {},
data: emptyData,
...result,
}
}
const parseAs =
(opts.parseAs === "auto" ? getParseAs(response.headers.get("Content-Type")) : opts.parseAs) ?? "json"
let data: any
switch (parseAs) {
case "arrayBuffer":
@@ -178,35 +198,53 @@ export const createClient = (config: Config = {}): Client => {
}
}
const makeMethod = (method: Required<Config>["method"]) => {
const fn = (options: RequestOptions) => request({ ...options, method })
fn.sse = async (options: RequestOptions) => {
const { opts, url } = await beforeRequest(options)
return createSseClient({
...opts,
body: opts.body as BodyInit | null | undefined,
headers: opts.headers as unknown as Record<string, string>,
method,
url,
})
}
return fn
const makeMethodFn = (method: Uppercase<HttpMethod>) => (options: RequestOptions) => request({ ...options, method })
const makeSseFn = (method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
const { opts, url } = await beforeRequest(options)
return createSseClient({
...opts,
body: opts.body as BodyInit | null | undefined,
headers: opts.headers as unknown as Record<string, string>,
method,
onRequest: async (url, init) => {
let request = new Request(url, init)
for (const fn of interceptors.request._fns) {
if (fn) {
request = await fn(request, opts)
}
}
return request
},
url,
})
}
return {
buildUrl,
connect: makeMethod("CONNECT"),
delete: makeMethod("DELETE"),
get: makeMethod("GET"),
connect: makeMethodFn("CONNECT"),
delete: makeMethodFn("DELETE"),
get: makeMethodFn("GET"),
getConfig,
head: makeMethod("HEAD"),
head: makeMethodFn("HEAD"),
interceptors,
options: makeMethod("OPTIONS"),
patch: makeMethod("PATCH"),
post: makeMethod("POST"),
put: makeMethod("PUT"),
options: makeMethodFn("OPTIONS"),
patch: makeMethodFn("PATCH"),
post: makeMethodFn("POST"),
put: makeMethodFn("PUT"),
request,
setConfig,
trace: makeMethod("TRACE"),
sse: {
connect: makeSseFn("CONNECT"),
delete: makeSseFn("DELETE"),
get: makeSseFn("GET"),
head: makeSseFn("HEAD"),
options: makeSseFn("OPTIONS"),
patch: makeSseFn("PATCH"),
post: makeSseFn("POST"),
put: makeSseFn("PUT"),
trace: makeSseFn("TRACE"),
},
trace: makeMethodFn("TRACE"),
} as Client
}
+4 -8
View File
@@ -20,7 +20,7 @@ export interface Config<T extends ClientOptions = ClientOptions>
*
* @default globalThis.fetch
*/
fetch?: (request: Request) => ReturnType<typeof fetch>
fetch?: typeof fetch
/**
* Please don't use the Fetch client for Next.js applications. The `next`
* options won't have any effect.
@@ -128,7 +128,7 @@ export interface ClientOptions {
throwOnError?: boolean
}
type MethodFnBase = <
type MethodFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
@@ -137,7 +137,7 @@ type MethodFnBase = <
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, "method">,
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>
type MethodFnServerSentEvents = <
type SseFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
@@ -146,10 +146,6 @@ type MethodFnServerSentEvents = <
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, "method">,
) => Promise<ServerSentEventsResult<TData, TError>>
type MethodFn = MethodFnBase & {
sse: MethodFnServerSentEvents
}
type RequestFn = <
TData = unknown,
TError = unknown,
@@ -171,7 +167,7 @@ type BuildUrlFn = <
options: Pick<TData, "url"> & Options<TData>,
) => string
export type Client = CoreClient<RequestFn, Config, MethodFn, BuildUrlFn> & {
export type Client = CoreClient<RequestFn, Config, MethodFn, BuildUrlFn, SseFn> & {
interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>
}
+10 -2
View File
@@ -162,14 +162,22 @@ export const mergeConfigs = (a: Config, b: Config): Config => {
return config
}
const headersEntries = (headers: Headers): Array<[string, string]> => {
const entries: Array<[string, string]> = []
headers.forEach((value, key) => {
entries.push([key, value])
})
return entries
}
export const mergeHeaders = (...headers: Array<Required<Config>["headers"] | undefined>): Headers => {
const mergedHeaders = new Headers()
for (const header of headers) {
if (!header || typeof header !== "object") {
if (!header) {
continue
}
const iterator = header instanceof Headers ? header.entries() : Object.entries(header)
const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header)
for (const [key, value] of iterator) {
if (value === null) {
@@ -4,6 +4,17 @@ import type { Config } from "./types.gen.js"
export type ServerSentEventsOptions<TData = unknown> = Omit<RequestInit, "method"> &
Pick<Config, "method" | "responseTransformer" | "responseValidator"> & {
/**
* Fetch API implementation. You can use this option to provide a custom
* fetch instance.
*
* @default globalThis.fetch
*/
fetch?: typeof fetch
/**
* Implementing clients can call request interceptors inside this hook.
*/
onRequest?: (url: string, init: RequestInit) => Promise<Request>
/**
* Callback invoked when a network or parsing error occurs during streaming.
*
@@ -21,6 +32,7 @@ export type ServerSentEventsOptions<TData = unknown> = Omit<RequestInit, "method
* @returns Nothing (void).
*/
onSseEvent?: (event: StreamEvent<TData>) => void
serializedBody?: RequestInit["body"]
/**
* Default retry delay in milliseconds.
*
@@ -64,6 +76,7 @@ export type ServerSentEventsResult<TData = unknown, TReturn = void, TNext = unkn
}
export const createSseClient = <TData = unknown>({
onRequest,
onSseError,
onSseEvent,
responseTransformer,
@@ -99,7 +112,21 @@ export const createSseClient = <TData = unknown>({
}
try {
const response = await fetch(url, { ...options, headers, signal })
const requestInit: RequestInit = {
redirect: "follow",
...options,
body: options.serializedBody,
headers,
signal,
}
let request = new Request(url, requestInit)
if (onRequest) {
request = await onRequest(url, requestInit)
}
// fetch must be assigned here, otherwise it would throw the error:
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
const _fetch = options.fetch ?? globalThis.fetch
const response = await _fetch(request)
if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`)
+7 -12
View File
@@ -3,24 +3,19 @@
import type { Auth, AuthToken } from "./auth.gen.js"
import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from "./bodySerializer.gen.js"
export interface Client<RequestFn = never, Config = unknown, MethodFn = never, BuildUrlFn = never> {
export type HttpMethod = "connect" | "delete" | "get" | "head" | "options" | "patch" | "post" | "put" | "trace"
export type Client<RequestFn = never, Config = unknown, MethodFn = never, BuildUrlFn = never, SseFn = never> = {
/**
* Returns the final request URL.
*/
buildUrl: BuildUrlFn
connect: MethodFn
delete: MethodFn
get: MethodFn
getConfig: () => Config
head: MethodFn
options: MethodFn
patch: MethodFn
post: MethodFn
put: MethodFn
request: RequestFn
setConfig: (config: Config) => Config
trace: MethodFn
}
} & {
[K in HttpMethod]: MethodFn
} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } })
export interface Config {
/**
@@ -47,7 +42,7 @@ export interface Config {
*
* {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more}
*/
method?: "CONNECT" | "DELETE" | "GET" | "HEAD" | "OPTIONS" | "PATCH" | "POST" | "PUT" | "TRACE"
method?: Uppercase<HttpMethod>
/**
* A function for serializing request query parameters. By default, arrays
* will be exploded in form style, objects will be exploded in deepObject
+29 -1
View File
@@ -1,6 +1,6 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { QuerySerializer } from "./bodySerializer.gen.js"
import type { BodySerializer, QuerySerializer } from "./bodySerializer.gen.js"
import {
type ArraySeparatorStyle,
serializeArrayParam,
@@ -107,3 +107,31 @@ export const getUrl = ({
}
return url
}
export function getValidRequestBody(options: {
body?: unknown
bodySerializer?: BodySerializer | null
serializedBody?: unknown
}) {
const hasBody = options.body !== undefined
const isSerializedBody = hasBody && options.bodySerializer
if (isSerializedBody) {
if ("serializedBody" in options) {
const hasSerializedBody = options.serializedBody !== undefined && options.serializedBody !== ""
return hasSerializedBody ? options.serializedBody : null
}
// not all clients implement a serializedBody property (i.e. client-axios)
return options.body !== "" ? options.body : null
}
// plain/text body
if (hasBody) {
return options.body
}
// no body was provided
return undefined
}
+13 -1
View File
@@ -29,6 +29,8 @@ import type {
SessionUpdateResponses,
SessionChildrenData,
SessionChildrenResponses,
SessionTodoData,
SessionTodoResponses,
SessionInitData,
SessionInitResponses,
SessionAbortData,
@@ -275,6 +277,16 @@ class Session extends _HeyApiClient {
})
}
/**
* Get the todo list for a session
*/
public todo<ThrowOnError extends boolean = false>(options: Options<SessionTodoData, ThrowOnError>) {
return (options.client ?? this._client).get<SessionTodoResponses, unknown, ThrowOnError>({
url: "/session/{id}/todo",
...options,
})
}
/**
* Analyze the app and create an AGENTS.md file
*/
@@ -647,7 +659,7 @@ class Event extends _HeyApiClient {
* Get events
*/
public subscribe<ThrowOnError extends boolean = false>(options?: Options<EventSubscribeData, ThrowOnError>) {
return (options?.client ?? this._client).get.sse<EventSubscribeResponses, unknown, ThrowOnError>({
return (options?.client ?? this._client).sse.get<EventSubscribeResponses, unknown, ThrowOnError>({
url: "/event",
...options,
})
+58 -1
View File
@@ -550,6 +550,25 @@ export type Session = {
}
}
export type Todo = {
/**
* Brief description of the task
*/
content: string
/**
* Current status of the task: pending, in_progress, completed, cancelled
*/
status: string
/**
* Priority level of the task: high, medium, low
*/
priority: string
/**
* Unique identifier for the todo item
*/
id: string
}
export type UserMessage = {
id: string
sessionID: string
@@ -695,11 +714,17 @@ export type FilePart = {
export type ToolStatePending = {
status: "pending"
raw: string
input: {
[key: string]: unknown
}
}
export type ToolStateRunning = {
status: "running"
input: unknown
input: {
[key: string]: unknown
}
title?: string
metadata?: {
[key: string]: unknown
@@ -1076,6 +1101,14 @@ export type EventFileEdited = {
}
}
export type EventTodoUpdated = {
type: "todo.updated"
properties: {
sessionID: string
todos: Array<Todo>
}
}
export type EventSessionIdle = {
type: "session.idle"
properties: {
@@ -1138,6 +1171,7 @@ export type Event =
| EventPermissionUpdated
| EventPermissionReplied
| EventFileEdited
| EventTodoUpdated
| EventSessionIdle
| EventSessionUpdated
| EventSessionDeleted
@@ -1404,6 +1438,29 @@ export type SessionChildrenResponses = {
export type SessionChildrenResponse = SessionChildrenResponses[keyof SessionChildrenResponses]
export type SessionTodoData = {
body?: never
path: {
/**
* Session ID
*/
id: string
}
query?: {
directory?: string
}
url: "/session/{id}/todo"
}
export type SessionTodoResponses = {
/**
* Todo list
*/
200: Array<Todo>
}
export type SessionTodoResponse = SessionTodoResponses[keyof SessionTodoResponses]
export type SessionInitData = {
body?: {
messageID: string
+2 -1
View File
@@ -9,5 +9,6 @@
"lib": ["es2022", "dom", "dom.iterable"],
"customConditions": ["development"]
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/gen"]
}
@@ -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
@@ -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>