warning
Some checks are pending
format / format (push) Waiting to run
snapshot / publish (push) Waiting to run
test / test (push) Waiting to run

This commit is contained in:
Dax Raad 2025-09-28 02:29:10 -04:00
parent 8f732fa1ee
commit 44e8bea4bb
13 changed files with 629 additions and 545 deletions

View file

@ -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 })
}}
/>
)

View file

@ -1,4 +1,4 @@
import { createContext, useContext, type ParentProps } from "solid-js"
import { createContext, Show, useContext, type ParentProps } from "solid-js"
export function createSimpleContext<T>(input: { name: string; init: () => T }) {
const ctx = createContext<T>()
@ -6,7 +6,12 @@ export function createSimpleContext<T>(input: { name: string; init: () => T }) {
return {
provider: (props: ParentProps) => {
const init = input.init()
return <ctx.Provider value={init}>{props.children}</ctx.Provider>
return (
// @ts-expect-error
<Show when={init.ready === undefined || init.ready === true}>
<ctx.Provider value={init}>{props.children}</ctx.Provider>
</Show>
)
},
use() {
const value = useContext(ctx)

View file

@ -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("<leader>", Keybind.toString(keybinds().leader![0]!))
},
}
return result
}
}
type Context = ReturnType<typeof init>
const context = createContext<Context>()
useKeyboard(async (evt) => {
if (!store.leader && result.match("leader", evt)) {
leader(true)
return
}
export function KeybindProvider(props: ParentProps) {
const value = init()
return <context.Provider value={value}>{props.children}</context.Provider>
}
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("<leader>", Keybind.toString(keybinds().leader![0]!))
},
}
return result
},
})
const DEFAULT_KEYBINDS: KeybindsConfig = {
leader: "ctrl+x",

View file

@ -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<typeof init>
const ctx = createContext<LocalContext>()
export function LocalProvider(props: ParentProps) {
const value = init()
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
}
export function useLocal() {
const value = useContext(ctx)
if (!value) {
throw new Error("useLocal must be used within a LocalProvider")
}
return value
}
return result
},
})

View file

@ -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<typeof init>
const ctx = createContext<SDKContext>()
export function SDKProvider(props: ParentProps) {
const value = init()
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
}
export function useSDK() {
const value = useContext(ctx)
if (!value) {
throw new Error("useSDK must be used within a SDKProvider")
}
return value
}
fetch: async (r) => {
// @ts-ignore
return Server.App().fetch(r)
},
})
return client
},
})

View file

@ -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<typeof init>
const ctx = createContext<SyncContext>()
export function SyncProvider(props: ParentProps) {
const value = init()
return (
<Show when={value.data.ready}>
<ctx.Provider value={value}>{props.children}</ctx.Provider>
</Show>
)
}
export function useSync() {
const value = useContext(ctx)
if (!value) {
throw new Error("useSync must be used within a SyncProvider")
}
return value
}
return result
},
})

View file

