diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index b861deafba..4db4a4aa2c 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -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" @@ -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 }) { +export async function tui(input: { url: string; sessionID?: string; model?: string; agent?: string; onExit?: () => Promise }) { + const routeData: Route | undefined = input.sessionID + ? { + type: "session", + sessionID: input.sessionID, + } + : undefined await render( () => { return ( Something went wrong}> - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + ) @@ -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") { @@ -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)) }) @@ -195,7 +224,7 @@ function App() { - + diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index 61557c31bb..4d78fd7584 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -1,5 +1,5 @@ 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" @@ -7,15 +7,37 @@ 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() + + // 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 }) + } + }) + }) 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, @@ -25,22 +47,38 @@ 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) + if (value.model) { + if (isModelValid(sync.data.provider, 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, + }) + } + }) }, color(name: string) { const index = agents().findIndex((x) => x.name === name) @@ -51,7 +89,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) const model = iife(() => { - const [store, setStore] = createStore<{ + const [modelStore, setModelStore] = createStore<{ ready: boolean model: Record< string, @@ -75,23 +113,23 @@ 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(() => { + const fallbackModel = createMemo(() => { function isValid(providerID: string, modelID: string) { const provider = sync.data.provider.find((x) => x.id === providerID) if (!provider) return false @@ -110,7 +148,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } } - for (const item of store.recent) { + for (const item of modelStore.recent) { if (isValid(item.providerID, item.modelID)) { return item } @@ -123,21 +161,26 @@ 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( + sync.data.provider, + () => 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 { @@ -147,11 +190,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(sync.data.provider, 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) } }) }, @@ -160,7 +212,7 @@ 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")) @@ -168,7 +220,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ file .json() .then((x) => { - setStore(x) + setKvStore(x) }) .catch(() => {}) .finally(() => { @@ -177,13 +229,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ 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({ @@ -205,3 +257,17 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ return result }, }) + +export function isModelValid(providers: Provider[], model: { providerID: string, modelID: string }) { + const provider = providers.find((x) => x.id === model.providerID) + return !!provider?.models[model.modelID] +} + +export function getFirstValidModel(providers: Provider[], ...modelFns: (() => { providerID: string, modelID: string } | undefined)[]) { + for (const modelFn of modelFns) { + const model = modelFn() + if (!model) continue + if (isModelValid(providers, model)) + return model + } +} \ No newline at end of file diff --git a/packages/opencode/src/cli/cmd/tui/context/route.tsx b/packages/opencode/src/cli/cmd/tui/context/route.tsx index f1408a5dc5..ef230dc988 100644 --- a/packages/opencode/src/cli/cmd/tui/context/route.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/route.tsx @@ -1,24 +1,29 @@ import { createStore } from "solid-js/store" import { createSimpleContext } from "./helper" -type Route = - | { - type: "home" - } - | { - type: "session" - sessionID: string - } +export type HomeRoute = { + type: "home" +} + +export type SessionRoute = { + type: "session" + sessionID: string +} + +export type Route = HomeRoute | SessionRoute export const { use: useRoute, provider: RouteProvider } = createSimpleContext({ name: "Route", - init: () => { + init: (props: { data?: Route }) => { const [store, setStore] = createStore( - process.env["OPENCODE_ROUTE"] - ? JSON.parse(process.env["OPENCODE_ROUTE"]) - : { + props.data ?? + ( + process.env["OPENCODE_ROUTE"] + ? JSON.parse(process.env["OPENCODE_ROUTE"]) + : { type: "home", - }, + } + ), ) return { diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index e123529c5a..765fb61961 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -246,7 +246,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }, async sync(sessionID: string) { const [session, messages, todo] = await Promise.all([ - sdk.client.session.get({ path: { id: sessionID } }), + sdk.client.session.get({ path: { id: sessionID }, throwOnError: true }), sdk.client.session.messages({ path: { id: sessionID } }), sdk.client.session.todo({ path: { id: sessionID } }), ]) diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx index 78e310e7c2..5ff1348cc9 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -6,6 +6,7 @@ import type { KeybindsConfig } from "@opencode-ai/sdk" import { Logo } from "../component/logo" import { Locale } from "@/util/locale" import { useSync } from "../context/sync" +import { Toast } from "../ui/toast" export function Home() { const sync = useSync() @@ -44,6 +45,7 @@ export function Home() { + ) } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index b768adde1c..2540b2699c 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -48,8 +48,9 @@ import { DialogConfirm } from "@tui/ui/dialog-confirm" import { DialogTimeline } from "./dialog-timeline" import { Sidebar } from "./sidebar" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" - import parsers from "../../../../../../parsers-config.json" +import { Clipboard } from "../../util/clipboard" +import { Toast, useToast } from "../../ui/toast" addDefaultParsers(parsers.parsers) @@ -83,6 +84,8 @@ export function Session() { createEffect(() => sync.session.sync(route.sessionID)) + const toast = useToast() + const sdk = useSDK() let scroll: ScrollBoxRenderable @@ -173,12 +176,21 @@ export function Session() { keybind: "session_share", disabled: !!session()?.share?.url, category: "Session", - onSelect: (dialog) => { - sdk.client.session.share({ + onSelect: async (dialog) => { + await sdk.client.session.share({ path: { id: route.sessionID, }, }) + .then((res) => + Clipboard.copy(res.data!.share!.url).catch(() => + toast.show({ message: "Failed to copy URL to clipboard", type: "error" }) + ) + ) + .then(() => + toast.show({ message: "Share URL copied to clipboard!", type: "success" }) + ) + .catch(() => toast.show({ message: "Failed to share session", type: "error" })) dialog.clear() }, }, @@ -500,6 +512,7 @@ export function Session() { /> + diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index dde93bdc5d..cadeac17e1 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -3,6 +3,10 @@ import { tui } from "./app" import { Rpc } from "@/util/rpc" import { type rpc } from "./worker" import { upgrade } from "@/cli/upgrade" +import { Session } from "@/session" +import { bootstrap } from "@/cli/bootstrap" +import path from "path" +import { UI } from "@/cli/ui" export const TuiThreadCommand = cmd({ command: "$0 [project]", @@ -13,6 +17,25 @@ export const TuiThreadCommand = cmd({ type: "string", describe: "path to start opencode in", }) + .option("model", { + type: "string", + alias: ["m"], + describe: "model to use in the format of provider/model", + }) + .option("continue", { + alias: ["c"], + describe: "continue the last session", + type: "boolean", + }) + .option("session", { + alias: ["s"], + describe: "session id to continue", + type: "string", + }) + .option("agent", { + type: "string", + describe: "agent to use", + }) .option("port", { type: "number", describe: "port to listen on", @@ -25,25 +48,58 @@ export const TuiThreadCommand = cmd({ default: "127.0.0.1", }), handler: async (args) => { - upgrade() - const worker = new Worker("./src/cli/cmd/tui/worker.ts") - worker.onerror = console.error - const client = Rpc.client(worker) - process.on("uncaughtException", (e) => { - console.error(e) - }) - process.on("unhandledRejection", (e) => { - console.error(e) - }) - const server = await client.call("server", { - port: args.port, - hostname: args.hostname, - }) - await tui({ - url: server.url, - onExit: async () => { - await client.call("shutdown", undefined) - }, + const cwd = args.project ? path.resolve(args.project) : process.cwd() + try { + process.chdir(cwd) + } catch (e) { + UI.error("Failed to change directory to " + cwd) + return + } + await bootstrap(cwd, async () => { + upgrade() + + const sessionID = await (async () => { + if (args.continue) { + const it = Session.list() + try { + for await (const s of it) { + if (s.parentID === undefined) { + return s.id + } + } + return + } finally { + await it.return() + } + } + if (args.session) { + return args.session + } + return undefined + })() + + const worker = new Worker("./src/cli/cmd/tui/worker.ts") + worker.onerror = console.error + const client = Rpc.client(worker) + process.on("uncaughtException", (e) => { + console.error(e) + }) + process.on("unhandledRejection", (e) => { + console.error(e) + }) + const server = await client.call("server", { + port: args.port, + hostname: args.hostname, + }) + await tui({ + url: server.url, + sessionID, + model: args.model, + agent: args.agent, + onExit: async () => { + await client.call("shutdown", undefined) + }, + }) }) }, }) diff --git a/packages/opencode/src/cli/cmd/tui/ui/toast.tsx b/packages/opencode/src/cli/cmd/tui/ui/toast.tsx new file mode 100644 index 0000000000..4d7599189c --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/ui/toast.tsx @@ -0,0 +1,81 @@ +import { createContext, useContext, type ParentProps, Show } from "solid-js" +import { createStore } from "solid-js/store" +import { Theme } from "@tui/context/theme" +import { SplitBorder } from "../component/border" + +export interface ToastOptions { + message: string | null + duration?: number + type: "info" | "success" | "warning" | "error" +} + +export function Toast() { + const toast = useToast() + + return ( + + {(current) => ( + + {current().message} + + )} + + ) +} + +function init() { + const [store, setStore] = createStore({ + currentToast: null as ToastOptions | null, + }) + + let timeoutHandle: NodeJS.Timeout | null = null + + return { + show(options: ToastOptions) { + const { duration, ...currentToast } = options + setStore("currentToast", currentToast) + if (timeoutHandle) clearTimeout(timeoutHandle) + timeoutHandle = setTimeout(() => { + setStore("currentToast", null) + }, duration ?? 5000).unref() + }, + get currentToast(): ToastOptions | null { + return store.currentToast + }, + } +} + +export type ToastContext = ReturnType + +const ctx = createContext() + +export function ToastProvider(props: ParentProps) { + const value = init() + return ( + + {props.children} + + ) +} + +export function useToast() { + const value = useContext(ctx) + if (!value) { + throw new Error("useToast must be used within a ToastProvider") + } + return value +} \ No newline at end of file