feat: add opentui interface for opencode

- Add comprehensive TUI interface with React Ink components
- Implement session management, model selection, and command dialogs
- Add theme system with customizable colors and styling
- Integrate with existing opencode server and SDK
- Add todo management and file browsing capabilities
- Include proper TypeScript support and error handling
This commit is contained in:
Dax Raad
2025-09-19 17:21:04 -04:00
parent f1cbdf441c
commit 02848a350c
69 changed files with 3240 additions and 349 deletions
+1 -1
View File
@@ -80,7 +80,7 @@ export const AuthLoginCommand = cmd({
UI.empty()
prompts.intro("Add credential")
if (args.url) {
const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json())
const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json() as any)
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
const proc = Bun.spawn({
cmd: wellknown.auth.command,
+2 -2
View File
@@ -1,5 +1,4 @@
import path from "path"
import { $ } from "bun"
import { exec } from "child_process"
import * as prompts from "@clack/prompts"
import { map, pipe, sortBy, values } from "remeda"
@@ -7,6 +6,7 @@ import { UI } from "../ui"
import { cmd } from "./cmd"
import { ModelsDev } from "../../provider/models"
import { Instance } from "../../project/instance"
import { $ } from "bun"
const WORKFLOW_FILE = ".github/workflows/opencode.yml"
@@ -196,7 +196,7 @@ export const GithubInstallCommand = cmd({
`https://api.opencode.ai/get_github_app_installation?owner=${app.owner}&repo=${app.repo}`,
)
.then((res) => res.json())
.then((data) => data.installation)
.then((data: any) => data.installation)
}
}
@@ -0,0 +1,19 @@
import { Theme } from "../context/theme"
export const SplitBorder = {
border: ["left" as const, "right" as const],
borderColor: Theme.border,
customBorderChars: {
topLeft: "",
bottomLeft: "",
vertical: "┃",
topRight: "",
bottomRight: "",
horizontal: "",
bottomT: "",
topT: "",
cross: "",
leftT: "",
rightT: "",
},
}
@@ -0,0 +1,52 @@
import { useDialog } from "../ui/dialog"
import { DialogModel } from "./dialog-model"
import { DialogSelect } from "../ui/dialog-select"
import { useRoute } from "../context/route"
import { DialogSessionList } from "./dialog-session-list"
export function DialogCommand() {
const dialog = useDialog()
const route = useRoute()
return (
<DialogSelect
title="Commands"
options={[
{
title: "Switch model",
value: "switch-model",
category: "Agent",
onSelect: () => {
dialog.replace(() => <DialogModel />)
},
},
{
title: "Switch session",
value: "switch-session",
category: "Session",
onSelect: () => {
dialog.replace(() => <DialogSessionList />)
},
},
{
title: "New session",
value: "new-session",
category: "Session",
onSelect: () => {
route.navigate({
type: "home",
})
dialog.clear()
},
},
{
title: "Share session",
value: "share-session",
category: "Session",
onSelect: () => {
console.log("share session")
},
},
]}
/>
)
}
@@ -0,0 +1,60 @@
import { createMemo } from "solid-js"
import { useLocal } from "../context/local"
import { useSync } from "../context/sync"
import { map, pipe, flatMap, entries, filter, isDeepEqual } from "remeda"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
export function DialogModel() {
const local = useLocal()
const sync = useSync()
const dialog = useDialog()
const options = createMemo(() => [
...local.model.recent().map((item) => {
const provider = sync.data.provider.find((x) => x.id === item.providerID)!
const model = provider.models[item.modelID]
return {
key: item,
value: {
providerID: provider.id,
modelID: model.id,
},
title: model.name ?? item.modelID,
description: provider.name,
category: "Recent",
}
}),
...pipe(
sync.data.provider,
flatMap((provider) =>
pipe(
provider.models,
entries(),
map(([model, info]) => ({
value: {
providerID: provider.id,
modelID: model,
},
title: info.name ?? model,
description: provider.name,
category: provider.name,
})),
filter((x) => !local.model.recent().find((y) => isDeepEqual(y, x.value))),
),
),
),
])
return (
<DialogSelect
title="Select model"
current={local.model.current()}
options={options()}
onSelect={(option) => {
local.model.set(option.value, { recent: true })
dialog.clear()
}}
/>
)
}
@@ -0,0 +1,44 @@
import { useDialog } from "../ui/dialog"
import { DialogSelect } from "../ui/dialog-select"
import { useRoute } from "../context/route"
import { useSync } from "../context/sync"
import { createMemo, onMount } from "solid-js"
export function DialogSessionList() {
const dialog = useDialog()
const sync = useSync()
const route = useRoute()
const options = createMemo(() => {
const today = new Date().toDateString()
return sync.data.session.map((x) => {
let category = new Date(x.time.created).toDateString()
if (category === today) {
category = "Today"
}
return {
title: x.title,
value: x.id,
category,
}
})
})
onMount(() => {
dialog.setSize("large")
})
return (
<DialogSelect
title="Sessions"
options={options()}
onSelect={(option) => {
route.navigate({
type: "session",
sessionID: option.value,
})
dialog.clear()
}}
/>
)
}
@@ -0,0 +1,46 @@
import { createMemo, createResource } from "solid-js"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useSDK } from "../context/sdk"
import { createStore } from "solid-js/store"
export function DialogTag(props: { onSelect?: (value: string) => void }) {
const sdk = useSDK()
const dialog = useDialog()
const [store] = createStore({
filter: "",
})
const [files] = createResource(
() => [store.filter],
async () => {
const result = await sdk.find.files({
query: {
query: store.filter,
},
})
if (result.error) return []
const sliced = (result.data ?? []).slice(0, 5)
return sliced
},
)
const options = createMemo(() =>
(files() ?? []).map((file) => ({
value: file,
title: file,
})),
)
return (
<DialogSelect
title="Autocomplete"
options={options()}
onSelect={(option) => {
props.onSelect?.(option.value)
dialog.clear()
}}
/>
)
}
@@ -0,0 +1,352 @@
import { InputRenderable, TextAttributes, BoxRenderable, type ParsedKey } from "@opentui/core"
import { createEffect, createMemo, createResource, For, Match, onMount, Switch } from "solid-js"
import { useLocal } from "../context/local"
import { Theme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { SplitBorder } from "./border"
import { useSDK } from "../context/sdk"
import { useRoute } from "../context/route"
import { useSync } from "../context/sync"
import { Identifier } from "../../../../id/id"
import { createStore, produce } from "solid-js/store"
import type { FilePart } from "@opencode-ai/sdk"
import { Instance } from "../../../../project/instance"
import fuzzysort from "fuzzysort"
export type PromptProps = {
sessionID?: string
}
type Prompt = {
input: string
parts: Omit<FilePart, "id" | "messageID" | "sessionID">[]
}
export function Prompt(props: PromptProps) {
let input: InputRenderable
let anchor: BoxRenderable
let autocomplete: AutocompleteRef
const dialog = useDialog()
const local = useLocal()
const sdk = useSDK()
const route = useRoute()
const sync = useSync()
const [store, setStore] = createStore<Prompt>({
input: "",
parts: [],
})
const messages = createMemo(() => {
if (!props.sessionID) return []
return sync.data.message[props.sessionID] ?? []
})
const working = createMemo(() => {
const last = messages()[messages().length - 1]
if (!last) return false
if (last.role === "user") return true
return !last.time.completed
})
createEffect(() => {
if (dialog.stack.length === 0 && input) input.focus()
if (dialog.stack.length > 0) input.blur()
})
return (
<>
<Autocomplete
ref={(r) => (autocomplete = r)}
anchor={() => anchor}
input={() => input}
setPrompt={(cb) => {
setStore(produce(cb))
input.cursorPosition = store.input.length
}}
value={store.input}
/>
<box ref={(r) => (anchor = r)}>
<box flexDirection="row" {...SplitBorder}>
<box backgroundColor={Theme.backgroundElement} width={3} justifyContent="center" alignItems="center">
<text attributes={TextAttributes.BOLD} fg={Theme.primary}>
{">"}
</text>
</box>
<box paddingTop={1} paddingBottom={2} backgroundColor={Theme.backgroundElement} flexGrow={1}>
<input
onInput={(value) => {
let diff = value.length - store.input.length
setStore(
produce((draft) => {
draft.input = value
for (let i = 0; i < draft.parts.length; i++) {
const part = draft.parts[i]
if (!part.source) continue
if (part.source.text.start >= input.cursorPosition) {
part.source.text.start += diff
part.source.text.end += diff
}
const sliced = draft.input.slice(part.source.text.start, part.source.text.end)
if (sliced != part.source.text.value && diff < 0) {
diff -= part.source.text.value.length
draft.input =
draft.input.slice(0, part.source.text.start) + draft.input.slice(part.source.text.end)
draft.parts.splice(i, 1)
input.cursorPosition = Math.max(0, part.source.text.start - 1)
i--
}
}
}),
)
autocomplete.onInput(value)
}}
value={store.input}
onKeyDown={(e) => {
autocomplete.onKeyDown(e)
const old = input.cursorPosition
setTimeout(() => {
const position = input.cursorPosition
const direction = Math.sign(old - position)
for (const part of store.parts) {
if (part.source && part.source.type === "file") {
if (position >= part.source.text.start && position < part.source.text.end) {
if (direction === 1) {
input.cursorPosition = Math.max(0, part.source.text.start - 1)
}
if (direction === -1) {
input.cursorPosition = part.source.text.end
}
}
}
}
}, 0)
}}
onSubmit={async () => {
if (autocomplete.visible) return
if (!store.input) return
const sessionID = props.sessionID
? props.sessionID
: await (async () => {
const sessionID = await sdk.session.create({}).then((x) => x.data!.id)
route.navigate({
type: "session",
sessionID,
})
return sessionID
})()
const messageID = Identifier.ascending("message")
const input = store.input
const parts = store.parts
setStore({
input: "",
parts: [],
})
await sdk.session.prompt({
path: {
id: sessionID,
},
body: {
...local.model.current(),
messageID,
agent: local.agent.current().name,
model: local.model.current(),
parts: [
{
id: Identifier.ascending("part"),
type: "text",
text: input,
},
...parts.map((x) => ({
id: Identifier.ascending("part"),
...x,
})),
],
},
})
}}
ref={(r) => (input = r)}
onMouseDown={(r) => r.target?.focus()}
focusedBackgroundColor={Theme.backgroundElement}
cursorColor={Theme.primary}
backgroundColor={Theme.backgroundElement}
/>
</box>
<box backgroundColor={Theme.backgroundElement} width={1} justifyContent="center" alignItems="center"></box>
</box>
<box paddingLeft={2} paddingRight={1} flexDirection="row" justifyContent="space-between">
<Switch>
<Match when={working()}>
<text>working...</text>
</Match>
<Match when={true}>
<text>
enter <span style={{ fg: Theme.textMuted }}>send</span>
</text>
</Match>
</Switch>
<text>
<span style={{ fg: Theme.textMuted }}>{local.model.parsed().provider}</span>{" "}
<span style={{ bold: true }}>{local.model.parsed().model}</span>
</text>
</box>
</box>
</>
)
}
type AutocompleteRef = {
onInput: (value: string) => void
onKeyDown: (e: ParsedKey) => void
visible: boolean
}
function Autocomplete(props: {
value: string
setPrompt: (input: (prompt: Prompt) => void) => void
anchor: () => BoxRenderable
input: () => InputRenderable
ref: (ref: AutocompleteRef) => void
}) {
const sdk = useSDK()
const [store, setStore] = createStore({
index: 0,
selected: 0,
visible: false,
position: { x: 0, y: 0, width: 0 },
})
const filter = createMemo(() => {
if (!store.visible) return ""
return props.value.substring(store.index + 1)
})
const [files] = createResource(
() => [filter()],
async () => {
if (!store.visible) return []
const result = await sdk.find.files({
query: {
query: filter(),
},
})
if (result.error) return []
return result.data ?? []
},
{
initialValue: [],
},
)
const options = createMemo(() => {
const mixed = [...files().map((x) => ({ type: "file", value: x }))]
const result = fuzzysort.go(filter(), mixed, {
keys: ["value"],
})
return result.map((arr) => arr.obj)
})
createEffect(() => {
filter()
setStore("selected", 0)
})
function move(direction: -1 | 1) {
if (!store.visible) return
let next = store.selected + direction
if (next < 0) next = files().length - 1
if (next >= files().length) next = 0
setStore("selected", next)
}
function show() {
setStore({
visible: true,
index: props.input().cursorPosition,
position: {
x: props.anchor().x,
y: props.anchor().y,
width: props.anchor().width,
},
})
}
function hide() {
setStore("visible", false)
}
onMount(() => {
props.ref({
get visible() {
return store.visible
},
onInput(value: string) {
if (value.length <= store.index) hide()
},
onKeyDown(e: ParsedKey) {
if (store.visible) {
if (e.name === "up") move(-1)
if (e.name === "down") move(1)
if (e.name === "escape") hide()
if (e.name === "return") {
const file = files()[store.selected]
if (!file) return
const part: Prompt["parts"][number] = {
type: "file",
mime: "text/plain",
filename: file,
url: `file://${Instance.directory}/${file}`,
source: {
type: "file",
text: {
start: store.index,
end: store.index + file.length + 1,
value: "@" + file,
},
path: file,
},
}
props.setPrompt((draft) => {
const append = "@" + file + " "
if (store.index === 0) draft.input = append
if (store.index > 0) draft.input = draft.input.slice(0, store.index) + append
draft.parts.push(part)
})
setTimeout(() => hide(), 0)
}
}
if (!store.visible && e.name === "@") {
const last = props.value.at(-1)
if (last === " " || last === undefined) {
show()
}
}
},
})
})
return (
<box
visible={store.visible}
position="absolute"
top={store.position.y - 10}
left={store.position.x}
width={store.position.width}
zIndex={100}
{...SplitBorder}
>
<box backgroundColor={Theme.backgroundElement} height={10}>
<For each={options()}>
{(option, index) => (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={index() === store.selected ? Theme.primary : undefined}
>
<text fg={index() === store.selected ? Theme.background : Theme.text}>{option.value}</text>
</box>
)}
</For>
</box>
</box>
)
}
@@ -0,0 +1,144 @@
import { createStore } from "solid-js/store"
import { batch, createContext, createEffect, createMemo, useContext, type ParentProps } from "solid-js"
import { useSync } from "./sync"
import { Theme } from "./theme"
import { uniqueBy } from "remeda"
import path from "path"
import { Global } from "../../../../global"
function init() {
const sync = useSync()
const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent"))
const agent = (() => {
const [store, setStore] = createStore<{
current: string
}>({
current: agents()[0].name,
})
return {
current() {
return agents().find((x) => x.name === store.current)!
},
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 = (() => {
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)
})
.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,
}
})
const current = createMemo(() => {
const a = agent.current()
return store.model[agent.current().name] ?? (a.model ? a.model : fallback())
})
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 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
}
@@ -0,0 +1,54 @@
import { createStore } from "solid-js/store"
import { createContext, useContext, type ParentProps } from "solid-js"
type Route =
| {
type: "home"
}
| {
type: "session"
sessionID: string
}
function init() {
const [store, setStore] = createStore<Route>(
process.env["OPENCODE_ROUTE"]
? JSON.parse(process.env["OPENCODE_ROUTE"])
: {
type: "home",
},
)
return {
get data() {
return store
},
navigate(route: Route) {
console.log("navigate", route)
setStore(route)
},
}
}
export type RouteContext = ReturnType<typeof init>
const ctx = createContext<RouteContext>()
export function RouteProvider(props: ParentProps) {
const value = init()
// @ts-ignore
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
}
export function useRoute() {
const value = useContext(ctx)
if (!value) {
throw new Error("useRoute must be used within a RouteProvider")
}
return value
}
export function useRouteData<T extends Route["type"]>(type: T) {
const route = useRoute()
return route.data as Extract<Route, { type: typeof type }>
}
@@ -0,0 +1,32 @@
import { createContext, useContext, type ParentProps } from "solid-js"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { Server } from "../../../../server/server"
function init() {
const client = createOpencodeClient({
baseUrl: "http://localhost:4096",
// @ts-ignore
fetch: async (a) => {
// @ts-ignore
return Server.App().fetch(a)
},
})
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
}
@@ -0,0 +1,156 @@
import type { Message, Agent, Provider, Session, Part, Config, Todo } from "@opencode-ai/sdk"
import { createStore, produce, reconcile } from "solid-js/store"
import { useSDK } from "./sdk"
import { createContext, Show, useContext, type ParentProps } from "solid-js"
import { Binary } from "../../../../util/binary"
function init() {
const [store, setStore] = createStore<{
ready: boolean
provider: Provider[]
agent: Agent[]
config: Config
session: Session[]
todo: {
[sessionID: string]: Todo[]
}
message: {
[sessionID: string]: Message[]
}
part: {
[messageID: string]: Part[]
}
}>({
config: {},
ready: false,
agent: [],
provider: [],
session: [],
todo: {},
message: {},
part: {},
})
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))
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
}
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!)),
]).then(() => setStore("ready", true))
return {
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
},
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
}
}),
)
},
},
}
}
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
}
@@ -0,0 +1,260 @@
const OPENCODE_THEME = {
primary: {
dark: "#fab283",
light: "#3b7dd8",
},
secondary: {
dark: "#5c9cf5",
light: "#7b5bb6",
},
accent: {
dark: "#9d7cd8",
light: "#d68c27",
},
error: {
dark: "#e06c75",
light: "#d1383d",
},
warning: {
dark: "#f5a742",
light: "#d68c27",
},
success: {
dark: "#7fd88f",
light: "#3d9a57",
},
info: {
dark: "#56b6c2",
light: "#318795",
},
text: {
dark: "#eeeeee",
light: "#1a1a1a",
},
textMuted: {
dark: "#808080",
light: "#8a8a8a",
},
background: {
dark: "#0a0a0a",
light: "#ffffff",
},
backgroundPanel: {
dark: "#141414",
light: "#fafafa",
},
backgroundElement: {
dark: "#1e1e1e",
light: "#f5f5f5",
},
border: {
dark: "#484848",
light: "#b8b8b8",
},
borderActive: {
dark: "#606060",
light: "#a0a0a0",
},
borderSubtle: {
dark: "#3c3c3c",
light: "#d4d4d4",
},
diffAdded: {
dark: "#4fd6be",
light: "#1e725c",
},
diffRemoved: {
dark: "#c53b53",
light: "#c53b53",
},
diffContext: {
dark: "#828bb8",
light: "#7086b5",
},
diffHunkHeader: {
dark: "#828bb8",
light: "#7086b5",
},
diffHighlightAdded: {
dark: "#b8db87",
light: "#4db380",
},
diffHighlightRemoved: {
dark: "#e26a75",
light: "#f52a65",
},
diffAddedBg: {
dark: "#20303b",
light: "#d5e5d5",
},
diffRemovedBg: {
dark: "#37222c",
light: "#f7d8db",
},
diffContextBg: {
dark: "#141414",
light: "#fafafa",
},
diffLineNumber: {
dark: "#1e1e1e",
light: "#f5f5f5",
},
diffAddedLineNumberBg: {
dark: "#1b2b34",
light: "#c5d5c5",
},
diffRemovedLineNumberBg: {
dark: "#2d1f26",
light: "#e7c8cb",
},
markdownText: {
dark: "#eeeeee",
light: "#1a1a1a",
},
markdownHeading: {
dark: "#9d7cd8",
light: "#d68c27",
},
markdownLink: {
dark: "#fab283",
light: "#3b7dd8",
},
markdownLinkText: {
dark: "#56b6c2",
light: "#318795",
},
markdownCode: {
dark: "#7fd88f",
light: "#3d9a57",
},
markdownBlockQuote: {
dark: "#e5c07b",
light: "#b0851f",
},
markdownEmph: {
dark: "#e5c07b",
light: "#b0851f",
},
markdownStrong: {
dark: "#f5a742",
light: "#d68c27",
},
markdownHorizontalRule: {
dark: "#808080",
light: "#8a8a8a",
},
markdownListItem: {
dark: "#fab283",
light: "#3b7dd8",
},
markdownListEnumeration: {
dark: "#56b6c2",
light: "#318795",
},
markdownImage: {
dark: "#fab283",
light: "#3b7dd8",
},
markdownImageText: {
dark: "#56b6c2",
light: "#318795",
},
markdownCodeBlock: {
dark: "#eeeeee",
light: "#1a1a1a",
},
syntaxComment: {
dark: "#808080",
light: "#8a8a8a",
},
syntaxKeyword: {
dark: "#9d7cd8",
light: "#d68c27",
},
syntaxFunction: {
dark: "#fab283",
light: "#3b7dd8",
},
syntaxVariable: {
dark: "#e06c75",
light: "#d1383d",
},
syntaxString: {
dark: "#7fd88f",
light: "#3d9a57",
},
syntaxNumber: {
dark: "#f5a742",
light: "#d68c27",
},
syntaxType: {
dark: "#e5c07b",
light: "#b0851f",
},
syntaxOperator: {
dark: "#56b6c2",
light: "#318795",
},
syntaxPunctuation: {
dark: "#eeeeee",
light: "#1a1a1a",
},
} as const
type Theme = {
primary: string
secondary: string
accent: string
error: string
warning: string
success: string
info: string
text: string
textMuted: string
background: string
backgroundPanel: string
backgroundElement: string
border: string
borderActive: string
borderSubtle: string
diffAdded: string
diffRemoved: string
diffContext: string
diffHunkHeader: string
diffHighlightAdded: string
diffHighlightRemoved: string
diffAddedBg: string
diffRemovedBg: string
diffContextBg: string
diffLineNumber: string
diffAddedLineNumberBg: string
diffRemovedLineNumberBg: string
markdownText: string
markdownHeading: {}
markdownLink: string
markdownLinkText: string
markdownCode: string
markdownBlockQuote: string
markdownEmph: string
markdownStrong: string
markdownHorizontalRule: string
markdownListItem: string
markdownListEnumeration: {}
markdownImage: string
markdownImageText: string
markdownCodeBlock: string
syntaxComment: string
syntaxKeyword: string
syntaxFunction: string
syntaxVariable: string
syntaxString: string
syntaxNumber: string
syntaxType: string
syntaxOperator: string
syntaxPunctuation: string
}
export const Theme = Object.entries(OPENCODE_THEME).reduce((acc, [key, value]) => {
acc[key as keyof Theme] = value.dark
return acc
}, {} as Theme)
@@ -0,0 +1,58 @@
import { Installation } from "../../../installation"
import { Theme } from "./context/theme"
import { TextAttributes } from "@opentui/core"
import { Prompt } from "./component/prompt"
export function Home() {
return (
<box flexGrow={1} justifyContent="center" alignItems="center">
<box>
<Logo />
<box paddingTop={2}>
<HelpRow slash="new">new session</HelpRow>
<HelpRow slash="help">show help</HelpRow>
<HelpRow slash="share">share session</HelpRow>
<HelpRow slash="models">list models</HelpRow>
<HelpRow slash="agents">list agents</HelpRow>
</box>
</box>
<box paddingTop={3} minWidth={75}>
<Prompt />
</box>
</box>
)
}
function HelpRow(props: { children: string; slash: string }) {
return (
<text>
<span style={{ bold: true, fg: Theme.primary }}>/{props.slash.padEnd(10, " ")}</span>
<span>{props.children.padEnd(15, " ")} </span>
<span style={{ fg: Theme.textMuted }}>ctrl+x n</span>
</text>
)
}
function Logo() {
return (
<box>
<box flexDirection="row">
<text fg={Theme.textMuted}>{"█▀▀█ █▀▀█ █▀▀ █▀▀▄"}</text>
<text fg={Theme.text} attributes={TextAttributes.BOLD}>
{" █▀▀ █▀▀█ █▀▀▄ █▀▀"}
</text>
</box>
<box flexDirection="row">
<text fg={Theme.textMuted}>{`█░░█ █░░█ █▀▀ █░░█`}</text>
<text fg={Theme.text}>{` █░░ █░░█ █░░█ █▀▀`}</text>
</box>
<box flexDirection="row">
<text fg={Theme.textMuted}>{`▀▀▀▀ █▀▀▀ ▀▀▀ ▀ ▀`}</text>
<text fg={Theme.text}>{` ▀▀▀ ▀▀▀▀ ▀▀▀ ▀▀▀`}</text>
</box>
<box flexDirection="row" justifyContent="flex-end">
<text fg={Theme.textMuted}>{Installation.VERSION}</text>
</box>
</box>
)
}
@@ -0,0 +1,121 @@
import { cmd } from "../cmd"
import { render, useKeyHandler, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { TextAttributes } from "@opentui/core"
import { RouteProvider, useRoute } from "./context/route"
import { Home } from "./home"
import { Switch, Match, createEffect } from "solid-js"
import { Theme } from "./context/theme"
import { Installation } from "../../../installation"
import { Global } from "../../../global"
import { DialogProvider, useDialog } from "./ui/dialog"
import { bootstrap } from "../../bootstrap"
import { SDKProvider } from "./context/sdk"
import { SyncProvider } from "./context/sync"
import { LocalProvider, useLocal } from "./context/local"
import { DialogModel } from "./component/dialog-model"
import { DialogCommand } from "./component/dialog-command"
import { Session } from "./session"
export const OpentuiCommand = cmd({
command: "opentui",
describe: "print hello",
handler: async () => {
await bootstrap(process.cwd(), async () => {
await render(
() => (
<RouteProvider>
<SDKProvider>
<SyncProvider>
<LocalProvider>
<DialogProvider>
<App />
</DialogProvider>
</LocalProvider>
</SyncProvider>
</SDKProvider>
</RouteProvider>
),
{
targetFps: 60,
gatherStats: false,
},
)
})
},
})
function App() {
const route = useRoute()
const dimensions = useTerminalDimensions()
const renderer = useRenderer()
const dialog = useDialog()
const local = useLocal()
useKeyHandler(async (evt) => {
if (evt.name === "tab") {
local.agent.move(evt.shift ? -1 : 1)
return
}
if (evt.ctrl && evt.name === "p") {
dialog.replace(() => <DialogCommand />)
return
}
if (evt.meta && evt.name === "t") {
renderer.toggleDebugOverlay()
return
}
if (evt.meta && evt.name === "d") {
renderer.console.toggle()
return
}
if (evt.meta && evt.name === "m") {
dialog.replace(() => <DialogModel />)
return
}
})
createEffect(() => {
console.log(JSON.stringify(route.data))
})
return (
<box width={dimensions().width} height={dimensions().height} backgroundColor={Theme.background}>
<box flexDirection="column" flexGrow={1}>
<Switch>
<Match when={route.data.type === "home"}>
<Home />
</Match>
<Match when={route.data.type === "session"}>
<Session />
</Match>
</Switch>
</box>
<box height={1} backgroundColor={Theme.backgroundPanel} flexDirection="row" justifyContent="space-between">
<box flexDirection="row">
<box flexDirection="row" backgroundColor={Theme.backgroundElement} paddingLeft={1} paddingRight={1}>
<text fg={Theme.textMuted}>open</text>
<text attributes={TextAttributes.BOLD}>code </text>
<text fg={Theme.textMuted}>v{Installation.VERSION}</text>
</box>
<box paddingLeft={1} paddingRight={1}>
<text fg={Theme.textMuted}>{process.cwd().replace(Global.Path.home, "~")}</text>
</box>
</box>
<box flexDirection="row">
<text paddingRight={1} fg={Theme.textMuted}>
tab
</text>
<text fg={local.agent.color(local.agent.current().name)}></text>
<text bg={local.agent.color(local.agent.current().name)} fg={Theme.background}>
{" "}
<span style={{ bold: true }}>{local.agent.current().name.toUpperCase()}</span>
<span> AGENT </span>
</text>
</box>
</box>
</box>
)
}
@@ -0,0 +1,457 @@
import { createEffect, createMemo, For, Match, Show, Switch, type Component } from "solid-js"
import { Dynamic } from "solid-js/web"
import path from "path"
import { useRouteData } from "./context/route"
import { useSync } from "./context/sync"
import { SplitBorder } from "./component/border"
import { Theme } from "./context/theme"
import { hastToStyledText, RGBA, ScrollBoxRenderable, SyntaxStyle } from "@opentui/core"
import { Prompt } from "./component/prompt"
import type { AssistantMessage, Part, ToolPart, UserMessage } from "@opencode-ai/sdk"
import type { TextPart } from "ai"
import { useLocal } from "./context/local"
import { Locale } from "../../../util/locale"
import type { Tool } from "../../../tool/tool"
import { highlightHast, Language } from "tree-sitter-highlight"
import type { ReadTool } from "../../../tool/read"
import type { WriteTool } from "../../../tool/write"
import { BashTool } from "../../../tool/bash"
import type { GlobTool } from "../../../tool/glob"
import { Instance } from "../../../project/instance"
import { TodoWriteTool } from "../../../tool/todo"
import type { GrepTool } from "../../../tool/grep"
import type { ListTool } from "../../../tool/ls"
import type { EditTool } from "../../../tool/edit"
import type { PatchTool } from "../../../tool/patch"
import type { WebFetchTool } from "../../../tool/webfetch"
import type { TaskTool } from "../../../tool/task"
import { useKeyboard, type JSX } from "@opentui/solid"
export function Session() {
const route = useRouteData("session")
const sync = useSync()
const session = createMemo(() => sync.session.get(route.sessionID)!)
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
const todo = createMemo(() => sync.data.todo[route.sessionID] ?? [])
let scroll: ScrollBoxRenderable
createEffect(() => sync.session.sync(route.sessionID))
useKeyboard((evt) => {
if (evt.name === "pageup") scroll.scrollBy(-scroll.height)
if (evt.name === "pagedown") scroll.scrollBy(scroll.height)
})
return (
<box paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={2} flexGrow={1} maxHeight="100%">
<Show when={session()}>
<box paddingLeft={1} paddingRight={1} {...SplitBorder} borderColor={Theme.backgroundElement}>
<text>
<span style={{ bold: true, fg: Theme.accent }}>#</span>{" "}
<span style={{ bold: true }}>{session().title}</span>
</text>
<box flexDirection="row">
<Switch>
<Match when={session().share?.url}>
<text fg={Theme.textMuted}>{session().share!.url}</text>
</Match>
<Match when={true}>
<text>
/share <span style={{ fg: Theme.textMuted }}>to create a shareable link</span>
</text>
</Match>
</Switch>
</box>
</box>
<scrollbox
ref={(r: any) => (scroll = r)}
scrollbarOptions={{ visible: false }}
stickyScroll={true}
stickyStart="bottom"
paddingTop={1}
paddingBottom={1}
contentOptions={{
gap: 1,
}}
>
<For each={messages()}>
{(message) => (
<Switch>
<Match when={message.role === "user"}>
<UserMessage message={message as UserMessage} parts={sync.data.part[message.id] ?? []} />
</Match>
<Match when={message.role === "assistant"}>
<AssistantMessage message={message as AssistantMessage} parts={sync.data.part[message.id] ?? []} />
</Match>
</Switch>
)}
</For>
</scrollbox>
<Show when={todo().length > 0}>
<box paddingBottom={1}>
<For each={todo()}>
{(todo) => (
<text style={{ fg: todo.status === "in_progress" ? Theme.success : Theme.textMuted }}>
[{todo.status === "completed" ? "✓" : " "}] {todo.content}
</text>
)}
</For>
</box>
</Show>
<box flexShrink={0}>
<Prompt sessionID={route.sessionID} />
</box>
</Show>
</box>
)
}
function UserMessage(props: { message: UserMessage; parts: Part[] }) {
const text = createMemo(() => props.parts.flatMap((x) => (x.type === "text" && !x.synthetic ? [x] : []))[0])
const sync = useSync()
return (
<box
border={["left"]}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={Theme.backgroundPanel}
customBorderChars={SplitBorder.customBorderChars}
borderColor={Theme.secondary}
>
<text>{text()?.text}</text>
<text>
{sync.data.config.username ?? "You"}{" "}
<span style={{ fg: Theme.textMuted }}>({Locale.time(props.message.time.created)})</span>
</text>
</box>
)
}
function AssistantMessage(props: { message: AssistantMessage; parts: Part[] }) {
return (
<For each={props.parts}>
{(part) => {
const component = createMemo(() => PART_MAPPING[part.type as keyof typeof PART_MAPPING])
return (
<Show when={component()}>
<Dynamic component={component()} part={part as any} message={props.message} />
</Show>
)
}}
</For>
)
}
const PART_MAPPING = {
text: TextPart,
tool: ToolPart,
}
function TextPart(props: { part: TextPart; message: AssistantMessage }) {
const sync = useSync()
const agent = createMemo(() => sync.data.agent.find((x) => x.name === props.message.mode)!)
const local = useLocal()
return (
<box paddingLeft={3}>
<text>{props.part.text.trim()}</text>
<text>
<span style={{ fg: local.agent.color(agent().name) }}>{Locale.titlecase(agent().name)}</span>{" "}
<span style={{ fg: Theme.textMuted }}>{props.message.providerID + "/" + props.message.modelID}</span>
</text>
</box>
)
}
// Pending messages moved to individual tool pending functions
function ToolPart(props: { part: ToolPart; message: AssistantMessage }) {
const component = createMemo(() => {
const ready = ToolRegistry.ready(props.part.tool)
if (!ready) return
const metadata = props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {})
const input = props.part.state.input
return (
<Dynamic
component={ready}
input={input}
metadata={metadata}
output={props.part.state.status === "completed" ? props.part.state.output : undefined}
/>
)
})
return (
<Show when={component()}>
<box paddingLeft={3}>{component()}</box>
</Show>
)
}
type ToolProps<T extends Tool.Info> = {
input: Partial<Tool.InferParameters<T>>
metadata: Partial<Tool.InferMetadata<T>>
output?: string
}
const ToolRegistry = (() => {
const state: Record<string, { name: string; ready?: Component<ToolProps<any>> }> = {}
function register<T extends Tool.Info>(input: { name: string; ready?: Component<ToolProps<T>> }) {
state[input.name] = input
return input
}
return {
register,
ready(name: string) {
return state[name]?.ready
},
}
})()
function ToolTitle(props: { fallback: string; when: any; icon: string; children: JSX.Element }) {
return (
<text fg={props.when ? Theme.textMuted : Theme.text}>
<Show fallback={<>~ {props.fallback}</>} when={props.when}>
<span style={{ bold: true }}>{props.icon}</span> {props.children}
</Show>
</text>
)
}
ToolRegistry.register<typeof BashTool>({
name: "bash",
ready(props) {
return (
<>
<ToolTitle icon="#" fallback="Writing command..." when={props.input.command}>
{props.input.description}
</ToolTitle>
<Show when={props.input.command}>
<box>
<text fg={Theme.textMuted}>$ {props.input.command}</text>
<box>
<text fg={Theme.textMuted}>{props.output?.trim()}</text>
</box>
</box>
</Show>
</>
)
},
})
const syntax = new SyntaxStyle({
keyword: { fg: RGBA.fromHex(Theme.syntaxKeyword), bold: true },
string: { fg: RGBA.fromHex(Theme.syntaxString) },
comment: { fg: RGBA.fromHex(Theme.syntaxComment), italic: true },
number: { fg: RGBA.fromHex(Theme.syntaxNumber) },
function: { fg: RGBA.fromHex(Theme.syntaxFunction) },
type: { fg: RGBA.fromHex(Theme.syntaxType) },
operator: { fg: RGBA.fromHex(Theme.syntaxOperator) },
variable: { fg: RGBA.fromHex(Theme.syntaxVariable) },
bracket: { fg: RGBA.fromHex(Theme.syntaxPunctuation) },
punctuation: { fg: RGBA.fromHex(Theme.syntaxPunctuation) },
default: { fg: RGBA.fromHex(Theme.syntaxVariable) },
})
ToolRegistry.register<typeof ReadTool>({
name: "read",
ready(props) {
return (
<>
<ToolTitle icon="→" fallback="Reading file..." when={props.input.filePath}>
Read {normalizePath(props.input.filePath!)}
</ToolTitle>
</>
)
},
})
ToolRegistry.register<typeof WriteTool>({
name: "write",
ready(props) {
const lines = createMemo(() => {
return props.input.content?.split("\n") ?? []
})
const code = createMemo(() => {
if (!props.input.content) return ""
const text = props.input.content
const hast = highlightHast(text, Language.TS)
const styled = hastToStyledText(hast as any, syntax)
return styled
})
const numbers = createMemo(() => {
const pad = lines().length.toString().length
return lines()
.map((_, index) => index + 1)
.map((x) => x.toString().padStart(pad, " "))
})
return (
<box gap={1}>
<ToolTitle icon="←" fallback="Preparing write..." when={props.input.filePath}>
Wrote {props.input.filePath}
</ToolTitle>
<box flexDirection="row">
<box>
<For each={numbers()}>{(value) => <text style={{ fg: Theme.textMuted }}>{value}</text>}</For>
</box>
<box paddingLeft={1}>
<text>{code()}</text>
</box>
</box>
</box>
)
},
})
ToolRegistry.register<typeof GlobTool>({
name: "glob",
ready(props) {
return (
<>
<ToolTitle icon="✱" fallback="Finding files..." when={props.input.pattern}>
Glob "{props.input.pattern}" <Show when={props.metadata.count}>({props.metadata.count} matches)</Show>
</ToolTitle>
</>
)
},
})
ToolRegistry.register<typeof GrepTool>({
name: "grep",
ready(props) {
return (
<>
<ToolTitle icon="%" fallback="Searching content..." when={props.input.pattern}>
Grep "{props.input.pattern}" <Show when={props.metadata.matches}>({props.metadata.matches} matches)</Show>
</ToolTitle>
</>
)
},
})
ToolRegistry.register<typeof ListTool>({
name: "list",
ready(props) {
const dir = createMemo(() => {
if (props.input.path) {
return normalizePath(props.input.path)
}
return ""
})
return (
<>
<ToolTitle icon="→" fallback="Listing directory..." when={props.input.path !== undefined}>
List {dir()}
</ToolTitle>
</>
)
},
})
ToolRegistry.register<typeof TaskTool>({
name: "task",
ready(props) {
return (
<>
<ToolTitle icon="%" fallback="Delegating..." when={props.input.description}>
Task {props.input.description}
</ToolTitle>
<Show when={props.metadata.summary?.length}>
<box>
<For each={props.metadata.summary ?? []}>
{(task) => (
<text style={{ fg: Theme.textMuted }}>
{task.tool} {task.state.status === "completed" ? task.state.title : ""}
</text>
)}
</For>
</box>
</Show>
</>
)
},
})
ToolRegistry.register<typeof WebFetchTool>({
name: "webfetch",
ready(props) {
return (
<>
<ToolTitle icon="%" fallback="Fetching from the web..." when={(props.input as any).url}>
WebFetch {(props.input as any).url}
</ToolTitle>
<Show when={props.output}>
<box>
<text>{props.output?.trim()}</text>
</box>
</Show>
</>
)
},
})
ToolRegistry.register<typeof EditTool>({
name: "edit",
ready(props) {
const code = createMemo(() => {
if (!props.metadata.diff) return "[no diff]"
const text = props.metadata.diff.split("\n").slice(5).join("\n")
const hast = highlightHast(text, Language.TS)
const styled = hastToStyledText(hast as any, syntax)
return styled
})
return (
<box gap={1}>
<ToolTitle icon="←" fallback="Preparing edit..." when={props.input.filePath}>
Edit {normalizePath(props.input.filePath!)}
</ToolTitle>
<box>
<text>{code()}</text>
</box>
</box>
)
},
})
ToolRegistry.register<typeof PatchTool>({
name: "patch",
ready(props) {
return (
<>
<ToolTitle icon="%" fallback="Preparing patch..." when={true}>
Patch
</ToolTitle>
<Show when={props.output}>
<box>
<text>{props.output?.trim()}</text>
</box>
</Show>
</>
)
},
})
ToolRegistry.register<typeof TodoWriteTool>({
name: "todowrite",
ready() {
return (
<>
<ToolTitle icon="%" fallback="Planning..." when={true}>
TodoWrite
</ToolTitle>
</>
)
},
})
function normalizePath(input: string) {
if (path.isAbsolute(input)) {
return path.relative(Instance.directory, input) || "."
}
return input
}
@@ -0,0 +1,184 @@
import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
import { Theme } from "../context/theme"
import { entries, flatMap, groupBy, pipe } from "remeda"
import { batch, createEffect, createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useKeyboard } from "@opentui/solid"
import * as fuzzysort from "fuzzysort"
import { isDeepEqual } from "remeda"
export interface DialogSelectProps<T> {
title: string
options: DialogSelectOption<T>[]
onFilter?: (query: string) => void
onSelect?: (option: DialogSelectOption<T>) => void
current?: T
}
export interface DialogSelectOption<T> {
value: T
title: string
description?: string
category?: string
onSelect?: () => void
}
export function DialogSelect<T>(props: DialogSelectProps<T>) {
const [store, setStore] = createStore({
selected: 0,
filter: "",
})
let input: InputRenderable
const grouped = createMemo(() => {
const needle = store.filter.toLowerCase()
const result = pipe(
props.options,
(x) => (!needle ? x : fuzzysort.go(needle, x, { keys: ["title", "category"] }).map((x) => x.obj)),
groupBy((x) => x.category ?? ""),
// mapValues((x) => x.sort((a, b) => a.title.localeCompare(b.title))),
entries(),
)
return result
})
const flat = createMemo(() => {
return pipe(
grouped(),
flatMap(([_, options]) => options),
)
})
const selected = createMemo(() => flat()[store.selected])
createEffect(() => {
store.filter
setStore("selected", 0)
scroll.scrollTo(0)
})
function move(direction: -1 | 1) {
let next = store.selected + direction
if (next < 0) next = flat().length - 1
if (next >= flat().length) next = 0
setStore("selected", next)
const target = scroll.findDescendantById(JSON.stringify(selected()?.value))
if (!target) return
const y = target.y - scroll.y
if (y >= scroll.height) {
scroll.scrollBy(y - scroll.height + 1)
}
if (y < 0) {
scroll.scrollBy(y)
if (isDeepEqual(flat()[0].value, selected()?.value)) {
scroll.scrollTo(0)
}
}
}
useKeyboard((evt) => {
if (evt.name === "up") move(-1)
if (evt.name === "down") move(1)
if (evt.name === "return") {
const option = selected()
if (option.onSelect) option.onSelect()
props.onSelect?.(option)
}
})
let scroll: ScrollBoxRenderable
return (
<box gap={1}>
<box paddingLeft={3} paddingRight={2}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD}>{props.title}</text>
<text fg={Theme.textMuted}>esc</text>
</box>
<box paddingTop={1} paddingBottom={1}>
<input
onInput={(e) => {
batch(() => {
setStore("filter", e)
props.onFilter?.(e)
})
}}
focusedBackgroundColor={Theme.backgroundPanel}
cursorColor={Theme.primary}
focusedTextColor={Theme.textMuted}
ref={(r) => {
input = r
input.focus()
}}
placeholder="Enter search term"
/>
</box>
</box>
<scrollbox
paddingLeft={2}
paddingRight={2}
scrollbarOptions={{ visible: false }}
ref={(r: ScrollBoxRenderable) => (scroll = r)}
maxHeight={10}
>
<For each={grouped()}>
{([category, options], index) => (
<box flexShrink={0}>
<Show when={category}>
<box paddingTop={index() > 0 ? 1 : 0} paddingLeft={1}>
<text fg={Theme.accent} attributes={TextAttributes.BOLD}>
{category}
</text>
</box>
</Show>
<For each={options}>
{(option) => {
return (
<Option
id={JSON.stringify(option.value)}
title={option.title}
description={option.description !== category ? option.description : undefined}
active={isDeepEqual(option.value, selected()?.value)}
current={isDeepEqual(option.value, props.current)}
/>
)
}}
</For>
</box>
)}
</For>
</scrollbox>
<box paddingRight={2} paddingLeft={3} paddingBottom={1} flexDirection="row">
<text fg={Theme.text} attributes={TextAttributes.BOLD}>
n
</text>
<text fg={Theme.textMuted}> new</text>
<text fg={Theme.text} attributes={TextAttributes.BOLD}>
{" "}r
</text>
<text fg={Theme.textMuted}> rename</text>
</box>
</box>
)
}
function Option(props: { id: string; title: string; description?: string; active?: boolean; current?: boolean }) {
return (
<box
// @ts-expect-error
id={props.id}
flexDirection="row"
backgroundColor={props.active ? Theme.primary : RGBA.fromInts(0, 0, 0, 0)}
paddingLeft={1}
paddingRight={1}
>
<text
fg={props.active ? Theme.background : props.current ? Theme.primary : Theme.text}
attributes={props.active ? TextAttributes.BOLD : undefined}
>
{props.title}
</text>
<text fg={props.active ? Theme.background : Theme.textMuted}> {props.description}</text>
</box>
)
}
@@ -0,0 +1,119 @@
import { useKeyHandler, useTerminalDimensions } from "@opentui/solid"
import { createContext, For, Show, useContext, type JSX, type ParentProps } from "solid-js"
import { Theme } from "../context/theme"
import { RGBA } from "@opentui/core"
import { createStore, produce } from "solid-js/store"
const Border = {
topLeft: "┃",
topRight: "┃",
bottomLeft: "┃",
bottomRight: "┃",
horizontal: "",
vertical: "┃",
topT: "+",
bottomT: "+",
leftT: "+",
rightT: "+",
cross: "+",
}
export function Dialog(
props: ParentProps<{
size?: "medium" | "large"
}>,
) {
const dimensions = useTerminalDimensions()
return (
<box
width={dimensions().width}
height={dimensions().height}
alignItems="center"
position="absolute"
paddingTop={dimensions().height / 4}
left={0}
top={0}
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
>
<box
customBorderChars={Border}
width={props.size === "large" ? 80 : 60}
maxWidth={dimensions().width - 2}
backgroundColor={Theme.backgroundPanel}
borderColor={Theme.border}
paddingTop={1}
>
{props.children}
</box>
</box>
)
}
function init() {
const [store, setStore] = createStore({
stack: [] as JSX.Element[],
size: "medium" as "medium" | "large",
})
useKeyHandler((evt) => {
if (evt.name === "escape") {
setStore("stack", store.stack.slice(0, -1))
}
})
return {
push(input: JSX.Element) {
setStore(
"stack",
produce((val) => val.push(input)),
)
},
clear() {
setStore("size", "medium")
setStore("stack", [])
},
replace(input: JSX.Element) {
setStore("size", "medium")
setStore("stack", [input])
},
get stack() {
return store.stack
},
get size() {
return store.size
},
setSize(size: "medium" | "large") {
setStore("size", size)
},
}
}
export type DialogContext = ReturnType<typeof init>
const ctx = createContext<DialogContext>()
export function DialogProvider(props: ParentProps) {
const value = init()
return (
<ctx.Provider value={value}>
{props.children}
<box position="absolute">
<For each={value.stack}>
{(item, index) => (
<Show when={index() === 0}>
<Dialog size={value.size}>{item}</Dialog>
</Show>
)}
</For>
</box>
</ctx.Provider>
)
}
export function useDialog() {
const value = useContext(ctx)
if (!value) {
throw new Error("useDialog must be used within a DialogProvider")
}
return value
}