@ -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<ThemeContextValue>()
export function ThemeProvider(props: { children: any }) {
const [mode, setMode] = createSignal<ThemeMode>("dark")
// Load saved theme preference
onMount(async () => {
try {
const saved = await Storage.read<ThemeMode>(["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 <ThemeContext.Provider value={value}>{props.children}</ThemeContext.Provider>
}
export function useTheme() {
const context = useContext(ThemeContext)
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider")
}
return context
}
}, {} as Theme)

View file

@ -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 (
<box flexGrow={1} justifyContent="center" alignItems="center">
<box>
<Logo theme={theme} />
<Logo />
<box paddingTop={2}>
<HelpRow slash="new" theme={theme}>
<HelpRow slash="new">
new session
</HelpRow>
<HelpRow slash="help" theme={theme}>
<HelpRow slash="help">
show help
</HelpRow>
<HelpRow slash="share" theme={theme}>
<HelpRow slash="share">
share session
</HelpRow>
<HelpRow slash="models" theme={theme}>
<HelpRow slash="models">
list models
</HelpRow>
<HelpRow slash="agents" theme={theme}>
<HelpRow slash="agents">
list agents
</HelpRow>
</box>
@ -37,12 +34,12 @@ export function Home() {
)
}
function HelpRow(props: { children: string; slash: string; theme: any }) {
function HelpRow(props: { children: string; slash: string }) {
return (
<text>
<span style={{ bold: true, fg: props.theme.primary }}>/{props.slash.padEnd(10, " ")}</span>
<span style={{ bold: true, fg: Theme.primary }}>/{props.slash.padEnd(10, " ")}</span>
<span>{props.children.padEnd(19, " ")} </span>
<span style={{ fg: props.theme.textMuted }}>ctrl+x n</span>
<span style={{ fg: Theme.textMuted }}>ctrl+x n</span>
</text>
)
}
@ -51,21 +48,21 @@ const LOGO_LEFT = [` `, `█▀▀█ █▀▀█ █▀▀
const LOGO_RIGHT = [``, `█▀▀▀ █▀▀█ █▀▀█ █▀▀█`, `█░░░ █░░█ █░░█ █▀▀▀`, `▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`]
function Logo(props: { theme: any }) {
function Logo() {
return (
<box>
<For each={LOGO_LEFT}>
{(line, index) => (
<box flexDirection="row" gap={1}>
<text fg={props.theme.textMuted}>{line}</text>
<text fg={props.theme.text} attributes={TextAttributes.BOLD}>
<text fg={Theme.textMuted}>{line}</text>
<text fg={Theme.text} attributes={TextAttributes.BOLD}>
{LOGO_RIGHT[index()]}
</text>
</box>
)}
</For>
<box flexDirection="row" justifyContent="flex-end">
<text fg={props.theme.textMuted}>{Installation.VERSION}</text>
<text fg={Theme.textMuted}>{Installation.VERSION}</text>
</box>
</box>
)

View file

@ -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 (
<RouteProvider>
<ThemeProvider>
<SDKProvider>
<SyncProvider>
<LocalProvider>
<KeybindProvider>
<DialogProvider>
<CommandProvider>
<PromptHistoryProvider>
<App />
</PromptHistoryProvider>
</CommandProvider>
</DialogProvider>
</KeybindProvider>
</LocalProvider>
</SyncProvider>
</SDKProvider>
</ThemeProvider>
<SDKProvider>
<SyncProvider>
<LocalProvider>
<KeybindProvider>
<DialogProvider>
<CommandProvider>
<PromptHistoryProvider>
<App />
</PromptHistoryProvider>
</CommandProvider>
</DialogProvider>
</KeybindProvider>
</LocalProvider>
</SyncProvider>
</SDKProvider>
</RouteProvider>
)
},
@ -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 (
<box width={dimensions().width} height={dimensions().height} backgroundColor={currentTheme().background}>
<box width={dimensions().width} height={dimensions().height} backgroundColor={Theme.background}>
<box flexDirection="column" flexGrow={1}>
<Switch>
<Match when={route.data.type === "home"}>
@ -196,27 +206,27 @@ function App() {
</box>
<box
height={1}
backgroundColor={currentTheme().backgroundPanel}
backgroundColor={Theme.backgroundPanel}
flexDirection="row"
justifyContent="space-between"
flexShrink={0}
>
<box flexDirection="row">
<box flexDirection="row" backgroundColor={currentTheme().backgroundElement} paddingLeft={1} paddingRight={1}>
<text fg={currentTheme().textMuted}>open</text>
<box flexDirection="row" backgroundColor={Theme.backgroundElement} paddingLeft={1} paddingRight={1}>
<text fg={Theme.textMuted}>open</text>
<text attributes={TextAttributes.BOLD}>code </text>
<text fg={currentTheme().textMuted}>v{Installation.VERSION}</text>
<text fg={Theme.textMuted}>v{Installation.VERSION}</text>
</box>
<box paddingLeft={1} paddingRight={1}>
<text fg={currentTheme().textMuted}>{process.cwd().replace(Global.Path.home, "~")}</text>
<text fg={Theme.textMuted}>{process.cwd().replace(Global.Path.home, "~")}</text>
</box>
</box>
<box flexDirection="row" flexShrink={0}>
<text fg={currentTheme().textMuted} paddingRight={1}>
<text fg={Theme.textMuted} paddingRight={1}>
tab
</text>
<text fg={local.agent.color(local.agent.current().name)}></text>
<text bg={local.agent.color(local.agent.current().name)} fg={currentTheme().background} wrap={false}>
<text bg={local.agent.color(local.agent.current().name)} fg={Theme.background} wrap={false}>
{" "}
<span style={{ bold: true }}>{local.agent.current().name.toUpperCase()}</span>
<span> AGENT </span>

View file

@ -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 (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD}>{props.title}</text>
<text fg={Theme.textMuted}>esc</text>
</box>
<box paddingBottom={1}>
<text fg={Theme.textMuted}>{props.message}</text>
</box>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
<box
paddingLeft={3}
paddingRight={3}
backgroundColor={Theme.primary}
onMouseDown={() => {
props.onConfirm?.()
dialog.clear()
}}
>
<text fg={Theme.background}>ok</text>
</box>
</box>
</box>
)
}
DialogAlert.show = (dialog: DialogContext, title: string, message: string) => {
return new Promise<void>((resolve) => {
dialog.replace(
() => <DialogAlert title={title} message={message} onConfirm={() => resolve()} />,
() => resolve(),
)
})
}

View file

@ -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 (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD}>{props.title}</text>
<text fg={Theme.textMuted}>esc</text>
</box>
<box paddingBottom={1}>
<text fg={Theme.textMuted}>{props.message}</text>
</box>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
<For each={["cancel", "confirm"]}>
{(key) => (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={key === store.active ? Theme.primary : undefined}
onMouseDown={() => {
if (key === "confirm") props.onConfirm?.()
if (key === "cancel") props.onCancel?.()
dialog.clear()
}}
>
<text fg={key === store.active ? Theme.background : Theme.textMuted}>{key}</text>
</box>
)}
</For>
</box>
</box>
)
}
DialogConfirm.show = (dialog: DialogContext, title: string, message: string) => {
return new Promise<boolean>((resolve) => {
dialog.replace(
() => (
<DialogConfirm
title={title}
message={message}
onConfirm={() => resolve(true)}
onCancel={() => resolve(false)}
/>
),
() => resolve(false),
)
})
}

View file

@ -73,7 +73,6 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
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

View file

@ -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) {
<For each={value.stack}>
{(item, index) => (
<Show when={index() === 0}>
<Dialog size={value.size}>{item}</Dialog>
<Dialog size={value.size}>{item.element}</Dialog>
</Show>
)}
</For>