This commit is contained in:
Dax Raad 2025-08-27 11:51:40 -04:00
parent 7993c2ebde
commit a0293468de
17 changed files with 393 additions and 133 deletions

View file

@ -26,7 +26,7 @@
}, },
"cloud/core": { "cloud/core": {
"name": "@opencode/cloud-core", "name": "@opencode/cloud-core",
"version": "0.5.18", "version": "0.5.23",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"drizzle-orm": "0.41.0", "drizzle-orm": "0.41.0",
@ -40,7 +40,7 @@
}, },
"cloud/function": { "cloud/function": {
"name": "@opencode/cloud-function", "name": "@opencode/cloud-function",
"version": "0.5.18", "version": "0.5.23",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "2.0.0", "@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2", "@ai-sdk/openai": "2.0.2",
@ -60,7 +60,7 @@
}, },
"cloud/web": { "cloud/web": {
"name": "@opencode/cloud-web", "name": "@opencode/cloud-web",
"version": "0.5.18", "version": "0.5.23",
"dependencies": { "dependencies": {
"@kobalte/core": "0.13.9", "@kobalte/core": "0.13.9",
"@openauthjs/solid": "0.0.0-20250322224806", "@openauthjs/solid": "0.0.0-20250322224806",
@ -79,7 +79,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode/function", "name": "@opencode/function",
"version": "0.5.18", "version": "0.5.23",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
@ -94,7 +94,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "0.5.18", "version": "0.5.23",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@ -105,8 +105,8 @@
"@openauthjs/openauth": "0.4.3", "@openauthjs/openauth": "0.4.3",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opentui/core": "0.1.9", "@opentui/core": "0.1.10",
"@opentui/solid": "0.1.9", "@opentui/solid": "0.1.10",
"@standard-schema/spec": "1.0.0", "@standard-schema/spec": "1.0.0",
"@zip.js/zip.js": "2.7.62", "@zip.js/zip.js": "2.7.62",
"ai": "catalog:", "ai": "catalog:",
@ -148,7 +148,7 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "0.5.18", "version": "0.5.23",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
}, },
@ -160,7 +160,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "0.5.18", "version": "0.5.23",
"devDependencies": { "devDependencies": {
"@hey-api/openapi-ts": "0.81.0", "@hey-api/openapi-ts": "0.81.0",
"@tsconfig/node22": "catalog:", "@tsconfig/node22": "catalog:",
@ -169,7 +169,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode/web", "name": "@opencode/web",
"version": "0.5.18", "version": "0.5.23",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
@ -779,21 +779,21 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="], "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="],
"@opentui/core": ["@opentui/core@0.1.9", "", { "dependencies": { "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.9", "@opentui/core-darwin-x64": "0.1.9", "@opentui/core-linux-arm64": "0.1.9", "@opentui/core-linux-x64": "0.1.9", "@opentui/core-win32-arm64": "0.1.9", "@opentui/core-win32-x64": "0.1.9", "bun-webgpu": "0.1.3", "planck": "^1.4.2", "three": "0.177.0" } }, "sha512-GX4PNUe07hbDXxD37kKJAf3tPyMEisVFC0XA493HRFsuOAtkspnbgQGBr/5YOn4WQsXI5U/vRrSjc8pv50xEmg=="], "@opentui/core": ["@opentui/core@0.1.10", "", { "dependencies": { "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.10", "@opentui/core-darwin-x64": "0.1.10", "@opentui/core-linux-arm64": "0.1.10", "@opentui/core-linux-x64": "0.1.10", "@opentui/core-win32-arm64": "0.1.10", "@opentui/core-win32-x64": "0.1.10", "bun-webgpu": "0.1.3", "planck": "^1.4.2", "three": "0.177.0" } }, "sha512-+MrWNp0fX8VPAIMHK5tMHfal5VYrXWIM5D1PEdxEQlBFZgdUADRVxktrCYZVJtdU+WwSlqJ0bQY8QzA3aXWH0A=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JsMjA1T9UAecIC/XFkxhv2jVKI0OBPIJGzqdTP+7MCWz2WBNEEkLVre3L06wxL/iDYudeM44XMZBgDUM1F05Ug=="], "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-O0e4gIlfsPUGUOI9eFi4mStWkiUGOChvwIcJNv8Ppt+8aPPXzEgku4ptZF9G4KURXt3pgfkvVXgde1K29EnsUg=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-TmpHcNySbKEBDTCcleN/06q8QZpUhSNP5YjkSfvz/qWnEL9RrzjcfZttZ2iotDomQ7ufywMFhUtHyRaFcN5tDg=="], "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-PmhB0Ij5Jz+5rnVq33QCuxSaiCkGBnLH3dy5MUwp11A4VohSFR8TrQz3L5k419wZdejUgedMaT83QTWME5Fbag=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-XDOBwX66jWhQVdGXu6BzNIALSpRS20119K7IO4SCK3NTPaTkKk+FX+gOwbV6noWxViHhYH2wXBRXY4b/6hmJNA=="], "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-2TNU9DrAW1+pnqxI+9/cvtvGyBeJcyRVuddipcRZXz2k3xmmAn8A2UJ+CPJPzxET1aDsjMWiHUnESJqgkX5rog=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.9", "", { "os": "linux", "cpu": "x64" }, "sha512-eQaEfsKt2fzAX4inTVPyrOcgLkRv+c3ytSs5rRhukd3QkhuX7d3s16cq+aQ4WeAbINbohvGvx3yyneWHwtUN3w=="], "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.10", "", { "os": "linux", "cpu": "x64" }, "sha512-7nCN+9llnRvwaaI+XAKoT+5RUvK5dykoDtOAYNIQwWB46gPwwwCXhrua8U9V3P1dPeh2E2Pvcl5qhatuBk7Gmw=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-1jnO8f1WqIDFkCVpa72KX1Sv2cCfaWsAwCB1oTs/ovREVVtqtvDAp9PuFtT7iXG8hZVPtTqsZtZy5DnQLO49/A=="], "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-CUGL+aPxLRYQrtrVUNBVwkMj8SzCZRaB2BVgiKDZSJODzcFewEFyKDNSmvon2TkXSrjBW8TDil+lb9E3gMiw1Q=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.9", "", { "os": "win32", "cpu": "x64" }, "sha512-UqhOj2j6VYM+Gvp5raU+aK2gjes3X1cDQOmyTiwK2OS3GEZIqkmGbZ+FSdttF1J2XnQp1MyBR1Zvmok45DDPMw=="], "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.10", "", { "os": "win32", "cpu": "x64" }, "sha512-+zPKXAA31mNGiqw0Wv+9i/NhemnbdoyKhxpw5p7vyM5mzmQbTzPJ5gLoULUNWcCEyJLK1qfp2d1yLpFjLq9tNw=="],
"@opentui/solid": ["@opentui/solid@0.1.9", "", { "dependencies": { "@opentui/core": "0.1.9" }, "peerDependencies": { "solid-js": "1.9.9", "typescript": "^5" } }, "sha512-yMI8C01QbEqFAGTXuLgfW4ZJHvegarD3K/HJeAIHqZ5oyLR+AgyOKg24Ih+vRaFlDtM8BBh9z4ZrSoZ1mr+v6g=="], "@opentui/solid": ["@opentui/solid@0.1.10", "", { "dependencies": { "@opentui/core": "0.1.10" }, "peerDependencies": { "solid-js": "1.9.9", "typescript": "^5" } }, "sha512-kp499X1n0whyCZeBcivzRzkClI+2m/tp/bjC9Yw3Bhr6kq59y48DR/ZFqNUFr0X7NOkYFvwo3ApLtslcXoJ6qQ=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],

View file

@ -33,8 +33,8 @@
"@openauthjs/openauth": "0.4.3", "@openauthjs/openauth": "0.4.3",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opentui/solid": "0.1.9", "@opentui/solid": "0.1.10",
"@opentui/core": "0.1.9", "@opentui/core": "0.1.10",
"@standard-schema/spec": "1.0.0", "@standard-schema/spec": "1.0.0",
"@zip.js/zip.js": "2.7.62", "@zip.js/zip.js": "2.7.62",
"ai": "catalog:", "ai": "catalog:",

View file

@ -0,0 +1,15 @@
import type { BorderCharacters } from "@opentui/core";
export const SplitBorder: BorderCharacters = {
topLeft: "",
bottomLeft: "",
vertical: "┃",
topRight: "",
bottomRight: "",
horizontal: "",
bottomT: "",
topT: "",
cross: "",
leftT: "",
rightT: "",
}

View file

@ -0,0 +1,53 @@
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")
}
}
]}
/>
)
}

