diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index d797ce987..32e00882b 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -52,8 +52,8 @@ export function DialogModel() { current={local.model.current()} options={options()} onSelect={(option) => { - local.model.set(option.value, { recent: true }) dialog.clear() + local.model.set(option.value, { recent: true }) }} /> ) diff --git a/packages/opencode/src/cli/cmd/tui/context/helper.tsx b/packages/opencode/src/cli/cmd/tui/context/helper.tsx index 14c7561ff..f2ed591cb 100644 --- a/packages/opencode/src/cli/cmd/tui/context/helper.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/helper.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, type ParentProps } from "solid-js" +import { createContext, Show, useContext, type ParentProps } from "solid-js" export function createSimpleContext(input: { name: string; init: () => T }) { const ctx = createContext() @@ -6,7 +6,12 @@ export function createSimpleContext(input: { name: string; init: () => T }) { return { provider: (props: ParentProps) => { const init = input.init() - return {props.children} + return ( + // @ts-expect-error + + {props.children} + + ) }, use() { const value = useContext(ctx) diff --git a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx index 4c8de8a9e..dc221b7bf 100644 --- a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx @@ -1,115 +1,104 @@ -import { createMemo, useContext, type ParentProps } from "solid-js" +import { createMemo } from "solid-js" import { useSync } from "@tui/context/sync" import { Keybind } from "@/util/keybind" import { pipe, mapValues } from "remeda" import type { KeybindsConfig } from "@opencode-ai/sdk" -import { createContext } from "solid-js" import type { ParsedKey, Renderable } from "@opentui/core" import { createStore } from "solid-js/store" import { useKeyboard, useRenderer } from "@opentui/solid" import { Instance } from "@/project/instance" +import { createSimpleContext } from "./helper" -export function init() { - const sync = useSync() - const keybinds = createMemo(() => { - return pipe( - DEFAULT_KEYBINDS, - (val) => Object.assign(val, sync.data.config.keybinds), - mapValues((value) => Keybind.parse(value)), - ) - }) - const [store, setStore] = createStore({ - leader: false, - }) - const renderer = useRenderer() +export const { use: useKeybind, provider: KeybindProvider } = createSimpleContext({ + name: "Keybind", + init: () => { + const sync = useSync() + const keybinds = createMemo(() => { + return pipe( + DEFAULT_KEYBINDS, + (val) => Object.assign(val, sync.data.config.keybinds), + mapValues((value) => Keybind.parse(value)), + ) + }) + const [store, setStore] = createStore({ + leader: false, + }) + const renderer = useRenderer() - let focus: Renderable | null - let timeout: NodeJS.Timeout - function leader(active: boolean) { - if (active) { - setStore("leader", true) - focus = renderer.currentFocusedRenderable - focus?.blur() - if (timeout) clearTimeout(timeout) - timeout = setTimeout(() => { - if (!store.leader) return - leader(false) - }, 2000) - return - } - - if (!active) { - if (focus && !renderer.currentFocusedRenderable) { - focus.focus() + let focus: Renderable | null + let timeout: NodeJS.Timeout + function leader(active: boolean) { + if (active) { + setStore("leader", true) + focus = renderer.currentFocusedRenderable + focus?.blur() + if (timeout) clearTimeout(timeout) + timeout = setTimeout(() => { + if (!store.leader) return + leader(false) + }, 2000) + return } - setStore("leader", false) - } - } - useKeyboard(async (evt) => { - if (!store.leader && result.match("leader", evt)) { - leader(true) - return - } - - if (store.leader && evt.name) { - setImmediate(() => { - leader(false) - }) - } - - if (result.match("app_exit", evt)) { - await Instance.disposeAll() - renderer.destroy() - } - }) - - const result = { - get all() { - return keybinds() - }, - get leader() { - return store.leader - }, - match(key: keyof KeybindsConfig, evt: ParsedKey) { - const keybind = keybinds()[key] - if (!keybind) return false - const parsed: Keybind.Info = { - ctrl: evt.ctrl, - name: evt.name, - shift: false, - leader: store.leader, - option: evt.option, - } - for (const key of keybind) { - if (Keybind.match(key, parsed)) { - return true + if (!active) { + if (focus && !renderer.currentFocusedRenderable) { + focus.focus() } + setStore("leader", false) } - }, - print(key: keyof KeybindsConfig) { - const first = keybinds()[key]?.at(0) - if (!first) return "" - const result = Keybind.toString(first) - return result.replace("", Keybind.toString(keybinds().leader![0]!)) - }, - } - return result -} + } -type Context = ReturnType -const context = createContext() + useKeyboard(async (evt) => { + if (!store.leader && result.match("leader", evt)) { + leader(true) + return + } -export function KeybindProvider(props: ParentProps) { - const value = init() - return {props.children} -} + if (store.leader && evt.name) { + setImmediate(() => { + leader(false) + }) + } -export function useKeybind() { - const result = useContext(context) - if (!result) throw new Error("KeybindProvider not found") - return result -} + if (result.match("app_exit", evt)) { + await Instance.disposeAll() + renderer.destroy() + } + }) + + const result = { + get all() { + return keybinds() + }, + get leader() { + return store.leader + }, + match(key: keyof KeybindsConfig, evt: ParsedKey) { + const keybind = keybinds()[key] + if (!keybind) return false + const parsed: Keybind.Info = { + ctrl: evt.ctrl, + name: evt.name, + shift: false, + leader: store.leader, + option: evt.option, + } + for (const key of keybind) { + if (Keybind.match(key, parsed)) { + return true + } + } + }, + print(key: keyof KeybindsConfig) { + const first = keybinds()[key]?.at(0) + if (!first) return "" + const result = Keybind.toString(first) + return result.replace("", Keybind.toString(keybinds().leader![0]!)) + }, + } + return result + }, +}) const DEFAULT_KEYBINDS: KeybindsConfig = { leader: "ctrl+x", diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index 0b95d514b..198256659 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -1,150 +1,185 @@ import { createStore } from "solid-js/store" -import { batch, createContext, createEffect, createMemo, useContext, type ParentProps } from "solid-js" +import { batch, createEffect, createMemo, createSignal } 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" -function init() { - const sync = useSync() +export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ + name: "Local", + init: () => { + const sync = useSync() - const agent = iife(() => { - const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent")) - const [store, setStore] = createStore<{ - current: string - }>({ - current: agents()[0].name, - }) - return { - list() { - return agents() - }, - current() { - return agents().find((x) => x.name === store.current)! - }, - set(name: string) { - setStore("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, - }) - }, - 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 = iife(() => { - 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) + const agent = iife(() => { + const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent")) + const [store, setStore] = createStore<{ + current: string + }>({ + current: agents()[0].name, }) - .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, + list() { + return agents() + }, + current() { + return agents().find((x) => x.name === store.current)! + }, + set(name: string) { + setStore("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, + }) + }, + 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 current = createMemo(() => { - const a = agent.current() - return store.model[agent.current().name] ?? (a.model ? a.model : fallback()) + const model = iife(() => { + const [store, setStore] = createStore<{ + ready: boolean + model: Record< + string, + { + providerID: string + modelID: string + } + > + recent: { + providerID: string + modelID: string + }[] + }>({ + ready: false, + model: {}, + recent: [], + }) + + const file = Bun.file(path.join(Global.Path.state, "model.json")) + + file + .json() + .then((x) => { + setStore("recent", x.recent) + }) + .catch(() => {}) + .finally(() => { + setStore("ready", true) + }) + + 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, + get ready() { + return store.ready + }, + 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) + } + }) + }, + } }) - 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 kv = iife(() => { + const [ready, setReady] = createSignal(false) + const [store, setStore] = createStore({ + openrouter_warning: false, + }) + const file = Bun.file(path.join(Global.Path.state, "kv.json")) + + file + .json() + .then((x) => { + setStore(x) }) + .catch(() => {}) + .finally(() => { + setReady(true) + }) + + return { + get data() { + return store + }, + get ready() { + return ready() + }, + set(key: string, value: any) { + setStore(key as any, value) + Bun.write( + file, + JSON.stringify({ + [key]: value, + }), + ) + }, + } + }) + + const result = { + model, + agent, + kv, + get ready() { + return kv.ready && model.ready }, } - }) - - const result = { - model, - agent, - } - return result -} - -type LocalContext = ReturnType - -const ctx = createContext() - -export function LocalProvider(props: ParentProps) { - const value = init() - return {props.children} -} - -export function useLocal() { - const value = useContext(ctx) - if (!value) { - throw new Error("useLocal must be used within a LocalProvider") - } - return value -} + return result + }, +}) diff --git a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx index 76c1f7648..7ad10a5e3 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx @@ -1,32 +1,18 @@ -import { createContext, useContext, type ParentProps } from "solid-js" import { createOpencodeClient } from "@opencode-ai/sdk" import { Server } from "@/server/server" +import { createSimpleContext } from "./helper" -function init() { - const client = createOpencodeClient({ - baseUrl: "http://localhost:4096", - // @ts-ignore - fetch: async (r) => { +export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ + name: "SDK", + init: () => { + const client = createOpencodeClient({ + baseUrl: "http://localhost:4096", // @ts-ignore - return Server.App().fetch(r) - }, - }) - return client -} - -type SDKContext = ReturnType - -const ctx = createContext() - -export function SDKProvider(props: ParentProps) { - const value = init() - return {props.children} -} - -export function useSDK() { - const value = useContext(ctx) - if (!value) { - throw new Error("useSDK must be used within a SDKProvider") - } - return value -} + fetch: async (r) => { + // @ts-ignore + return Server.App().fetch(r) + }, + }) + return client + }, +}) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 516791752..4f29ae412 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -1,170 +1,155 @@ import type { Message, Agent, Provider, Session, Part, Config, Todo, Command } from "@opencode-ai/sdk" import { createStore, produce, reconcile } from "solid-js/store" import { useSDK } from "@tui/context/sdk" -import { createContext, Show, useContext, type ParentProps } from "solid-js" import { Binary } from "@/util/binary" +import { createSimpleContext } from "./helper" -function init() { - const [store, setStore] = createStore<{ - ready: boolean - provider: Provider[] - agent: Agent[] - command: Command[] - config: Config - session: Session[] - todo: { - [sessionID: string]: Todo[] - } - message: { - [sessionID: string]: Message[] - } - part: { - [messageID: string]: Part[] - } - }>({ - config: {}, - ready: false, - agent: [], - command: [], - provider: [], - session: [], - todo: {}, - message: {}, - part: {}, - }) +export const { use: useSync, provider: SyncProvider } = createSimpleContext({ + name: "Sync", + init: () => { + const [store, setStore] = createStore<{ + ready: boolean + provider: Provider[] + agent: Agent[] + command: Command[] + config: Config + session: Session[] + todo: { + [sessionID: string]: Todo[] + } + message: { + [sessionID: string]: Message[] + } + part: { + [messageID: string]: Part[] + } + }>({ + config: {}, + ready: false, + agent: [], + command: [], + provider: [], + session: [], + todo: {}, + message: {}, + part: {}, + }) - const sdk = useSDK() + 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)) + 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 } - 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]) + 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 } - 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!)), + sdk.command.list().then((x) => setStore("command", x.data ?? [])), + ]).then(() => setStore("ready", true)) + + const result = { + data: store, + set: setStore, + get ready() { + return store.ready + }, + session: { + get(sessionID: string) { + const match = Binary.search(store.session, sessionID, (s) => s.id) + if (match.found) return store.session[match.index] + return undefined + }, + status(sessionID: string) { + const session = result.session.get(sessionID) + if (!session) return "idle" + if (session.time.compacting) return "compacting" + const messages = store.message[sessionID] ?? [] + const last = messages.at(-1) + if (!last) return "idle" + if (last.role === "user") return "working" + return last.time.completed ? "idle" : "working" + }, + 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 + } + }), + ) + }, + }, } - }) - - 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!)), - sdk.command.list().then((x) => setStore("command", x.data ?? [])), - ]).then(() => setStore("ready", true)) - - const result = { - 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 - }, - status(sessionID: string) { - const session = result.session.get(sessionID) - if (!session) return "idle" - if (session.time.compacting) return "compacting" - const messages = store.message[sessionID] ?? [] - const last = messages.at(-1) - if (!last) return "idle" - if (last.role === "user") return "working" - return last.time.completed ? "idle" : "working" - }, - 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 - } - }), - ) - }, - }, - } - return result -} - -type SyncContext = ReturnType - -const ctx = createContext() - -export function SyncProvider(props: ParentProps) { - const value = init() - return ( - - {props.children} - - ) -} - -export function useSync() { - const value = useContext(ctx) - if (!value) { - throw new Error("useSync must be used within a SyncProvider") - } - return value -} + return result + }, +}) diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 629daffd2..896c37ffa 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -254,83 +254,7 @@ type Theme = { syntaxPunctuation: string } -import { createContext, useContext, createSignal, createEffect, onMount } from "solid-js" -import { Storage } from "@/storage/storage" - export const Theme = Object.entries(OPENCODE_THEME).reduce((acc, [key, value]) => { acc[key as keyof Theme] = value.dark return acc -}, {} as Theme) - -type ThemeMode = "dark" | "light" | "auto" - -interface ThemeContextValue { - mode: () => ThemeMode - setMode: (mode: ThemeMode) => void - currentTheme: () => Theme - isDark: () => boolean -} - -const ThemeContext = createContext() - -export function ThemeProvider(props: { children: any }) { - const [mode, setMode] = createSignal("dark") - - // Load saved theme preference - onMount(async () => { - try { - const saved = await Storage.read(["theme-mode"]).catch(() => null) - if (saved && ["dark", "light", "auto"].includes(saved)) { - setMode(saved) - } - } catch { - // Fallback to default if storage fails - } - }) - - // Save theme preference when it changes - createEffect(async () => { - const currentMode = mode() - try { - await Storage.write(["theme-mode"], currentMode) - } catch { - // Ignore storage errors - } - }) - - // For terminal environment, we'll assume dark mode by default - // since most terminals have dark backgrounds - const isDark = () => { - const currentMode = mode() - if (currentMode === "auto") { - // In terminal context, default to dark for auto mode - return true - } - return currentMode === "dark" - } - - const currentTheme = () => { - const dark = isDark() - return Object.entries(OPENCODE_THEME).reduce((acc, [key, value]) => { - acc[key as keyof Theme] = dark ? value.dark : value.light - return acc - }, {} as Theme) - } - - const value: ThemeContextValue = { - mode, - setMode, - currentTheme, - isDark, - } - - return {props.children} -} - -export function useTheme() { - const context = useContext(ThemeContext) - if (!context) { - throw new Error("useTheme must be used within a ThemeProvider") - } - return context -} +}, {} as Theme) \ No newline at end of file diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx index 842c7a2ab..a86bfecfa 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -1,31 +1,28 @@ import { Installation } from "@/installation" -import { useTheme } from "@tui/context/theme" import { TextAttributes } from "@opentui/core" import { Prompt } from "@tui/component/prompt" import { For } from "solid-js" +import { Theme } from "@tui/context/theme" export function Home() { - const { currentTheme } = useTheme() - const theme = currentTheme() - return ( - + - + new session - + show help - + share session - + list models - + list agents @@ -37,12 +34,12 @@ export function Home() { ) } -function HelpRow(props: { children: string; slash: string; theme: any }) { +function HelpRow(props: { children: string; slash: string }) { return ( - /{props.slash.padEnd(10, " ")} + /{props.slash.padEnd(10, " ")} {props.children.padEnd(19, " ")} - ctrl+x n + ctrl+x n ) } @@ -51,21 +48,21 @@ const LOGO_LEFT = [` `, `█▀▀█ █▀▀█ █▀▀ const LOGO_RIGHT = [` ▄ `, `█▀▀▀ █▀▀█ █▀▀█ █▀▀█`, `█░░░ █░░█ █░░█ █▀▀▀`, `▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`] -function Logo(props: { theme: any }) { +function Logo() { return ( {(line, index) => ( - {line} - + {line} + {LOGO_RIGHT[index()]} )} - {Installation.VERSION} + {Installation.VERSION} ) diff --git a/packages/opencode/src/cli/cmd/tui/tui.tsx b/packages/opencode/src/cli/cmd/tui/tui.tsx index c6746f8fd..bff4fe2d5 100644 --- a/packages/opencode/src/cli/cmd/tui/tui.tsx +++ b/packages/opencode/src/cli/cmd/tui/tui.tsx @@ -2,8 +2,7 @@ import { cmd } from "@/cli/cmd/cmd" import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" import { TextAttributes } from "@opentui/core" import { RouteProvider, useRoute } from "@tui/context/route" -import { Switch, Match, createEffect } from "solid-js" -import { ThemeProvider, useTheme } from "@tui/context/theme" +import { Switch, Match, createEffect, untrack } from "solid-js" import { Installation } from "@/installation" import { Global } from "@/global" import { DialogProvider, useDialog } from "@tui/ui/dialog" @@ -17,10 +16,12 @@ import { DialogSessionList } from "@tui/component/dialog-session-list" import { KeybindProvider, useKeybind } from "@tui/context/keybind" import { Config } from "@/config/config" import { Instance } from "@/project/instance" +import { Theme } from "@tui/context/theme" import { Home } from "@tui/routes/home" import { Session } from "@tui/routes/session" import { PromptHistoryProvider } from "./component/prompt/history" +import { DialogAlert } from "./ui/dialog-alert" export const TuiCommand = cmd({ command: "$0 [project]", @@ -75,23 +76,21 @@ export const TuiCommand = cmd({ () => { return ( - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + ) }, @@ -180,10 +179,21 @@ function App() { }, ]) - const { currentTheme } = useTheme() + createEffect(() => { + const providerID = local.model.current().providerID + if (providerID === "openrouter" && !local.kv.data.openrouter_warning) { + untrack(() => { + DialogAlert.show( + dialog, + "Warning", + "While openrouter is a convenient way to access LLMs your request will often be routed to subpar providers that do not work well in our testing.\n\nFor reliable access to models check out OpenCode Zen\nhttps://opencode.ai/zen", + ).then(() => local.kv.set("openrouter_warning", true)) + }) + } + }) return ( - + @@ -196,27 +206,27 @@ function App() { - - open + + open code - v{Installation.VERSION} + v{Installation.VERSION} - {process.cwd().replace(Global.Path.home, "~")} + {process.cwd().replace(Global.Path.home, "~")} - + tab - + {" "} {local.agent.current().name.toUpperCase()} AGENT diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx new file mode 100644 index 000000000..31eee9f4b --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx @@ -0,0 +1,54 @@ +import { TextAttributes } from "@opentui/core" +import { Theme } from "../context/theme" +import { useDialog, type DialogContext } from "./dialog" +import { useKeyboard } from "@opentui/solid" + +export type DialogAlertProps = { + title: string + message: string + onConfirm?: () => void +} + +export function DialogAlert(props: DialogAlertProps) { + const dialog = useDialog() + + useKeyboard((evt) => { + if (evt.name === "return") { + props.onConfirm?.() + dialog.clear() + } + }) + return ( + + + {props.title} + esc + + + {props.message} + + + { + props.onConfirm?.() + dialog.clear() + }} + > + ok + + + + ) +} + +DialogAlert.show = (dialog: DialogContext, title: string, message: string) => { + return new Promise((resolve) => { + dialog.replace( + () => resolve()} />, + () => resolve(), + ) + }) +} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx new file mode 100644 index 000000000..2363c96d8 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx @@ -0,0 +1,77 @@ +import { TextAttributes } from "@opentui/core" +import { Theme } from "../context/theme" +import { useDialog, type DialogContext } from "./dialog" +import { createStore } from "solid-js/store" +import { For } from "solid-js" +import { useKeyboard } from "@opentui/solid" + +export type DialogConfirmProps = { + title: string + message: string + onConfirm?: () => void + onCancel?: () => void +} + +export function DialogConfirm(props: DialogConfirmProps) { + const dialog = useDialog() + const [store, setStore] = createStore({ + active: "confirm" as "confirm" | "cancel", + }) + + useKeyboard((evt) => { + if (evt.name === "return") { + if (store.active === "confirm") props.onConfirm?.() + if (store.active === "cancel") props.onCancel?.() + dialog.clear() + } + + if (evt.name === "left" || evt.name === "right") { + setStore("active", store.active === "confirm" ? "cancel" : "confirm") + } + }) + return ( + + + {props.title} + esc + + + {props.message} + + + + {(key) => ( + { + if (key === "confirm") props.onConfirm?.() + if (key === "cancel") props.onCancel?.() + dialog.clear() + }} + > + {key} + + )} + + + + ) +} + +DialogConfirm.show = (dialog: DialogContext, title: string, message: string) => { + return new Promise((resolve) => { + dialog.replace( + () => ( + resolve(true)} + onCancel={() => resolve(false)} + /> + ), + () => resolve(false), + ) + }) +} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index 479dff354..dbe1a7136 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -73,7 +73,6 @@ export function DialogSelect(props: DialogSelectProps) { if (next >= flat().length) next = 0 setStore("selected", next) const target = scroll.getChildren().find((child) => { - console.log(child.id) return child.id === JSON.stringify(selected()?.value) }) if (!target) return diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx index f020acd64..03ae2d86f 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx @@ -1,5 +1,5 @@ import { useKeyboard, useTerminalDimensions } from "@opentui/solid" -import { createContext, For, Show, useContext, type JSX, type ParentProps } from "solid-js" +import { batch, createContext, For, Show, useContext, type JSX, type ParentProps } from "solid-js" import { Theme } from "@tui/context/theme" import { RGBA } from "@opentui/core" import { createStore, produce } from "solid-js/store" @@ -51,31 +51,54 @@ export function Dialog( function init() { const [store, setStore] = createStore({ - stack: [] as JSX.Element[], + stack: [] as { + element: JSX.Element + onClose?: () => void + }[], size: "medium" as "medium" | "large", }) useKeyboard((evt) => { if (evt.name === "escape" && store.stack.length > 0) { + const current = store.stack.at(-1)! + current.onClose?.() setStore("stack", store.stack.slice(0, -1)) evt.preventDefault() } }) return { - push(input: JSX.Element) { + push(element: JSX.Element, onClose?: () => void) { setStore( "stack", - produce((val) => val.push(input)), + produce((val) => + val.push({ + element, + onClose, + }), + ), ) }, clear() { - setStore("size", "medium") - setStore("stack", []) + for (const item of store.stack) { + if (item.onClose) item.onClose() + } + batch(() => { + setStore("size", "medium") + setStore("stack", []) + }) }, - replace(input: any) { + replace(input: any, onClose?: () => void) { + for (const item of store.stack) { + if (item.onClose) item.onClose() + } setStore("size", "medium") - setStore("stack", [input]) + setStore("stack", [ + { + element: input, + onClose, + }, + ]) }, get stack() { return store.stack @@ -102,7 +125,7 @@ export function DialogProvider(props: ParentProps) { {(item, index) => ( - {item} + {item.element} )}