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:
@@ -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",
|
||||
|
||||
@@ -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/*"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
preload = ["@opentui/solid/preload"]
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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({
|
||||
|
||||
@@ -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: "",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -928,6 +928,8 @@ export namespace SessionPrompt {
|
||||
callID: value.id,
|
||||
state: {
|
||||
status: "pending",
|
||||
input: {},
|
||||
raw: "",
|
||||
},
|
||||
})
|
||||
toolcalls[value.id] = part as MessageV2.ToolPart
|
||||
|
||||
@@ -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]) ?? []
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"]>>,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user