View file

@ -1,9 +1,9 @@
import { createMemo } from "solid-js"; import { createMemo } from "solid-js";
import { useLocal } from "../context/local"; import { useLocal } from "../context/local";
import { useSync } from "../context/sync"; import { useSync } from "../context/sync";
import { map, pipe, flatMap, entries, filter } from "remeda"; import { map, pipe, flatMap, entries, filter, isDeepEqual } from "remeda";
import { DialogSelect } from "./dialog-select"; import { DialogSelect } from "../ui/dialog-select";
import { useDialog } from "./dialog"; import { useDialog } from "../ui/dialog";
export function DialogModel() { export function DialogModel() {
const local = useLocal() const local = useLocal()
@ -11,14 +11,16 @@ export function DialogModel() {
const dialog = useDialog() const dialog = useDialog()
const options = createMemo(() => [ const options = createMemo(() => [
...local.model.recent().map(key => { ...local.model.recent().map(item => {
const [providerID, ...rest] = key.split("/") const provider = sync.data.provider.find((x) => x.id === item.providerID)!
const provider = sync.data.provider.find((x) => x.id === providerID)! const model = provider.models[item.modelID]
const modelID = rest.join("/")
const model = provider.models[modelID]
return { return {
key, key: item,
title: model.name ?? modelID, value: {
providerID: provider.id,
modelID: model.id,
},
title: model.name ?? item.modelID,
description: provider.name, description: provider.name,
category: "Recent", category: "Recent",
} }
@ -29,12 +31,15 @@ export function DialogModel() {
provider.models, provider.models,
entries(), entries(),
map(([model, info]) => ({ map(([model, info]) => ({
key: `${provider.id}/${model}`, value: {
providerID: provider.id,
modelID: model,
},
title: info.name ?? model, title: info.name ?? model,
description: provider.name, description: provider.name,
category: provider.name, category: provider.name,
})), })),
filter(x => !local.model.recent().includes(x.key)), filter(x => !local.model.recent().find(y => isDeepEqual(y, x.value))),
)), )),
) )
]) ])
@ -44,7 +49,7 @@ export function DialogModel() {
current={local.model.current()} current={local.model.current()}
options={options()} options={options()}
onSelect={option => { onSelect={option => {
local.model.set(option.key, { recent: true }) local.model.set(option.value, { recent: true })
dialog.clear() dialog.clear()
}} /> }} />

View file

@ -0,0 +1,42 @@
import { useDialog } from "../ui/dialog"
import { DialogModel } from "./dialog-model"
import { DialogSelect } from "../ui/dialog-select"
import { useRoute } from "../context/route"
import { useSync } from "../context/sync"
import { createMemo } from "solid-js"
export function DialogSessionList() {
const dialog = useDialog()
const sync = useSync()
const route = useRoute()
const options = createMemo(() => {
const today = new Date().toDateString()
return Object.values(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,
}
})
})
return (
<DialogSelect
title="Sessions"
options={options()}
onSelect={(option) => {
route.navigate({
type: "session",
sessionID: option.value,
})
dialog.clear()
}}
/>
)
}

View file

@ -0,0 +1,41 @@
import { InputRenderable, TextAttributes, fg, bold } from "@opentui/core"
import { createEffect } from "solid-js"
import { useLocal } from "../context/local"
import { Theme } from "../context/theme"
import { useDialog } from "../ui/dialog"
export type PromptProps = {
onSubmit?: (value: string) => void
}
export function Prompt(props: PromptProps) {
let input: InputRenderable
const dialog = useDialog()
const local = useLocal()
createEffect(() => {
if (dialog.stack.length === 0 && input)
input.focus()
if (dialog.stack.length > 0)
input.blur()
})
return (
<box>
<box flexDirection="row">
<box backgroundColor={Theme.backgroundElement} width={3} border={false} justifyContent="center" alignItems="center">
<text attributes={TextAttributes.BOLD} fg={Theme.primary}>{">"}</text>
</box>
<box border={false} paddingTop={1} paddingBottom={2} backgroundColor={Theme.backgroundElement} flexGrow={1}>
<input onSubmit={props.onSubmit} 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} border={false} justifyContent="center" alignItems="center">
</box>
</box>
<group paddingLeft={2} paddingRight={1} flexDirection="row" justifyContent="space-between">
<text>enter {fg(Theme.textMuted)("send")}</text>
<text>{fg(Theme.textMuted)(local.model.parsed().provider)} {bold(local.model.parsed().model)}</text>
</group >
</box>
)
}

