Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 51 additions & 22 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { Clipboard } from "@tui/util/clipboard"
import { TextAttributes } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
import { Switch, Match, createEffect, untrack, ErrorBoundary } from "solid-js"
import { RouteProvider, useRoute, type Route } from "@tui/context/route"
import { Switch, Match, createEffect, untrack, ErrorBoundary, createMemo, createSignal } from "solid-js"
import { Installation } from "@/installation"
import { Global } from "@/global"
import { DialogProvider, useDialog } from "@tui/ui/dialog"
import { SDKProvider, useSDK } from "@tui/context/sdk"
import { SyncProvider } from "@tui/context/sync"
import { SyncProvider, useSync } from "@tui/context/sync"
import { LocalProvider, useLocal } from "@tui/context/local"
import { DialogModel } from "@tui/component/dialog-model"
import { DialogStatus } from "@tui/component/dialog-status"
Expand All @@ -20,31 +20,41 @@ import { Home } from "@tui/routes/home"
import { Session } from "@tui/routes/session"
import { PromptHistoryProvider } from "./component/prompt/history"
import { DialogAlert } from "./ui/dialog-alert"
import { ToastProvider, useToast } from "./ui/toast"
import { ExitProvider } from "./context/exit"
import type { SessionRoute } from "./context/route"