View file

@ -2,9 +2,10 @@ import { createStore } from "solid-js/store"
import { batch, createContext, createEffect, createMemo, useContext, type ParentProps } from "solid-js" import { batch, createContext, createEffect, createMemo, useContext, type ParentProps } from "solid-js"
import { useSync } from "./sync" import { useSync } from "./sync"
import { Theme } from "./theme" import { Theme } from "./theme"
import { unique } from "remeda" import { unique, uniqueBy } from "remeda"
import path from "path" import path from "path"
import { Global } from "../../../../global" import { Global } from "../../../../global"
import type { Agent } from "@opencode-ai/sdk"
function init() { function init() {
@ -29,7 +30,10 @@ function init() {
const value = agents()[next] const value = agents()[next]
setStore("current", value.name) setStore("current", value.name)
if (value.model) if (value.model)
model.set(`${value.model.providerID}/${value.model.modelID}`) model.set({
providerID: value.model.providerID,
modelID: value.model.modelID,
})
}, },
color(name: string) { color(name: string) {
const index = agents().findIndex((x) => x.name === name) const index = agents().findIndex((x) => x.name === name)
@ -41,8 +45,14 @@ function init() {
const model = (() => { const model = (() => {
const [store, setStore] = createStore<{ const [store, setStore] = createStore<{
model: Record<string, string> model: Record<string, {
recent: string[] providerID: string
modelID: string
}>
recent: {
providerID: string
modelID: string
}[]
}>({ }>({
model: {}, model: {},
recent: [] recent: []
@ -63,12 +73,15 @@ function init() {
const fallback = createMemo(() => { const fallback = createMemo(() => {
const provider = sync.data.provider[0] const provider = sync.data.provider[0]
const model = Object.values(provider.models)[0] const model = Object.values(provider.models)[0]
return `${provider.id}/${model.id}` return {
providerID: provider.id,
modelID: model.id,
}
}) })
const current = createMemo<string>(() => { const current = createMemo(() => {
const a = agent.current() const a = agent.current()
return store.model[agent.current().name] ?? (a.model ? `${a.model.providerID}/${a.model.modelID}` : fallback()) return store.model[agent.current().name] ?? (a.model ? a.model : fallback())
}) })
@ -79,20 +92,18 @@ function init() {
}, },
parsed: createMemo(() => { parsed: createMemo(() => {
const value = current() const value = current()
const [providerID, ...rest] = value.split("/") const provider = sync.data.provider.find((x) => x.id === value.providerID)!
const provider = sync.data.provider.find((x) => x.id === providerID)! const model = provider.models[value.modelID]
const modelID = rest.join("/")
const model = provider.models[modelID]
return { return {
provider: provider.name ?? providerID, provider: provider.name ?? value.providerID,
model: model.name ?? modelID, model: model.name ?? value.modelID,
} }
}), }),
set(model: string, options?: { recent?: boolean }) { set(model: { providerID: string, modelID: string }, options?: { recent?: boolean }) {
batch(() => { batch(() => {
setStore("model", agent.current().name, model) setStore("model", agent.current().name, model)
if (options?.recent) { if (options?.recent) {
const uniq = unique([model, ...store.recent]) const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
if (uniq.length > 5) uniq.pop() if (uniq.length > 5) uniq.pop()
setStore("recent", uniq) setStore("recent", uniq)
} }

View file

@ -11,15 +11,16 @@ type Route =
} }
function init() { function init() {
const [store, setStore] = createStore<Route>({ const [store, setStore] = createStore<Route>(
type: "home", { type: 'session', sessionID: 'ses_71b466c91ffelM4E0ltHr2sQr3' }
}) )
return { return {
get route() { get data() {
return store return store
}, },
set navigate(route: Route) { navigate(route: Route) {
console.log("navigate", route)
setStore(route) setStore(route)
}, },
} }
@ -42,3 +43,8 @@ export function useRoute() {
} }
return value return value
} }
export function useRouteData<T extends Route["type"]>(type: T) {
const route = useRoute()
return route.data as Extract<Route, { type: typeof type }>
}

View file

@ -4,10 +4,12 @@ import { Server } from "../../../../server/server"
function init() { function init() {
const app = Server.app() const server = Server.listen({
port: 0,
hostname: "127.0.0.1",
})
const client = createOpencodeClient({ const client = createOpencodeClient({
baseUrl: "http://localhost:4096", baseUrl: server.url.toString(),
fetch: async (...args) => app.fetch(...args),
}) })
return client return client
} }

View file

@ -1,8 +1,7 @@
import type { Agent, Provider, Session } from "@opencode-ai/sdk" import type { Message, Agent, Provider, Session, Part } from "@opencode-ai/sdk"
import { createStore } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import { createContext, onMount, Show, useContext, type ParentProps } from "solid-js" import { createContext, Show, useContext, type ParentProps } from "solid-js"
import type { Message } from "vscode-jsonrpc"
function init() { function init() {
@ -10,27 +9,51 @@ function init() {
ready: boolean ready: boolean
provider: Provider[] provider: Provider[]
agent: Agent[] agent: Agent[]
session: Record<string, { session: {
info: Session [sessionID: string]: Session
message: Record<string, { }
info: Message message: {
part: Record<string, Message> [sessionID: string]: {
}> [messageID: string]: Message
}> }
}
part: {
[sessionID: string]: {
[messageID: string]: {
[partID: string]: Part
}
}
}
}>({ }>({
ready: false, ready: false,
agent: [], agent: [],
provider: [], provider: [],
session: {}, session: {},
message: {},
part: {},
}) })
const sdk = useSDK() const sdk = useSDK()
onMount(async () => { sdk.event.subscribe().then(async events => {
const events = await sdk.event.subscribe()
for await (const event of events.stream) { for await (const event of events.stream) {
switch (event.type) { switch (event.type) {
case "storage.write": case "session.updated":
setStore("session", event.properties.info.id, event.properties.info)
break
case "message.updated":
setStore("message", produce((message) => {
message[event.properties.info.sessionID] ??= {}
message[event.properties.info.sessionID][event.properties.info.id] = event.properties.info
}))
break
case "message.part.updated":
setStore("part", produce((part) => {
part[event.properties.part.sessionID] ??= {}
part[event.properties.part.sessionID][event.properties.part.messageID] ??= {}
part[event.properties.part.sessionID][event.properties.part.messageID][event.properties.part.id] = event.properties.part
}))
break break
} }
} }
@ -39,6 +62,12 @@ function init() {
Promise.all([ Promise.all([
sdk.config.providers().then((x) => setStore("provider", x.data!.providers)), sdk.config.providers().then((x) => setStore("provider", x.data!.providers)),
sdk.app.agents().then((x) => setStore("agent", x.data ?? [])), sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
sdk.session.list().then((x) =>
setStore("session", x.data!.reduce((acc, item) => {
acc[item.id] = item
return acc
}, {} as Record<string, Session>))
),
]).then(() => setStore("ready", true)) ]).then(() => setStore("ready", true))
return { return {

View file

@ -1,11 +1,15 @@
import { createEffect } from "solid-js";
import { Installation } from "../../../installation"; import { Installation } from "../../../installation";
import { Theme } from "./context/theme"; import { Theme } from "./context/theme";
import { InputRenderable, TextAttributes, bold, fg } from "@opentui/core" import { TextAttributes, bold, fg } from "@opentui/core"
import { useDialog } from "./ui/dialog"; import { Prompt } from "./component/prompt";
import { useSDK } from "./context/sdk";
import { useRoute } from "./context/route";
import { useLocal } from "./context/local"; import { useLocal } from "./context/local";
export function Home() { export function Home() {
const sdk = useSDK()
const route = useRoute()
const local = useLocal()
return ( return (
<group flexGrow={1} justifyContent="center" alignItems="center"> <group flexGrow={1} justifyContent="center" alignItems="center">
<group> <group>
@ -19,49 +23,31 @@ export function Home() {
</group> </group>
</group> </group>
<group paddingTop={3} > <group paddingTop={3} >
<Prompt /> <Prompt onSubmit={async (val) => {
</group > const session = await sdk.session.create({
</group> body: {
) },
}
function Prompt() {
let input: InputRenderable
const dialog = useDialog()
const local = useLocal()
createEffect(() => {
if (dialog.stack.length === 0 && input)
input.focus()
if (dialog.stack.length > 0)
input.blur()
}) })
route.navigate({
return ( type: "session",
<group> sessionID: session.data!.id,
<group flexDirection="row"> })
<group> await sdk.session.chat({
<text fg={Theme.textMuted}></text> path: {
<text fg={Theme.textMuted}></text> id: session.data!.id,
<text fg={Theme.textMuted}></text> },
</group> body: {
<box backgroundColor={Theme.backgroundElement} width={3} border={false} justifyContent="center" alignItems="center"> ...local.model.current(),
<text attributes={TextAttributes.BOLD} fg={Theme.primary}>{">"}</text> agent: local.agent.current().name,
</box> parts: [
<box border={false} paddingTop={1} paddingBottom={2} backgroundColor={Theme.backgroundElement}> {
<input ref={r => input = r} onMouseDown={r => r.target?.focus()} focusedBackgroundColor={Theme.backgroundElement} cursorColor={Theme.primary} backgroundColor={Theme.backgroundElement} width={70} /> type: "text",
</box> text: val,
<box backgroundColor={Theme.backgroundElement} width={1} border={false} justifyContent="center" alignItems="center"> }
</box> ]
<group> },
<text fg={Theme.textMuted}></text> })
<text fg={Theme.textMuted}></text> }} />
<text fg={Theme.textMuted}></text>
</group>
</group>
<group paddingLeft={2} paddingRight={1} flexDirection="row" justifyContent="space-between">
<text>enter {fg(Theme.textMuted)("send")}</text>
<text>{fg(Theme.textMuted)(local.model.parsed().provider)} {bold(local.model.parsed().model)}</text>
</group > </group >
</group> </group>
) )

View file

@ -12,7 +12,9 @@ import { bootstrap } from "../../bootstrap"
import { SDKProvider } from "./context/sdk" import { SDKProvider } from "./context/sdk"
import { SyncProvider } from "./context/sync" import { SyncProvider } from "./context/sync"
import { LocalProvider, useLocal } from "./context/local" import { LocalProvider, useLocal } from "./context/local"
import { DialogModel } from "./ui/dialog-model" import { DialogModel } from "./component/dialog-model"
import { DialogCommand } from "./component/dialog-command"
import { Session } from "./session"
export const OpentuiCommand = cmd({ export const OpentuiCommand = cmd({
command: "opentui", command: "opentui",
@ -53,6 +55,11 @@ function App() {
return return
} }
if (evt.ctrl && evt.name === "p") {
dialog.replace(() => <DialogCommand />)
return
}
if (evt.meta && evt.name === "d") { if (evt.meta && evt.name === "d") {
renderer.console.toggle() renderer.console.toggle()
return return
@ -67,9 +74,12 @@ function App() {
<box border={false} width={dimensions().width} height={dimensions().height} backgroundColor={Theme.background}> <box border={false} width={dimensions().width} height={dimensions().height} backgroundColor={Theme.background}>
<group flexDirection="column" flexGrow={1}> <group flexDirection="column" flexGrow={1}>
<Switch> <Switch>
<Match when={route.route.type === "home"}> <Match when={route.data.type === "home"}>
<Home /> <Home />
</Match> </Match>
<Match when={route.data.type === "session"}>
<Session />
</Match>
</Switch> </Switch>
</group> </group>
<box border={false} height={1} backgroundColor={Theme.backgroundPanel} flexDirection="row" justifyContent="space-between"> <box border={false} height={1} backgroundColor={Theme.backgroundPanel} flexDirection="row" justifyContent="space-between">

View file

@ -0,0 +1,40 @@
import { createEffect, createMemo, Match, Show, Switch } from "solid-js";
import { useRoute, useRouteData } from "./context/route";
import { useSync } from "./context/sync";
import { SplitBorder } from "./component/border";
import { Theme } from "./context/theme";
import { bold, fg } from "@opentui/core";
import { Prompt } from "./component/prompt";
import { useTerminalDimensions } from "@opentui/solid";
export function Session() {
const route = useRouteData("session")
const sync = useSync()
const session = createMemo(() => sync.data.session[route.sessionID])
const dimensions = useTerminalDimensions()
return (
<Show when={session()}>
<box paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={2} flexGrow={1}>
<box customBorderChars={SplitBorder} border={["left", "right"]} borderColor={Theme.backgroundElement} paddingLeft={1} paddingRight={1}>
<text>{bold(fg(Theme.accent)("#"))} {bold(session().title)}</text>
<group flexDirection="row">
<Switch>
<Match when={session().share?.url}>
<text fg={Theme.textMuted}>{session().share!.url}</text>
</Match>
<Match when={true}>
<text>/share {fg(Theme.textMuted)("to create a shareable link")}</text>
</Match>
</Switch>
</group>
</box>
<box flexGrow={1}>
</box>
<group>
<Prompt />
</group>
</box >
</Show >
)
}

View file

@ -1,26 +1,28 @@
import { InputRenderable, RGBA, TextAttributes } from "@opentui/core" import { InputRenderable, RGBA, TextAttributes } from "@opentui/core"
import { Theme } from "../context/theme" import { Theme } from "../context/theme"
import { entries, flatMap, groupBy, mapValues, pipe, take } from "remeda" import { entries, flatMap, groupBy, pipe, take } from "remeda"
import { createEffect, createMemo, For, Show } from "solid-js" import { createEffect, createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useKeyHandler } from "@opentui/solid" import { useKeyHandler } from "@opentui/solid"
import * as fuzzysort from "fuzzysort" import * as fuzzysort from "fuzzysort"
import { isDeepEqual } from "remeda"
export interface DialogSelectProps { export interface DialogSelectProps<T> {
title: string title: string
options: DialogSelectOption[] options: DialogSelectOption<T>[]
onSelect: (option: DialogSelectOption) => void onSelect?: (option: DialogSelectOption<T>) => void
current?: string current?: T
} }
export interface DialogSelectOption { export interface DialogSelectOption<T> {
key: string value: T
title: string title: string
description?: string description?: string
category?: string category?: string
onSelect?: () => void
} }
export function DialogSelect(props: DialogSelectProps) { export function DialogSelect<T>(props: DialogSelectProps<T>) {
const [store, setStore] = createStore({ const [store, setStore] = createStore({
selected: 0, selected: 0,
filter: "" filter: ""
@ -64,14 +66,18 @@ export function DialogSelect(props: DialogSelectProps) {
useKeyHandler((evt) => { useKeyHandler((evt) => {
if (evt.name === "up") move(-1) if (evt.name === "up") move(-1)
if (evt.name === "down") move(1) if (evt.name === "down") move(1)
if (evt.name === "return") props.onSelect(flat()[store.selected]) if (evt.name === "return") {
const option = flat()[store.selected]
if (option.onSelect) option.onSelect()
props.onSelect?.(option)
}
}) })
return ( return (
<group> <group>
<group paddingLeft={2} paddingRight={2}> <group paddingLeft={2} paddingRight={2}>
<group paddingLeft={1} paddingRight={1}> <group paddingLeft={1}>
<group flexDirection="row" justifyContent="space-between"> <group flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD}>{props.title}</text> <text attributes={TextAttributes.BOLD}>{props.title}</text>
<text fg={Theme.textMuted}>esc</text> <text fg={Theme.textMuted}>esc</text>
@ -91,16 +97,19 @@ export function DialogSelect(props: DialogSelectProps) {
<group paddingBottom={1} > <group paddingBottom={1} >
<For each={grouped()}> <For each={grouped()}>
{([category, options]) => {([category, options]) =>
<group paddingTop={1} flexShrink={0} > <group flexShrink={0} >
<Show when={category}> <Show when={category}>
<group paddingLeft={1} > <group paddingTop={1} paddingLeft={1} >
<text fg={Theme.accent} attributes={TextAttributes.BOLD}>{category}</text> <text fg={Theme.accent} attributes={TextAttributes.BOLD}>{category}</text>
</group> </group>
</Show> </Show>
<For each={options}> <For each={options}>
{(option) => {(option) =>
<Option <Option
title={option.title} description={option.description !== category ? option.description : undefined} active={option.key === flat()[store.selected].key} current={option.key === props.current} /> title={option.title}
description={option.description !== category ? option.description : undefined}
active={isDeepEqual(option.value, flat()[store.selected].value)}
current={isDeepEqual(option.value, props.current)} />
} }
</For> </For>
</group> </group>
@ -108,7 +117,7 @@ export function DialogSelect(props: DialogSelectProps) {
</For> </For>
</group> </group>
</group> </group>
<box border={false} paddingRight={2} paddingLeft={3} paddingBottom={1} paddingTop={1} flexDirection="row" > <box border={false} paddingRight={2} paddingLeft={3} paddingBottom={1} flexDirection="row" >
<text fg={Theme.text} attributes={TextAttributes.BOLD}>n</text> <text fg={Theme.text} attributes={TextAttributes.BOLD}>n</text>
<text fg={Theme.textMuted}> new</text> <text fg={Theme.textMuted}> new</text>
<text fg={Theme.text} attributes={TextAttributes.BOLD}>{" "}r</text> <text fg={Theme.text} attributes={TextAttributes.BOLD}>{" "}r</text>

View file

@ -35,7 +35,7 @@ export function Dialog(props: ParentProps) {
<box <box
border={false} border={false}
customBorderChars={Border} customBorderChars={Border}
width={76} width={60}
maxWidth={dimensions().width - 2} maxWidth={dimensions().width - 2}
backgroundColor={Theme.backgroundPanel} backgroundColor={Theme.backgroundPanel}
borderColor={Theme.border} borderColor={Theme.border}

View file

@ -0,0 +1,11 @@
import { createOpencodeClient, createOpencodeServer } from "../src/index"
const client = createOpencodeClient({
baseUrl: "http://localhost:4096",
})
await client.event.subscribe().then(async (event) => {
for await (const e of event.stream) {
console.log(e)
}
})