export async function tui(input: { url: string; onExit?: () => Promise<void> }) {
export async function tui(input: { url: string; sessionID?: string; model?: string; agent?: string; onExit?: () => Promise<void> }) {
const routeData: Route | undefined = input.sessionID
? {
type: "session",
sessionID: input.sessionID,
}
: undefined
await render(
() => {
return (
<ErrorBoundary fallback={<text>Something went wrong</text>}>
<ExitProvider onExit={input.onExit}>
<RouteProvider>
<SDKProvider url={input.url}>
<SyncProvider>
<LocalProvider>
<KeybindProvider>
<DialogProvider>
<CommandProvider>
<PromptHistoryProvider>
<App />
</PromptHistoryProvider>
</CommandProvider>
</DialogProvider>
</KeybindProvider>
</LocalProvider>
</SyncProvider>
</SDKProvider>
</RouteProvider>
<ToastProvider>
<RouteProvider data={routeData}>
<SDKProvider url={input.url}>
<SyncProvider>
<LocalProvider initialModel={input.model} initialAgent={input.agent}>
<KeybindProvider>
<DialogProvider>
<CommandProvider>
<PromptHistoryProvider>
<App />
</PromptHistoryProvider>
</CommandProvider>
</DialogProvider>
</KeybindProvider>
</LocalProvider>
</SyncProvider>
</SDKProvider>
</RouteProvider>
</ToastProvider>
</ExitProvider>
</ErrorBoundary>
)
Expand All @@ -67,6 +77,9 @@ function App() {
const local = useLocal()
const command = useCommandDialog()
const { event } = useSDK()
const sync = useSync()
const toast = useToast()
const [sessionExists, setSessionExists] = createSignal(false)

useKeyboard(async (evt) => {
if (evt.meta && evt.name === "t") {
Expand All @@ -80,6 +93,22 @@ function App() {
}
})

// Make sure session is valid, otherwise redirect to home
createEffect(async () => {
if (route.data.type === "session") {
const data = route.data as SessionRoute
await sync.session.sync(data.sessionID)
.catch(() => {
toast.show({
message: `Session not found: ${data.sessionID}`,
type: "error",
})
return route.navigate({ type: "home" })
})
setSessionExists(true)
}
})

createEffect(() => {
console.log(JSON.stringify(route.data))
})
Expand Down Expand Up @@ -195,7 +224,7 @@ function App() {
<Match when={route.data.type === "home"}>
<Home />
</Match>
<Match when={route.data.type === "session"}>
<Match when={route.data.type === "session" && sessionExists()}>
<Session />
</Match>
</Switch>
Expand Down
156 changes: 109 additions & 47 deletions packages/opencode/src/cli/cmd/tui/context/local.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,75 @@
import { createStore } from "solid-js/store"
import { batch, createEffect, createMemo, createSignal } from "solid-js"
import { batch, createEffect, createMemo, createSignal, onMount } from "solid-js"
import { useSync } from "@tui/context/sync"
import { Theme } from "@tui/context/theme"
import { uniqueBy } from "remeda"
import path from "path"
import { Global } from "@/global"
import { iife } from "@/util/iife"
import { createSimpleContext } from "./helper"
import { useToast } from "../ui/toast"
import type { Provider } from "@opencode-ai/sdk"

export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
name: "Local",
init: () => {
init: (props: { initialModel?: string; initialAgent?: string }) => {
const sync = useSync()
const toast = useToast()

function isModelValid(model: { providerID: string, modelID: string }) {
const provider = sync.data.provider.find((x) => x.id === model.providerID)
return !!provider?.models[model.modelID]
}

function getFirstValidModel(...modelFns: (() => { providerID: string, modelID: string } | undefined)[]) {
for (const modelFn of modelFns) {
const model = modelFn()
if (!model) continue
if (isModelValid(model))
return model
}
}

// Set initial model if provided
onMount(() => {
batch(() => {
if (props.initialAgent) {
agent.set(props.initialAgent)
}
if (props.initialModel) {
const [providerID, modelID] = props.initialModel.split("/")
if (!providerID || !modelID)
return toast.show({
type: "warning",
message: `Invalid model format: ${props.initialModel}`,
duration: 3000,
})
model.set({ providerID, modelID }, { recent: true })
}
})
})

// Automatically update model when agent changes
createEffect(() => {
const value = agent.current()
if (value.model) {
if (isModelValid(value.model))
model.set({
providerID: value.model.providerID,
modelID: value.model.modelID,
})
else
toast.show({
type: "warning",
message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`,
duration: 3000,
})
}
})

const agent = iife(() => {
const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent"))
const [store, setStore] = createStore<{
const [agentStore, setAgentStore] = createStore<{
current: string
}>({
current: agents()[0].name,
Expand All @@ -25,22 +79,25 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return agents()
},
current() {
return agents().find((x) => x.name === store.current)!
return agents().find((x) => x.name === agentStore.current)!
},
set(name: string) {
setStore("current", name)
if (!agents().some((x) => x.name === name))
return toast.show({
type: "warning",
message: `Agent not found: ${name}`,
duration: 3000,
})
setAgentStore("current", name)
},
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,
})
batch(() => {
let next = agents().findIndex((x) => x.name === agentStore.current) + direction
if (next < 0) next = agents().length - 1
if (next >= agents().length) next = 0
const value = agents()[next]
setAgentStore("current", value.name)
})
},
color(name: string) {
const index = agents().findIndex((x) => x.name === name)
Expand All @@ -51,7 +108,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})

const model = iife(() => {
const [store, setStore] = createStore<{
const [modelStore, setModelStore] = createStore<{
ready: boolean
model: Record<
string,
Expand All @@ -75,43 +132,35 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
file
.json()
.then((x) => {
setStore("recent", x.recent)
setModelStore("recent", x.recent)
})
.catch(() => {})
.catch(() => { })
.finally(() => {
setStore("ready", true)
setModelStore("ready", true)
})

createEffect(() => {
Bun.write(
file,
JSON.stringify({
recent: store.recent,
recent: modelStore.recent,
}),
)
})

const fallback = createMemo(() => {
function isValid(providerID: string, modelID: string) {
const provider = sync.data.provider.find((x) => x.id === providerID)
if (!provider) return false
const model = provider.models[modelID]
if (!model) return false
return true
}

const fallbackModel = createMemo(() => {
if (sync.data.config.model) {
const [providerID, modelID] = sync.data.config.model.split("/")
if (isValid(providerID, modelID)) {
if (isModelValid({ providerID, modelID })) {
return {
providerID,
modelID,
}
}
}

for (const item of store.recent) {
if (isValid(item.providerID, item.modelID)) {
for (const item of modelStore.recent) {
if (isModelValid(item)) {
return item
}
}
Expand All @@ -123,21 +172,25 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
})

const current = createMemo(() => {
const currentModel = createMemo(() => {
const a = agent.current()
return store.model[agent.current().name] ?? (a.model ? a.model : fallback())
return getFirstValidModel(
() => modelStore.model[a.name],
() => a.model,
fallbackModel,
)!
})

return {
current,
current: currentModel,
get ready() {
return store.ready
return modelStore.ready
},
recent() {
return store.recent
return modelStore.recent
},
parsed: createMemo(() => {
const value = current()
const value = currentModel()
const provider = sync.data.provider.find((x) => x.id === value.providerID)!
const model = provider.models[value.modelID]
return {
Expand All @@ -147,11 +200,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}),
set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
batch(() => {
setStore("model", agent.current().name, model)
if (!isModelValid(model)) {
toast.show({
message: `Model ${model.providerID}/${model.modelID} is not valid`,
type: "warning",
duration: 3000,
})
return
}

setModelStore("model", agent.current().name, model)
if (options?.recent) {
const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
const uniq = uniqueBy([model, ...modelStore.recent], (x) => x.providerID + x.modelID)
if (uniq.length > 5) uniq.pop()
setStore("recent", uniq)
setModelStore("recent", uniq)
}
})
},
Expand All @@ -160,30 +222,30 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({

const kv = iife(() => {
const [ready, setReady] = createSignal(false)
const [store, setStore] = createStore({
const [kvStore, setKvStore] = createStore({
openrouter_warning: false,
})
const file = Bun.file(path.join(Global.Path.state, "kv.json"))

file
.json()
.then((x) => {
setStore(x)
setKvStore(x)
})
.catch(() => {})
.catch(() => { })
.finally(() => {
setReady(true)
})

return {
get data() {
return store
return kvStore
},
get ready() {
return ready()
},
set(key: string, value: any) {
setStore(key as any, value)
setKvStore(key as any, value)
Bun.write(
file,
JSON.stringify({
Expand All @@ -204,4 +266,4 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
return result
},
})
})
Loading
Loading