mirror of
https://github.com/sst/opencode.git
synced 2025-12-23 10:11:41 +00:00
feat(opentui): port theming (#3234)
Co-authored-by: Dax <mail@thdxr.com> Co-authored-by: Dax Raad <d@ironbay.co>
This commit is contained in:
parent
1d25cf8bfd
commit
2dfb741dc7
22 changed files with 495 additions and 464 deletions
|
|
@ -2,7 +2,15 @@ import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentu
|
|||
import { Clipboard } from "@tui/util/clipboard"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { RouteProvider, useRoute, type Route } from "@tui/context/route"
|
||||
import { Switch, Match, createEffect, untrack, ErrorBoundary, createMemo, createSignal } from "solid-js"
|
||||
import {
|
||||
Switch,
|
||||
Match,
|
||||
createEffect,
|
||||
untrack,
|
||||
ErrorBoundary,
|
||||
createMemo,
|
||||
createSignal,
|
||||
} from "solid-js"
|
||||
import { Installation } from "@/installation"
|
||||
import { Global } from "@/global"
|
||||
import { DialogProvider, useDialog } from "@tui/ui/dialog"
|
||||
|
|
@ -11,11 +19,12 @@ import { SyncProvider, useSync } from "@tui/context/sync"
|
|||
import { LocalProvider, useLocal } from "@tui/context/local"
|
||||
import { DialogModel } from "@tui/component/dialog-model"
|
||||
import { DialogStatus } from "@tui/component/dialog-status"
|
||||
import { DialogThemeList } from "@tui/component/dialog-theme-list"
|
||||
import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
|
||||
import { DialogAgent } from "@tui/component/dialog-agent"
|
||||
import { DialogSessionList } from "@tui/component/dialog-session-list"
|
||||
import { KeybindProvider } from "@tui/context/keybind"
|
||||
import { Theme } from "@tui/context/theme"
|
||||
import { ThemeProvider, useTheme } from "@tui/context/theme"
|
||||
import { Home } from "@tui/routes/home"
|
||||
import { Session } from "@tui/routes/session"
|
||||
import { PromptHistoryProvider } from "./component/prompt/history"
|
||||
|
|
@ -24,12 +33,18 @@ import { ToastProvider, useToast } from "./ui/toast"
|
|||
import { ExitProvider } from "./context/exit"
|
||||
import type { SessionRoute } from "./context/route"
|
||||
|
||||
export async function tui(input: { url: string; sessionID?: string; model?: string; agent?: string; onExit?: () => Promise<void> }) {
|
||||
export async function tui(input: {
|
||||
url: string
|
||||
sessionID?: string
|
||||
model?: string
|
||||
agent?: string
|
||||
onExit?: () => Promise<void>
|
||||
}) {
|
||||
const routeData: Route | undefined = input.sessionID
|
||||
? {
|
||||
type: "session",
|
||||
sessionID: input.sessionID,
|
||||
}
|
||||
type: "session",
|
||||
sessionID: input.sessionID,
|
||||
}
|
||||
: undefined
|
||||
await render(
|
||||
() => {
|
||||
|
|
@ -40,17 +55,19 @@ export async function tui(input: { url: string; sessionID?: string; model?: stri
|
|||
<RouteProvider data={routeData}>
|
||||
<SDKProvider url={input.url}>
|
||||
<SyncProvider>
|
||||
<LocalProvider initialModel={input.model} initialAgent={input.agent}>
|
||||
<KeybindProvider>
|
||||
<DialogProvider>
|
||||
<CommandProvider>
|
||||
<PromptHistoryProvider>
|
||||
<App />
|
||||
</PromptHistoryProvider>
|
||||
</CommandProvider>
|
||||
</DialogProvider>
|
||||
</KeybindProvider>
|
||||
</LocalProvider>
|
||||
<ThemeProvider>
|
||||
<LocalProvider initialModel={input.model} initialAgent={input.agent}>
|
||||
<KeybindProvider>
|
||||
<DialogProvider>
|
||||
<CommandProvider>
|
||||
<PromptHistoryProvider>
|
||||
<App />
|
||||
</PromptHistoryProvider>
|
||||
</CommandProvider>
|
||||
</DialogProvider>
|
||||
</KeybindProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SyncProvider>
|
||||
</SDKProvider>
|
||||
</RouteProvider>
|
||||
|
|
@ -63,7 +80,6 @@ export async function tui(input: { url: string; sessionID?: string; model?: stri
|
|||
targetFps: 60,
|
||||
gatherStats: false,
|
||||
exitOnCtrlC: false,
|
||||
useKittyKeyboard: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -80,6 +96,7 @@ function App() {
|
|||
const sync = useSync()
|
||||
const toast = useToast()
|
||||
const [sessionExists, setSessionExists] = createSignal(false)
|
||||
const { theme } = useTheme()
|
||||
|
||||
useKeyboard(async (evt) => {
|
||||
if (evt.meta && evt.name === "t") {
|
||||
|
|
@ -97,14 +114,13 @@ function App() {
|
|||
createEffect(async () => {
|
||||
if (route.data.type === "session") {
|
||||
const data = route.data as SessionRoute
|
||||
await sync.session.sync(data.sessionID)
|
||||
.catch(() => {
|
||||
toast.show({
|
||||
message: `Session not found: ${data.sessionID}`,
|
||||
type: "error",
|
||||
})
|
||||
return route.navigate({ type: "home" })
|
||||
await sync.session.sync(data.sessionID).catch(() => {
|
||||
toast.show({
|
||||
message: `Session not found: ${data.sessionID}`,
|
||||
type: "error",
|
||||
})
|
||||
return route.navigate({ type: "home" })
|
||||
})
|
||||
setSessionExists(true)
|
||||
}
|
||||
})
|
||||
|
|
@ -182,6 +198,14 @@ function App() {
|
|||
},
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
title: "Switch theme",
|
||||
value: "theme.switch",
|
||||
onSelect: () => {
|
||||
dialog.replace(() => <DialogThemeList />)
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
])
|
||||
|
||||
createEffect(() => {
|
||||
|
|
@ -205,7 +229,7 @@ function App() {
|
|||
<box
|
||||
width={dimensions().width}
|
||||
height={dimensions().height}
|
||||
backgroundColor={Theme.background}
|
||||
backgroundColor={theme.background}
|
||||
onMouseUp={async () => {
|
||||
const text = renderer.getSelection()?.getSelectedText()
|
||||
if (text && text.length > 0) {
|
||||
|
|
@ -231,27 +255,36 @@ function App() {
|
|||
</box>
|
||||
<box
|
||||
height={1}
|
||||
backgroundColor={Theme.backgroundPanel}
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
flexShrink={0}
|
||||
>
|
||||
<box flexDirection="row">
|
||||
<box flexDirection="row" backgroundColor={Theme.backgroundElement} paddingLeft={1} paddingRight={1}>
|
||||
<text fg={Theme.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={Theme.textMuted}>v{Installation.VERSION}</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>
|
||||
<text fg={theme.textMuted}>{process.cwd().replace(Global.Path.home, "~")}</text>
|
||||
</box>
|
||||
</box>
|
||||
<box flexDirection="row" flexShrink={0}>
|
||||
<text fg={Theme.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={Theme.background} wrapMode="none">
|
||||
<text
|
||||
bg={local.agent.color(local.agent.current().name)}
|
||||
fg={theme.background}
|
||||
wrapMode="none"
|
||||
>
|
||||
<span style={{ bold: true }}> {local.agent.current().name.toUpperCase()}</span>
|
||||
<span> AGENT </span>
|
||||
</text>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
import { Theme } from "@tui/context/theme"
|
||||
|
||||
export const SplitBorder = {
|
||||
border: ["left" as const, "right" as const],
|
||||
borderColor: Theme.border,
|
||||
customBorderChars: {
|
||||
topLeft: "",
|
||||
bottomLeft: "",
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export function DialogModel() {
|
|||
const local = useLocal()
|
||||
const sync = useSync()
|
||||
const dialog = useDialog()
|
||||
const [ref, setRef] = createSignal<DialogSelectRef>()
|
||||
const [ref, setRef] = createSignal<DialogSelectRef<unknown>>()
|
||||
|
||||
const options = createMemo(() => {
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -5,12 +5,13 @@ import { useSync } from "@tui/context/sync"
|
|||
import { createMemo, createSignal, onMount } from "solid-js"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { Keybind } from "@/util/keybind"
|
||||
import { Theme } from "../context/theme"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useSDK } from "../context/sdk"
|
||||
|
||||
export function DialogSessionList() {
|
||||
const dialog = useDialog()
|
||||
const sync = useSync()
|
||||
const { theme } = useTheme()
|
||||
const route = useRoute()
|
||||
const sdk = useSDK()
|
||||
|
||||
|
|
@ -29,7 +30,7 @@ export function DialogSessionList() {
|
|||
const isDeleting = toDelete() === x.id
|
||||
return {
|
||||
title: isDeleting ? "Press delete again to confirm" : x.title,
|
||||
bg: isDeleting ? Theme.error : undefined,
|
||||
bg: isDeleting ? theme.error : undefined,
|
||||
value: x.id,
|
||||
category,
|
||||
footer: Locale.time(x.time.updated),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { TextAttributes } from "@opentui/core"
|
||||
import { Theme } from "../context/theme"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { For, Match, Switch, Show } from "solid-js"
|
||||
|
||||
|
|
@ -7,12 +7,13 @@ export type DialogStatusProps = {}
|
|||
|
||||
export function DialogStatus() {
|
||||
const sync = useSync()
|
||||
const { theme } = useTheme()
|
||||
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD}>Status</text>
|
||||
<text fg={Theme.textMuted}>esc</text>
|
||||
<text fg={theme.textMuted}>esc</text>
|
||||
</box>
|
||||
<Show when={Object.keys(sync.data.mcp).length > 0}>
|
||||
<box>
|
||||
|
|
@ -24,9 +25,9 @@ export function DialogStatus() {
|
|||
flexShrink={0}
|
||||
style={{
|
||||
fg: {
|
||||
connected: Theme.success,
|
||||
failed: Theme.error,
|
||||
disabled: Theme.textMuted,
|
||||
connected: theme.success,
|
||||
failed: theme.error,
|
||||
disabled: theme.textMuted,
|
||||
}[item.status],
|
||||
}}
|
||||
>
|
||||
|
|
@ -34,7 +35,7 @@ export function DialogStatus() {
|
|||
</text>
|
||||
<text wrapMode="word">
|
||||
<b>{key}</b>{" "}
|
||||
<span style={{ fg: Theme.textMuted }}>
|
||||
<span style={{ fg: theme.textMuted }}>
|
||||
<Switch>
|
||||
<Match when={item.status === "connected"}>Connected</Match>
|
||||
<Match when={item.status === "failed" && item}>{(val) => val().error}</Match>
|
||||
|
|
@ -57,15 +58,15 @@ export function DialogStatus() {
|
|||
flexShrink={0}
|
||||
style={{
|
||||
fg: {
|
||||
connected: Theme.success,
|
||||
error: Theme.error,
|
||||
connected: theme.success,
|
||||
error: theme.error,
|
||||
}[item.status],
|
||||
}}
|
||||
>
|
||||
•
|
||||
</text>
|
||||
<text wrapMode="word">
|
||||
<b>{item.id}</b> <span style={{ fg: Theme.textMuted }}>{item.root}</span>
|
||||
<b>{item.id}</b> <span style={{ fg: theme.textMuted }}>{item.root}</span>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select"
|
||||
import { THEMES, useTheme } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
|
||||
export function DialogThemeList() {
|
||||
const { selectedTheme, setSelectedTheme } = useTheme()
|
||||
const options = Object.keys(THEMES).map((value) => ({
|
||||
title: value,
|
||||
value: value as keyof typeof THEMES,
|
||||
}))
|
||||
const initial = selectedTheme()
|
||||
const dialog = useDialog()
|
||||
let confirmed = false
|
||||
let ref: DialogSelectRef<keyof typeof THEMES>
|
||||
|
||||
onMount(() => {
|
||||
// highlight the first theme in the list when we open it for UX
|
||||
setSelectedTheme(Object.keys(THEMES)[0] as keyof typeof THEMES)
|
||||
})
|
||||
onCleanup(() => {
|
||||
// if we close the dialog without confirming, reset back to the initial theme
|
||||
if (!confirmed) setSelectedTheme(initial)
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Themes"
|
||||
options={options}
|
||||
onMove={(opt) => {
|
||||
setSelectedTheme(opt.value)
|
||||
}}
|
||||
onSelect={(opt) => {
|
||||
setSelectedTheme(opt.value)
|
||||
confirmed = true
|
||||
dialog.clear()
|
||||
}}
|
||||
ref={(r) => {
|
||||
ref = r
|
||||
}}
|
||||
onFilter={(query) => {
|
||||
if (query.length === 0) {
|
||||
setSelectedTheme(initial)
|
||||
return
|
||||
}
|
||||
|
||||
const first = ref.filtered[0]
|
||||
if (first) setSelectedTheme(first.value)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,27 +1,28 @@
|
|||
import { Installation } from "@/installation"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { For } from "solid-js"
|
||||
import { Theme } from "@tui/context/theme"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
|
||||
const LOGO_LEFT = [` `, `█▀▀█ █▀▀█ █▀▀█ █▀▀▄`, `█░░█ █░░█ █▀▀▀ █░░█`, `▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀`]
|
||||
|
||||
const LOGO_RIGHT = [` ▄ `, `█▀▀▀ █▀▀█ █▀▀█ █▀▀█`, `█░░░ █░░█ █░░█ █▀▀▀`, `▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`]
|
||||
|
||||
export function Logo() {
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<box>
|
||||
<For each={LOGO_LEFT}>
|
||||
{(line, index) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={Theme.textMuted}>{line}</text>
|
||||
<text fg={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={Theme.textMuted}>{Installation.VERSION}</text>
|
||||
<text fg={theme.textMuted}>{Installation.VERSION}</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { createMemo, createResource, createEffect, onMount, For, Show } from "so
|
|||
import { createStore } from "solid-js/store"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { Theme } from "@tui/context/theme"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { SplitBorder } from "@tui/component/border"
|
||||
import { useCommandDialog } from "@tui/component/dialog-command"
|
||||
import type { PromptInfo } from "./history"
|
||||
|
|
@ -38,6 +38,7 @@ export function Autocomplete(props: {
|
|||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const command = useCommandDialog()
|
||||
const { theme } = useTheme()
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
index: 0,
|
||||
|
|
@ -361,7 +362,7 @@ export function Autocomplete(props: {
|
|||
zIndex={100}
|
||||
{...SplitBorder}
|
||||
>
|
||||
<box backgroundColor={Theme.backgroundElement} height={height()}>
|
||||
<box backgroundColor={theme.backgroundElement} height={height()}>
|
||||
<For
|
||||
each={options()}
|
||||
fallback={
|
||||
|
|
@ -374,15 +375,14 @@ export function Autocomplete(props: {
|
|||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={index() === store.selected ? Theme.primary : undefined}
|
||||
backgroundColor={index() === store.selected ? theme.primary : undefined}
|
||||
flexDirection="row"
|
||||
>
|
||||
<text fg={index() === store.selected ? Theme.background : Theme.text}>
|
||||
<text fg={index() === store.selected ? theme.background : theme.text}>
|
||||
{option.display}
|
||||
</text>
|
||||
<Show when={option.description}>
|
||||
<text fg={index() === store.selected ? Theme.background : Theme.textMuted}>
|
||||
{" "}
|
||||
<text fg={index() === store.selected ? theme.background : theme.textMuted}>
|
||||
{option.description}
|
||||
</text>
|
||||
</Show>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
} from "@opentui/core"
|
||||
import { createEffect, createMemo, Match, Switch, type JSX, onMount } from "solid-js"
|
||||
import { useLocal } from "@tui/context/local"
|
||||
import { Theme, syntaxTheme } from "@tui/context/theme"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { SplitBorder } from "@tui/component/border"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
|
|
@ -59,6 +59,7 @@ export function Prompt(props: PromptProps) {
|
|||
const history = usePromptHistory()
|
||||
const command = useCommandDialog()
|
||||
const renderer = useRenderer()
|
||||
const { theme, syntaxTheme } = useTheme()
|
||||
|
||||
const textareaKeybindings = createMemo(() => {
|
||||
const newlineBindings = keybind.all.input_newline || []
|
||||
|
|
@ -84,9 +85,9 @@ export function Prompt(props: PromptProps) {
|
|||
]
|
||||
})
|
||||
|
||||
const fileStyleId = syntaxTheme.getStyleId("extmark.file")!
|
||||
const agentStyleId = syntaxTheme.getStyleId("extmark.agent")!
|
||||
const pasteStyleId = syntaxTheme.getStyleId("extmark.paste")!
|
||||
const fileStyleId = syntaxTheme().getStyleId("extmark.file")!
|
||||
const agentStyleId = syntaxTheme().getStyleId("extmark.agent")!
|
||||
const pasteStyleId = syntaxTheme().getStyleId("extmark.paste")!
|
||||
let promptPartTypeId: number
|
||||
|
||||
command.register(() => {
|
||||
|
|
@ -171,8 +172,8 @@ export function Prompt(props: PromptProps) {
|
|||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (props.disabled) input.cursorColor = Theme.backgroundElement
|
||||
if (!props.disabled) input.cursorColor = Theme.primary
|
||||
if (props.disabled) input.cursorColor = theme.backgroundElement
|
||||
if (!props.disabled) input.cursorColor = theme.primary
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore<{
|
||||
|
|
@ -475,35 +476,35 @@ export function Prompt(props: PromptProps) {
|
|||
flexDirection="row"
|
||||
{...SplitBorder}
|
||||
borderColor={
|
||||
keybind.leader ? Theme.accent : store.mode === "shell" ? Theme.secondary : undefined
|
||||
keybind.leader ? theme.accent : store.mode === "shell" ? theme.secondary : theme.border
|
||||
}
|
||||
justifyContent="space-evenly"
|
||||
>
|
||||
<box
|
||||
backgroundColor={Theme.backgroundElement}
|
||||
backgroundColor={theme.backgroundElement}
|
||||
width={3}
|
||||
height="100%"
|
||||
alignItems="center"
|
||||
paddingTop={1}
|
||||
>
|
||||
<text attributes={TextAttributes.BOLD} fg={Theme.primary}>
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.primary}>
|
||||
{store.mode === "normal" ? ">" : "!"}
|
||||
</text>
|
||||
</box>
|
||||
<box
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
backgroundColor={Theme.backgroundElement}
|
||||
backgroundColor={theme.backgroundElement}
|
||||
flexGrow={1}
|
||||
>
|
||||
<textarea
|
||||
placeholder={
|
||||
props.showPlaceholder
|
||||
? t`${dim(fg(Theme.primary)(" → up/down"))} ${dim(fg("#64748b")("history"))} ${dim(fg("#a78bfa")("•"))} ${dim(fg(Theme.primary)(keybind.print("input_newline")))} ${dim(fg("#64748b")("newline"))} ${dim(fg("#a78bfa")("•"))} ${dim(fg(Theme.primary)(keybind.print("input_submit")))} ${dim(fg("#64748b")("submit"))}`
|
||||
? t`${dim(fg(theme.primary)(" → up/down"))} ${dim(fg("#64748b")("history"))} ${dim(fg("#a78bfa")("•"))} ${dim(fg(theme.primary)(keybind.print("input_newline")))} ${dim(fg("#64748b")("newline"))} ${dim(fg("#a78bfa")("•"))} ${dim(fg(theme.primary)(keybind.print("input_submit")))} ${dim(fg("#64748b")("submit"))}`
|
||||
: undefined
|
||||
}
|
||||
textColor={Theme.text}
|
||||
focusedTextColor={Theme.text}
|
||||
textColor={theme.text}
|
||||
focusedTextColor={theme.text}
|
||||
minHeight={1}
|
||||
maxHeight={6}
|
||||
onContentChange={() => {
|
||||
|
|
@ -659,13 +660,13 @@ export function Prompt(props: PromptProps) {
|
|||
}}
|
||||
ref={(r: TextareaRenderable) => (input = r)}
|
||||
onMouseDown={(r: MouseEvent) => r.target?.focus()}
|
||||
focusedBackgroundColor={Theme.backgroundElement}
|
||||
cursorColor={Theme.primary}
|
||||
focusedBackgroundColor={theme.backgroundElement}
|
||||
cursorColor={theme.primary}
|
||||
syntaxStyle={syntaxTheme}
|
||||
/>
|
||||
</box>
|
||||
<box
|
||||
backgroundColor={Theme.backgroundElement}
|
||||
backgroundColor={theme.backgroundElement}
|
||||
width={1}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
|
|
@ -673,24 +674,24 @@ export function Prompt(props: PromptProps) {
|
|||
</box>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text flexShrink={0} wrapMode="none">
|
||||
<span style={{ fg: Theme.textMuted }}>{local.model.parsed().provider}</span>{" "}
|
||||
<span style={{ fg: theme.textMuted }}>{local.model.parsed().provider}</span>{" "}
|
||||
<span style={{ bold: true }}>{local.model.parsed().model}</span>
|
||||
</text>
|
||||
<Switch>
|
||||
<Match when={status() === "compacting"}>
|
||||
<text fg={Theme.textMuted}>compacting...</text>
|
||||
<text fg={theme.textMuted}>compacting...</text>
|
||||
</Match>
|
||||
<Match when={status() === "working"}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text>
|
||||
esc <span style={{ fg: Theme.textMuted }}>interrupt</span>
|
||||
esc <span style={{ fg: theme.textMuted }}>interrupt</span>
|
||||
</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={props.hint}>{props.hint!}</Match>
|
||||
<Match when={true}>
|
||||
<text>
|
||||
ctrl+p <span style={{ fg: Theme.textMuted }}>commands</span>
|
||||
ctrl+p <span style={{ fg: theme.textMuted }}>commands</span>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { createStore } from "solid-js/store"
|
||||
import { batch, createEffect, createMemo, createSignal, onMount } from "solid-js"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { Theme } from "@tui/context/theme"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { uniqueBy } from "remeda"
|
||||
import path from "path"
|
||||
import { Global } from "@/global"
|
||||
|
|
@ -74,6 +74,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
}>({
|
||||
current: agents()[0].name,
|
||||
})
|
||||
const { theme } = useTheme()
|
||||
const colors = createMemo(() => [
|
||||
theme.secondary,
|
||||
theme.accent,
|
||||
theme.success,
|
||||
theme.warning,
|
||||
theme.primary,
|
||||
theme.error,
|
||||
])
|
||||
return {
|
||||
list() {
|
||||
return agents()
|
||||
|
|
@ -101,8 +110,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
},
|
||||
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]
|
||||
return colors()[index % colors().length]
|
||||
},
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,256 +1,127 @@
|
|||
import { SyntaxStyle } from "@opentui/core"
|
||||
|
||||
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
|
||||
import { SyntaxStyle, RGBA } from "@opentui/core"
|
||||
import { createMemo, createSignal, createEffect } from "solid-js"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import aura from "../../../../../../tui/internal/theme/themes/aura.json" with { type: "json" }
|
||||
import ayu from "../../../../../../tui/internal/theme/themes/ayu.json" with { type: "json" }
|
||||
import catppuccin from "../../../../../../tui/internal/theme/themes/catppuccin.json" with { type: "json" }
|
||||
import cobalt2 from "../../../../../../tui/internal/theme/themes/cobalt2.json" with { type: "json" }
|
||||
import dracula from "../../../../../../tui/internal/theme/themes/dracula.json" with { type: "json" }
|
||||
import everforest from "../../../../../../tui/internal/theme/themes/everforest.json" with { type: "json" }
|
||||
import github from "../../../../../../tui/internal/theme/themes/github.json" with { type: "json" }
|
||||
import gruvbox from "../../../../../../tui/internal/theme/themes/gruvbox.json" with { type: "json" }
|
||||
import kanagawa from "../../../../../../tui/internal/theme/themes/kanagawa.json" with { type: "json" }
|
||||
import material from "../../../../../../tui/internal/theme/themes/material.json" with { type: "json" }
|
||||
import matrix from "../../../../../../tui/internal/theme/themes/matrix.json" with { type: "json" }
|
||||
import monokai from "../../../../../../tui/internal/theme/themes/monokai.json" with { type: "json" }
|
||||
import nord from "../../../../../../tui/internal/theme/themes/nord.json" with { type: "json" }
|
||||
import onedark from "../../../../../../tui/internal/theme/themes/one-dark.json" with { type: "json" }
|
||||
import opencode from "../../../../../../tui/internal/theme/themes/opencode.json" with { type: "json" }
|
||||
import palenight from "../../../../../../tui/internal/theme/themes/palenight.json" with { type: "json" }
|
||||
import rosepine from "../../../../../../tui/internal/theme/themes/rosepine.json" with { type: "json" }
|
||||
import solarized from "../../../../../../tui/internal/theme/themes/solarized.json" with { type: "json" }
|
||||
import synthwave84 from "../../../../../../tui/internal/theme/themes/synthwave84.json" with { type: "json" }
|
||||
import tokyonight from "../../../../../../tui/internal/theme/themes/tokyonight.json" with { type: "json" }
|
||||
import vesper from "../../../../../../tui/internal/theme/themes/vesper.json" with { type: "json" }
|
||||
import zenburn from "../../../../../../tui/internal/theme/themes/zenburn.json" with { type: "json" }
|
||||
import { iife } from "@/util/iife"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
|
||||
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
|
||||
primary: RGBA
|
||||
secondary: RGBA
|
||||
accent: RGBA
|
||||
error: RGBA
|
||||
warning: RGBA
|
||||
success: RGBA
|
||||
info: RGBA
|
||||
text: RGBA
|
||||
textMuted: RGBA
|
||||
background: RGBA
|
||||
backgroundPanel: RGBA
|
||||
backgroundElement: RGBA
|
||||
border: RGBA
|
||||
borderActive: RGBA
|
||||
borderSubtle: RGBA
|
||||
diffAdded: RGBA
|
||||
diffRemoved: RGBA
|
||||
diffContext: RGBA
|
||||
diffHunkHeader: RGBA
|
||||
diffHighlightAdded: RGBA
|
||||
diffHighlightRemoved: RGBA
|
||||
diffAddedBg: RGBA
|
||||
diffRemovedBg: RGBA
|
||||
diffContextBg: RGBA
|
||||
diffLineNumber: RGBA
|
||||
diffAddedLineNumberBg: RGBA
|
||||
diffRemovedLineNumberBg: RGBA
|
||||
markdownText: RGBA
|
||||
markdownHeading: RGBA
|
||||
markdownLink: RGBA
|
||||
markdownLinkText: RGBA
|
||||
markdownCode: RGBA
|
||||
markdownBlockQuote: RGBA
|
||||
markdownEmph: RGBA
|
||||
markdownStrong: RGBA
|
||||
markdownHorizontalRule: RGBA
|
||||
markdownListItem: RGBA
|
||||
markdownListEnumeration: RGBA
|
||||
markdownImage: RGBA
|
||||
markdownImageText: RGBA
|
||||
markdownCodeBlock: RGBA
|
||||
}
|
||||
|
||||
export const Theme = Object.entries(OPENCODE_THEME).reduce((acc, [key, value]) => {
|
||||
acc[key as keyof Theme] = value.dark
|
||||
return acc
|
||||
}, {} as Theme)
|
||||
type HexColor = `#${string}`
|
||||
type RefName = string
|
||||
type ColorModeObj = {
|
||||
dark: HexColor | RefName
|
||||
light: HexColor | RefName
|
||||
}
|
||||
type ColorValue = HexColor | RefName | ColorModeObj
|
||||
type ThemeJson = {
|
||||
$schema?: string
|
||||
defs?: Record<string, HexColor | RefName>
|
||||
theme: Record<keyof Theme, ColorValue>
|
||||
}
|
||||
|
||||
export const THEMES = {
|
||||
aura: resolveTheme(aura),
|
||||
ayu: resolveTheme(ayu),
|
||||
catppuccin: resolveTheme(catppuccin),
|
||||
cobalt2: resolveTheme(cobalt2),
|
||||
dracula: resolveTheme(dracula),
|
||||
everforest: resolveTheme(everforest),
|
||||
github: resolveTheme(github),
|
||||
gruvbox: resolveTheme(gruvbox),
|
||||
kanagawa: resolveTheme(kanagawa),
|
||||
material: resolveTheme(material),
|
||||
matrix: resolveTheme(matrix),
|
||||
monokai: resolveTheme(monokai),
|
||||
nord: resolveTheme(nord),
|
||||
["one-dark"]: resolveTheme(onedark),
|
||||
opencode: resolveTheme(opencode),
|
||||
palenight: resolveTheme(palenight),
|
||||
rosepine: resolveTheme(rosepine),
|
||||
solarized: resolveTheme(solarized),
|
||||
synthwave84: resolveTheme(synthwave84),
|
||||
tokyonight: resolveTheme(tokyonight),
|
||||
vesper: resolveTheme(vesper),
|
||||
zenburn: resolveTheme(zenburn),
|
||||
}
|
||||
|
||||
function resolveTheme(theme: ThemeJson) {
|
||||
const defs = theme.defs ?? {}
|
||||
function resolveColor(c: ColorValue): RGBA {
|
||||
if (typeof c === "string") return c.startsWith("#") ? RGBA.fromHex(c) : resolveColor(defs[c])
|
||||
// TODO: support light theme when opentui has the equivalent of lipgloss.AdaptiveColor
|
||||
return resolveColor(c.dark)
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(theme.theme).map(([key, value]) => {
|
||||
return [key, resolveColor(value)]
|
||||
}),
|
||||
) as Theme
|
||||
}
|
||||
|
||||
const syntaxThemeDark = [
|
||||
{
|
||||
|
|
@ -454,4 +325,37 @@ const syntaxThemeDark = [
|
|||
},
|
||||
]
|
||||
|
||||
export const syntaxTheme = SyntaxStyle.fromTheme(syntaxThemeDark)
|
||||
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
name: "Theme",
|
||||
init: () => {
|
||||
const sync = useSync()
|
||||
const [selectedTheme, setSelectedTheme] = createSignal<keyof typeof THEMES>("opencode")
|
||||
const [theme, setTheme] = createStore({} as Theme)
|
||||
const syntaxTheme = createMemo(() => SyntaxStyle.fromTheme(syntaxThemeDark))
|
||||
|
||||
createEffect(() => {
|
||||
if (!sync.ready) return
|
||||
setSelectedTheme(
|
||||
iife(() => {
|
||||
if (typeof sync.data.config.theme === "string" && sync.data.config.theme in THEMES) {
|
||||
return sync.data.config.theme as keyof typeof THEMES
|
||||
}
|
||||
return "opencode"
|
||||
}),
|
||||
)
|
||||
})
|
||||
createEffect(() => {
|
||||
setTheme(reconcile(THEMES[selectedTheme()]))
|
||||
})
|
||||
|
||||
return {
|
||||
theme,
|
||||
syntaxTheme,
|
||||
selectedTheme,
|
||||
setSelectedTheme,
|
||||
get ready() {
|
||||
return sync.ready
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Prompt } from "@tui/component/prompt"
|
||||
import { createMemo, Match, Show, Switch, type ParentProps } from "solid-js"
|
||||
import { Theme } from "@tui/context/theme"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { useKeybind } from "../context/keybind"
|
||||
import type { KeybindsConfig } from "@opencode-ai/sdk"
|
||||
import { Logo } from "../component/logo"
|
||||
|
|
@ -10,6 +10,7 @@ import { Toast } from "../ui/toast"
|
|||
|
||||
export function Home() {
|
||||
const sync = useSync()
|
||||
const { theme } = useTheme()
|
||||
const mcpError = createMemo(() => {
|
||||
return Object.values(sync.data.mcp).some((x) => x.status === "failed")
|
||||
})
|
||||
|
|
@ -20,11 +21,11 @@ export function Home() {
|
|||
<text>
|
||||
<Switch>
|
||||
<Match when={mcpError()}>
|
||||
<span style={{ fg: Theme.error }}>•</span> mcp errors{" "}
|
||||
<span style={{ fg: Theme.textMuted }}>ctrl+x s</span>
|
||||
<span style={{ fg: theme.error }}>•</span> mcp errors{" "}
|
||||
<span style={{ fg: theme.textMuted }}>ctrl+x s</span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<span style={{ fg: Theme.success }}>•</span>{" "}
|
||||
<span style={{ fg: theme.success }}>•</span>{" "}
|
||||
{Locale.pluralize(
|
||||
Object.values(sync.data.mcp).length,
|
||||
"{} mcp server",
|
||||
|
|
@ -63,10 +64,11 @@ export function Home() {
|
|||
|
||||
function HelpRow(props: ParentProps<{ keybind: keyof KeybindsConfig }>) {
|
||||
const keybind = useKeybind()
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<box flexDirection="row" justifyContent="space-between" width="100%">
|
||||
<text>{props.children}</text>
|
||||
<text fg={Theme.primary}>{keybind.print(props.keybind)}</text>
|
||||
<text fg={theme.primary}>{keybind.print(props.keybind)}</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@ import { createMemo, Match, Show, Switch } from "solid-js"
|
|||
import { useRouteData } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { pipe, sumBy } from "remeda"
|
||||
import { Theme } from "@tui/context/theme"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { SplitBorder } from "@tui/component/border"
|
||||
import type { AssistantMessage } from "@opencode-ai/sdk"
|
||||
|
||||
export function Header() {
|
||||
const route = useRouteData("session")
|
||||
const sync = useSync()
|
||||
const { theme } = useTheme()
|
||||
const session = createMemo(() => sync.session.get(route.sessionID)!)
|
||||
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
|
||||
|
||||
|
|
@ -24,10 +25,16 @@ export function Header() {
|
|||
})
|
||||
|
||||
const context = createMemo(() => {
|
||||
const last = messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage
|
||||
const last = messages().findLast(
|
||||
(x) => x.role === "assistant" && x.tokens.output > 0,
|
||||
) as AssistantMessage
|
||||
if (!last) return
|
||||
const total =
|
||||
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
|
||||
last.tokens.input +
|
||||
last.tokens.output +
|
||||
last.tokens.reasoning +
|
||||
last.tokens.cache.read +
|
||||
last.tokens.cache.write
|
||||
const model = sync.data.provider.find((x) => x.id === last.providerID)?.models[last.modelID]
|
||||
let result = total.toLocaleString()
|
||||
if (model?.limit.context) {
|
||||
|
|
@ -37,27 +44,34 @@ export function Header() {
|
|||
})
|
||||
|
||||
return (
|
||||
<box paddingLeft={1} paddingRight={1} {...SplitBorder} borderColor={Theme.backgroundElement} flexShrink={0}>
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
{...SplitBorder}
|
||||
borderColor={theme.backgroundElement}
|
||||
flexShrink={0}
|
||||
>
|
||||
<text>
|
||||
<span style={{ bold: true, fg: Theme.accent }}>#</span> <span style={{ bold: true }}>{session().title}</span>
|
||||
<span style={{ bold: true, fg: theme.accent }}>#</span>{" "}
|
||||
<span style={{ bold: true }}>{session().title}</span>
|
||||
</text>
|
||||
<box flexDirection="row" justifyContent="space-between" gap={1}>
|
||||
<box flexGrow={1} flexShrink={1}>
|
||||
<Switch>
|
||||
<Match when={session().share?.url}>
|
||||
<text fg={Theme.textMuted} wrapMode="word">
|
||||
<text fg={theme.textMuted} wrapMode="word">
|
||||
{session().share!.url}
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<text wrapMode="word">
|
||||
/share <span style={{ fg: Theme.textMuted }}>to create a shareable link</span>
|
||||
/share <span style={{ fg: theme.textMuted }}>to create a shareable link</span>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
<Show when={context()}>
|
||||
<text fg={Theme.textMuted} wrapMode="none" flexShrink={0}>
|
||||
<text fg={theme.textMuted} wrapMode="none" flexShrink={0}>
|
||||
{context()} ({cost()})
|
||||
</text>
|
||||
</Show>
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import path from "path"
|
|||
import { useRouteData } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { SplitBorder } from "@tui/component/border"
|
||||
import { syntaxTheme, Theme } from "@tui/context/theme"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers } from "@opentui/core"
|
||||
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
||||
import type {
|
||||
|
|
@ -74,6 +74,7 @@ function use() {
|
|||
export function Session() {
|
||||
const route = useRouteData("session")
|
||||
const sync = useSync()
|
||||
const { theme } = useTheme()
|
||||
const session = createMemo(() => sync.session.get(route.sessionID)!)
|
||||
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
|
||||
const permissions = createMemo(() => sync.data.permission[route.sessionID] ?? [])
|
||||
|
|
@ -91,8 +92,6 @@ export function Session() {
|
|||
|
||||
createEffect(() => sync.session.sync(route.sessionID))
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const sdk = useSDK()
|
||||
|
||||
let scroll: ScrollBoxRenderable
|
||||
|
|
@ -183,20 +182,12 @@ export function Session() {
|
|||
keybind: "session_share",
|
||||
disabled: !!session()?.share?.url,
|
||||
category: "Session",
|
||||
onSelect: async (dialog) => {
|
||||
await sdk.client.session
|
||||
.share({
|
||||
path: {
|
||||
id: route.sessionID,
|
||||
},
|
||||
})
|
||||
.then((res) =>
|
||||
Clipboard.copy(res.data!.share!.url).catch(() =>
|
||||
toast.show({ message: "Failed to copy URL to clipboard", type: "error" }),
|
||||
),
|
||||
)
|
||||
.then(() => toast.show({ message: "Share URL copied to clipboard!", type: "success" }))
|
||||
.catch(() => toast.show({ message: "Failed to share session", type: "error" }))
|
||||
onSelect: (dialog) => {
|
||||
sdk.client.session.share({
|
||||
path: {
|
||||
id: route.sessionID,
|
||||
},
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
|
|
@ -454,21 +445,21 @@ export function Session() {
|
|||
flexShrink={0}
|
||||
border={["left"]}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={Theme.backgroundPanel}
|
||||
borderColor={theme.backgroundPanel}
|
||||
>
|
||||
<box
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
backgroundColor={
|
||||
hover() ? Theme.backgroundElement : Theme.backgroundPanel
|
||||
hover() ? theme.backgroundElement : theme.backgroundPanel
|
||||
}
|
||||
>
|
||||
<text fg={Theme.textMuted}>
|
||||
<text fg={theme.textMuted}>
|
||||
{revert()!.reverted.length} message reverted
|
||||
</text>
|
||||
<text fg={Theme.textMuted}>
|
||||
<span style={{ fg: Theme.text }}>
|
||||
<text fg={theme.textMuted}>
|
||||
<span style={{ fg: theme.text }}>
|
||||
{keybind.print("messages_redo")}
|
||||
</span>{" "}
|
||||
or /redo to restore
|
||||
|
|
@ -480,13 +471,13 @@ export function Session() {
|
|||
<text>
|
||||
{file.filename}
|
||||
<Show when={file.additions > 0}>
|
||||
<span style={{ fg: Theme.diffAdded }}>
|
||||
<span style={{ fg: theme.diffAdded }}>
|
||||
{" "}
|
||||
+{file.additions}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={file.deletions > 0}>
|
||||
<span style={{ fg: Theme.diffRemoved }}>
|
||||
<span style={{ fg: theme.diffRemoved }}>
|
||||
{" "}
|
||||
-{file.deletions}
|
||||
</span>
|
||||
|
|
@ -539,7 +530,6 @@ export function Session() {
|
|||
/>
|
||||
</box>
|
||||
</Show>
|
||||
<Toast />
|
||||
</box>
|
||||
<Show when={sidebarVisible()}>
|
||||
<Sidebar sessionID={route.sessionID} />
|
||||
|
|
@ -571,9 +561,10 @@ function UserMessage(props: {
|
|||
)
|
||||
const files = createMemo(() => props.parts.flatMap((x) => (x.type === "file" ? [x] : [])))
|
||||
const sync = useSync()
|
||||
const { theme } = useTheme()
|
||||
const [hover, setHover] = createSignal(false)
|
||||
const queued = createMemo(() => props.pending && props.message.id > props.pending)
|
||||
const color = createMemo(() => (queued() ? Theme.accent : Theme.secondary))
|
||||
const color = createMemo(() => (queued() ? theme.accent : theme.secondary))
|
||||
|
||||
return (
|
||||
<Show when={text()}>
|
||||
|
|
@ -591,7 +582,7 @@ function UserMessage(props: {
|
|||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
marginTop={props.index === 0 ? 0 : 1}
|
||||
backgroundColor={hover() ? Theme.backgroundElement : Theme.backgroundPanel}
|
||||
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={color()}
|
||||
flexShrink={0}
|
||||
|
|
@ -602,17 +593,17 @@ function UserMessage(props: {
|
|||
<For each={files()}>
|
||||
{(file) => {
|
||||
const bg = createMemo(() => {
|
||||
if (file.mime.startsWith("image/")) return Theme.accent
|
||||
if (file.mime === "application/pdf") return Theme.primary
|
||||
return Theme.secondary
|
||||
if (file.mime.startsWith("image/")) return theme.accent
|
||||
if (file.mime === "application/pdf") return theme.primary
|
||||
return theme.secondary
|
||||
})
|
||||
return (
|
||||
<text>
|
||||
<span style={{ bg: bg(), fg: Theme.background }}>
|
||||
<span style={{ bg: bg(), fg: theme.background }}>
|
||||
{" "}
|
||||
{MIME_BADGE[file.mime] ?? file.mime}{" "}
|
||||
</span>
|
||||
<span style={{ bg: Theme.backgroundElement, fg: Theme.textMuted }}>
|
||||
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}>
|
||||
{" "}
|
||||
{file.filename}{" "}
|
||||
</span>
|
||||
|
|
@ -627,12 +618,12 @@ function UserMessage(props: {
|
|||
<Show
|
||||
when={queued()}
|
||||
fallback={
|
||||
<span style={{ fg: Theme.textMuted }}>
|
||||
<span style={{ fg: theme.textMuted }}>
|
||||
({Locale.time(props.message.time.created)})
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span style={{ bg: Theme.accent, fg: Theme.backgroundPanel, bold: true }}>
|
||||
<span style={{ bg: theme.accent, fg: theme.backgroundPanel, bold: true }}>
|
||||
{" "}
|
||||
QUEUED{" "}
|
||||
</span>
|
||||
|
|
@ -645,6 +636,7 @@ function UserMessage(props: {
|
|||
|
||||
function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; last: boolean }) {
|
||||
const local = useLocal()
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<>
|
||||
<For each={props.parts}>
|
||||
|
|
@ -664,11 +656,11 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
|
|||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
marginTop={1}
|
||||
backgroundColor={Theme.backgroundPanel}
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={Theme.error}
|
||||
borderColor={theme.error}
|
||||
>
|
||||
<text fg={Theme.textMuted}>{props.message.error?.data.message}</text>
|
||||
<text fg={theme.textMuted}>{props.message.error?.data.message}</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Show
|
||||
|
|
@ -685,12 +677,12 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
|
|||
gap={1}
|
||||
border={["left"]}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={Theme.backgroundElement}
|
||||
borderColor={theme.backgroundElement}
|
||||
>
|
||||
<text fg={local.agent.color(props.message.mode)}>
|
||||
{Locale.titlecase(props.message.mode)}
|
||||
</text>
|
||||
<Shimmer text={`${props.message.modelID}`} color={Theme.text} />
|
||||
<Shimmer text={`${props.message.modelID}`} color={theme.text} />
|
||||
</box>
|
||||
</Show>
|
||||
<Show
|
||||
|
|
@ -704,7 +696,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
|
|||
<span style={{ fg: local.agent.color(props.message.mode) }}>
|
||||
{Locale.titlecase(props.message.mode)}
|
||||
</span>{" "}
|
||||
<span style={{ fg: Theme.textMuted }}>{props.message.modelID}</span>
|
||||
<span style={{ fg: theme.textMuted }}>{props.message.modelID}</span>
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
|
|
@ -719,6 +711,7 @@ const PART_MAPPING = {
|
|||
}
|
||||
|
||||
function ReasoningPart(props: { part: ReasoningPart; message: AssistantMessage }) {
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<Show when={props.part.text.trim()}>
|
||||
<box
|
||||
|
|
@ -727,13 +720,13 @@ function ReasoningPart(props: { part: ReasoningPart; message: AssistantMessage }
|
|||
flexShrink={0}
|
||||
border={["left"]}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={Theme.backgroundPanel}
|
||||
borderColor={theme.backgroundPanel}
|
||||
>
|
||||
<box
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
backgroundColor={Theme.backgroundPanel}
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
>
|
||||
<text>{props.part.text.trim()}</text>
|
||||
</box>
|
||||
|
|
@ -755,6 +748,7 @@ function TextPart(props: { part: TextPart; message: AssistantMessage }) {
|
|||
// Pending messages moved to individual tool pending functions
|
||||
|
||||
function ToolPart(props: { part: ToolPart; message: AssistantMessage }) {
|
||||
const { theme } = useTheme()
|
||||
const sync = useSync()
|
||||
const [margin, setMargin] = createSignal(0)
|
||||
const component = createMemo(() => {
|
||||
|
|
@ -776,9 +770,9 @@ function ToolPart(props: { part: ToolPart; message: AssistantMessage }) {
|
|||
paddingLeft: 2,
|
||||
marginTop: 1,
|
||||
gap: 1,
|
||||
backgroundColor: Theme.backgroundPanel,
|
||||
backgroundColor: theme.backgroundPanel,
|
||||
customBorderChars: SplitBorder.customBorderChars,
|
||||
borderColor: permissionIndex === 0 ? Theme.warning : Theme.background,
|
||||
borderColor: permissionIndex === 0 ? theme.warning : theme.background,
|
||||
}
|
||||
: {
|
||||
paddingLeft: 3,
|
||||
|
|
@ -821,24 +815,24 @@ function ToolPart(props: { part: ToolPart; message: AssistantMessage }) {
|
|||
/>
|
||||
{props.part.state.status === "error" && (
|
||||
<box paddingLeft={2}>
|
||||
<text fg={Theme.error}>{props.part.state.error.replace("Error: ", "")}</text>
|
||||
<text fg={theme.error}>{props.part.state.error.replace("Error: ", "")}</text>
|
||||
</box>
|
||||
)}
|
||||
{permission && (
|
||||
<box gap={1}>
|
||||
<text fg={Theme.text}>Permission required to run this tool:</text>
|
||||
<text fg={theme.text}>Permission required to run this tool:</text>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text>
|
||||
<b>enter</b>
|
||||
<span style={{ fg: Theme.textMuted }}> accept</span>
|
||||
<span style={{ fg: theme.textMuted }}> accept</span>
|
||||
</text>
|
||||
<text>
|
||||
<b>a</b>
|
||||
<span style={{ fg: Theme.textMuted }}> accept always</span>
|
||||
<span style={{ fg: theme.textMuted }}> accept always</span>
|
||||
</text>
|
||||
<text>
|
||||
<b>d</b>
|
||||
<span style={{ fg: Theme.textMuted }}> deny</span>
|
||||
<span style={{ fg: theme.textMuted }}> deny</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
|
@ -890,8 +884,9 @@ const ToolRegistry = (() => {
|
|||
})()
|
||||
|
||||
function ToolTitle(props: { fallback: string; when: any; icon: string; children: JSX.Element }) {
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<text paddingLeft={3} fg={props.when ? Theme.textMuted : Theme.text}>
|
||||
<text paddingLeft={3} fg={props.when ? theme.textMuted : theme.text}>
|
||||
<Show fallback={<>~ {props.fallback}</>} when={props.when}>
|
||||
<span style={{ bold: true }}>{props.icon}</span> {props.children}
|
||||
</Show>
|
||||
|
|
@ -904,17 +899,18 @@ ToolRegistry.register<typeof BashTool>({
|
|||
container: "block",
|
||||
render(props) {
|
||||
const output = createMemo(() => Bun.stripANSI(props.metadata.output?.trim() ?? ""))
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<>
|
||||
<ToolTitle icon="#" fallback="Writing command..." when={props.input.command}>
|
||||
{props.input.description || "Shell"}
|
||||
</ToolTitle>
|
||||
<Show when={props.input.command}>
|
||||
<text fg={Theme.text}>$ {props.input.command}</text>
|
||||
<text fg={theme.text}>$ {props.input.command}</text>
|
||||
</Show>
|
||||
<Show when={output()}>
|
||||
<box>
|
||||
<text fg={Theme.text}>{output()}</text>
|
||||
<text fg={theme.text}>{output()}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</>
|
||||
|
|
@ -940,6 +936,7 @@ ToolRegistry.register<typeof WriteTool>({
|
|||
name: "write",
|
||||
container: "block",
|
||||
render(props) {
|
||||
const { theme, syntaxTheme } = useTheme()
|
||||
const lines = createMemo(() => {
|
||||
return props.input.content?.split("\n") ?? []
|
||||
})
|
||||
|
|
@ -964,13 +961,13 @@ ToolRegistry.register<typeof WriteTool>({
|
|||
<box flexDirection="row">
|
||||
<box flexShrink={0}>
|
||||
<For each={numbers()}>
|
||||
{(value) => <text style={{ fg: Theme.textMuted }}>{value}</text>}
|
||||
{(value) => <text style={{ fg: theme.textMuted }}>{value}</text>}
|
||||
</For>
|
||||
</box>
|
||||
<box paddingLeft={1} flexGrow={1}>
|
||||
<code
|
||||
filetype={filetype(props.input.filePath!)}
|
||||
syntaxStyle={syntaxTheme}
|
||||
syntaxStyle={syntaxTheme()}
|
||||
content={code()}
|
||||
/>
|
||||
</box>
|
||||
|
|
@ -1034,6 +1031,7 @@ ToolRegistry.register<typeof TaskTool>({
|
|||
name: "task",
|
||||
container: "block",
|
||||
render(props) {
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<>
|
||||
<ToolTitle icon="%" fallback="Delegating..." when={props.input.description}>
|
||||
|
|
@ -1043,7 +1041,7 @@ ToolRegistry.register<typeof TaskTool>({
|
|||
<box>
|
||||
<For each={props.metadata.summary ?? []}>
|
||||
{(task) => (
|
||||
<text style={{ fg: Theme.textMuted }}>
|
||||
<text style={{ fg: theme.textMuted }}>
|
||||
∟ {task.tool} {task.state.status === "completed" ? task.state.title : ""}
|
||||
</text>
|
||||
)}
|
||||
|
|
@ -1072,6 +1070,7 @@ ToolRegistry.register<typeof EditTool>({
|
|||
container: "block",
|
||||
render(props) {
|
||||
const ctx = use()
|
||||
const { syntaxTheme } = useTheme()
|
||||
|
||||
const style = createMemo(() => (ctx.width > 120 ? "split" : "stacked"))
|
||||
|
||||
|
|
@ -1156,16 +1155,16 @@ ToolRegistry.register<typeof EditTool>({
|
|||
<Match when={diff() && style() === "split"}>
|
||||
<box paddingLeft={1} flexDirection="row" gap={2}>
|
||||
<box flexGrow={1} flexBasis={0}>
|
||||
<code filetype={ft()} syntaxStyle={syntaxTheme} content={diff()!.oldContent} />
|
||||
<code filetype={ft()} syntaxStyle={syntaxTheme()} content={diff()!.oldContent} />
|
||||
</box>
|
||||
<box flexGrow={1} flexBasis={0}>
|
||||
<code filetype={ft()} syntaxStyle={syntaxTheme} content={diff()!.newContent} />
|
||||
<code filetype={ft()} syntaxStyle={syntaxTheme()} content={diff()!.newContent} />
|
||||
</box>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={code()}>
|
||||
<box paddingLeft={1}>
|
||||
<code filetype={ft()} syntaxStyle={syntaxTheme} content={code()} />
|
||||
<code filetype={ft()} syntaxStyle={syntaxTheme()} content={code()} />
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
|
@ -1197,11 +1196,12 @@ ToolRegistry.register<typeof TodoWriteTool>({
|
|||
name: "todowrite",
|
||||
container: "block",
|
||||
render(props) {
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<box>
|
||||
<For each={props.input.todos ?? []}>
|
||||
{(todo) => (
|
||||
<text style={{ fg: todo.status === "in_progress" ? Theme.success : Theme.textMuted }}>
|
||||
<text style={{ fg: todo.status === "in_progress" ? theme.success : theme.textMuted }}>
|
||||
[{todo.status === "completed" ? "✓" : " "}] {todo.content}
|
||||
</text>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import { useSync } from "@tui/context/sync"
|
||||
import { createMemo, For, Show, Switch, Match } from "solid-js"
|
||||
import { Theme } from "../../context/theme"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { Locale } from "@/util/locale"
|
||||
import path from "path"
|
||||
import type { AssistantMessage } from "@opencode-ai/sdk"
|
||||
|
||||
export function Sidebar(props: { sessionID: string }) {
|
||||
const sync = useSync()
|
||||
const { theme } = useTheme()
|
||||
const session = createMemo(() => sync.session.get(props.sessionID)!)
|
||||
const todo = createMemo(() => sync.data.todo[props.sessionID] ?? [])
|
||||
const messages = createMemo(() => sync.data.message[props.sessionID] ?? [])
|
||||
|
|
@ -20,10 +21,16 @@ export function Sidebar(props: { sessionID: string }) {
|
|||
})
|
||||
|
||||
const context = createMemo(() => {
|
||||
const last = messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage
|
||||
const last = messages().findLast(
|
||||
(x) => x.role === "assistant" && x.tokens.output > 0,
|
||||
) as AssistantMessage
|
||||
if (!last) return
|
||||
const total =
|
||||
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
|
||||
last.tokens.input +
|
||||
last.tokens.output +
|
||||
last.tokens.reasoning +
|
||||
last.tokens.cache.read +
|
||||
last.tokens.cache.write
|
||||
const model = sync.data.provider.find((x) => x.id === last.providerID)?.models[last.modelID]
|
||||
return {
|
||||
tokens: total.toLocaleString(),
|
||||
|
|
@ -39,16 +46,16 @@ export function Sidebar(props: { sessionID: string }) {
|
|||
<b>{session().title}</b>
|
||||
</text>
|
||||
<Show when={session().share?.url}>
|
||||
<text fg={Theme.textMuted}>{session().share!.url}</text>
|
||||
<text fg={theme.textMuted}>{session().share!.url}</text>
|
||||
</Show>
|
||||
</box>
|
||||
<box>
|
||||
<text>
|
||||
<b>Context</b>
|
||||
</text>
|
||||
<text fg={Theme.textMuted}>{context()?.tokens ?? 0} tokens</text>
|
||||
<text fg={Theme.textMuted}>{context()?.percentage ?? 0}% used</text>
|
||||
<text fg={Theme.textMuted}>{cost()} spent</text>
|
||||
<text fg={theme.textMuted}>{context()?.tokens ?? 0} tokens</text>
|
||||
<text fg={theme.textMuted}>{context()?.percentage ?? 0}% used</text>
|
||||
<text fg={theme.textMuted}>{cost()} spent</text>
|
||||
</box>
|
||||
<Show when={Object.keys(sync.data.mcp).length > 0}>
|
||||
<box>
|
||||
|
|
@ -62,9 +69,9 @@ export function Sidebar(props: { sessionID: string }) {
|
|||
flexShrink={0}
|
||||
style={{
|
||||
fg: {
|
||||
connected: Theme.success,
|
||||
failed: Theme.error,
|
||||
disabled: Theme.textMuted,
|
||||
connected: theme.success,
|
||||
failed: theme.error,
|
||||
disabled: theme.textMuted,
|
||||
}[item.status],
|
||||
}}
|
||||
>
|
||||
|
|
@ -72,10 +79,12 @@ export function Sidebar(props: { sessionID: string }) {
|
|||
</text>
|
||||
<text wrapMode="word">
|
||||
{key}{" "}
|
||||
<span style={{ fg: Theme.textMuted }}>
|
||||
<span style={{ fg: theme.textMuted }}>
|
||||
<Switch>
|
||||
<Match when={item.status === "connected"}>Connected</Match>
|
||||
<Match when={item.status === "failed" && item}>{(val) => <i>{val().error}</i>}</Match>
|
||||
<Match when={item.status === "failed" && item}>
|
||||
{(val) => <i>{val().error}</i>}
|
||||
</Match>
|
||||
<Match when={item.status === "disabled"}>Disabled in configuration</Match>
|
||||
</Switch>
|
||||
</span>
|
||||
|
|
@ -97,14 +106,14 @@ export function Sidebar(props: { sessionID: string }) {
|
|||
flexShrink={0}
|
||||
style={{
|
||||
fg: {
|
||||
connected: Theme.success,
|
||||
error: Theme.error,
|
||||
connected: theme.success,
|
||||
error: theme.error,
|
||||
}[item.status],
|
||||
}}
|
||||
>
|
||||
•
|
||||
</text>
|
||||
<text fg={Theme.textMuted}>
|
||||
<text fg={theme.textMuted}>
|
||||
{item.id} {item.root}
|
||||
</text>
|
||||
</box>
|
||||
|
|
@ -127,15 +136,15 @@ export function Sidebar(props: { sessionID: string }) {
|
|||
})
|
||||
return (
|
||||
<box flexDirection="row" gap={1} justifyContent="space-between">
|
||||
<text fg={Theme.textMuted} wrapMode="char">
|
||||
<text fg={theme.textMuted} wrapMode="char">
|
||||
{file()}
|
||||
</text>
|
||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||
<Show when={item.additions}>
|
||||
<text fg={Theme.diffAdded}>+{item.additions}</text>
|
||||
<text fg={theme.diffAdded}>+{item.additions}</text>
|
||||
</Show>
|
||||
<Show when={item.deletions}>
|
||||
<text fg={Theme.diffRemoved}>-{item.deletions}</text>
|
||||
<text fg={theme.diffRemoved}>-{item.deletions}</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
|
|
@ -151,7 +160,9 @@ export function Sidebar(props: { sessionID: string }) {
|
|||
</text>
|
||||
<For each={todo()}>
|
||||
{(todo) => (
|
||||
<text style={{ fg: todo.status === "in_progress" ? Theme.success : Theme.textMuted }}>
|
||||
<text
|
||||
style={{ fg: todo.status === "in_progress" ? theme.success : theme.textMuted }}
|
||||
>
|
||||
[{todo.status === "completed" ? "✓" : " "}] {todo.content}
|
||||
</text>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { TextAttributes } from "@opentui/core"
|
||||
import { Theme } from "../context/theme"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog, type DialogContext } from "./dialog"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
|
||||
|
|
@ -11,6 +11,7 @@ export type DialogAlertProps = {
|
|||
|
||||
export function DialogAlert(props: DialogAlertProps) {
|
||||
const dialog = useDialog()
|
||||
const { theme } = useTheme()
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.name === "return") {
|
||||
|
|
@ -22,22 +23,22 @@ export function DialogAlert(props: DialogAlertProps) {
|
|||
<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>
|
||||
<text fg={theme.textMuted}>esc</text>
|
||||
</box>
|
||||
<box paddingBottom={1}>
|
||||
<text fg={Theme.textMuted}>{props.message}</text>
|
||||
<text fg={theme.textMuted}>{props.message}</text>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
|
||||
<box
|
||||
paddingLeft={3}
|
||||
paddingRight={3}
|
||||
backgroundColor={Theme.primary}
|
||||
backgroundColor={theme.primary}
|
||||
onMouseDown={() => {
|
||||
props.onConfirm?.()
|
||||
dialog.clear()
|
||||
}}
|
||||
>
|
||||
<text fg={Theme.background}>ok</text>
|
||||
<text fg={theme.background}>ok</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { TextAttributes } from "@opentui/core"
|
||||
import { Theme } from "../context/theme"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog, type DialogContext } from "./dialog"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { For } from "solid-js"
|
||||
|
|
@ -15,6 +15,7 @@ export type DialogConfirmProps = {
|
|||
|
||||
export function DialogConfirm(props: DialogConfirmProps) {
|
||||
const dialog = useDialog()
|
||||
const { theme } = useTheme()
|
||||
const [store, setStore] = createStore({
|
||||
active: "confirm" as "confirm" | "cancel",
|
||||
})
|
||||
|
|
@ -34,10 +35,10 @@ export function DialogConfirm(props: DialogConfirmProps) {
|
|||
<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>
|
||||
<text fg={theme.textMuted}>esc</text>
|
||||
</box>
|
||||
<box paddingBottom={1}>
|
||||
<text fg={Theme.textMuted}>{props.message}</text>
|
||||
<text fg={theme.textMuted}>{props.message}</text>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
|
||||
<For each={["cancel", "confirm"]}>
|
||||
|
|
@ -45,14 +46,14 @@ export function DialogConfirm(props: DialogConfirmProps) {
|
|||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={key === store.active ? Theme.primary : undefined}
|
||||
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}>{Locale.titlecase(key)}</text>
|
||||
<text fg={key === store.active ? theme.background : theme.textMuted}>{Locale.titlecase(key)}</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
|
||||
import { Theme } from "@tui/context/theme"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { entries, filter, flatMap, groupBy, pipe, take } from "remeda"
|
||||
import { batch, createEffect, createMemo, For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
|
@ -14,7 +14,7 @@ import { Locale } from "@/util/locale"
|
|||
export interface DialogSelectProps<T> {
|
||||
title: string
|
||||
options: DialogSelectOption<T>[]
|
||||
ref?: (ref: DialogSelectRef) => void
|
||||
ref?: (ref: DialogSelectRef<T>) => void
|
||||
onMove?: (option: DialogSelectOption<T>) => void
|
||||
onFilter?: (query: string) => void
|
||||
onSelect?: (option: DialogSelectOption<T>) => void
|
||||
|
|
@ -34,16 +34,18 @@ export interface DialogSelectOption<T = any> {
|
|||
footer?: string
|
||||
category?: string
|
||||
disabled?: boolean
|
||||
bg?: string
|
||||
bg?: RGBA
|
||||
onSelect?: (ctx: DialogContext) => void
|
||||
}
|
||||
|
||||
export type DialogSelectRef = {
|
||||
export type DialogSelectRef<T> = {
|
||||
filter: string
|
||||
filtered: DialogSelectOption<T>[]
|
||||
}
|
||||
|
||||
export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
const dialog = useDialog()
|
||||
const { theme } = useTheme()
|
||||
const [store, setStore] = createStore({
|
||||
selected: 0,
|
||||
filter: "",
|
||||
|
|
@ -139,10 +141,13 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||
})
|
||||
|
||||
let scroll: ScrollBoxRenderable
|
||||
const ref: DialogSelectRef = {
|
||||
const ref: DialogSelectRef<T> = {
|
||||
get filter() {
|
||||
return store.filter
|
||||
},
|
||||
get filtered() {
|
||||
return filtered()
|
||||
},
|
||||
}
|
||||
props.ref?.(ref)
|
||||
|
||||
|
|
@ -151,7 +156,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||
<box paddingLeft={3} paddingRight={2}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD}>{props.title}</text>
|
||||
<text fg={Theme.textMuted}>esc</text>
|
||||
<text fg={theme.textMuted}>esc</text>
|
||||
</box>
|
||||
<box paddingTop={1} paddingBottom={1}>
|
||||
<input
|
||||
|
|
@ -161,9 +166,9 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||
props.onFilter?.(e)
|
||||
})
|
||||
}}
|
||||
focusedBackgroundColor={Theme.backgroundPanel}
|
||||
cursorColor={Theme.primary}
|
||||
focusedTextColor={Theme.textMuted}
|
||||
focusedBackgroundColor={theme.backgroundPanel}
|
||||
cursorColor={theme.primary}
|
||||
focusedTextColor={theme.textMuted}
|
||||
ref={(r) => {
|
||||
input = r
|
||||
input.focus()
|
||||
|
|
@ -184,7 +189,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||
<>
|
||||
<Show when={category}>
|
||||
<box paddingTop={index() > 0 ? 1 : 0} paddingLeft={1}>
|
||||
<text fg={Theme.accent} attributes={TextAttributes.BOLD}>
|
||||
<text fg={theme.accent} attributes={TextAttributes.BOLD}>
|
||||
{category}
|
||||
</text>
|
||||
</box>
|
||||
|
|
@ -205,7 +210,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||
if (index === -1) return
|
||||
moveTo(index)
|
||||
}}
|
||||
backgroundColor={active() ? (option.bg ?? Theme.primary) : RGBA.fromInts(0, 0, 0, 0)}
|
||||
backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
gap={1}
|
||||
|
|
@ -229,8 +234,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||
<For each={props.keybind ?? []}>
|
||||
{(item) => (
|
||||
<text>
|
||||
<span style={{ fg: Theme.text, attributes: TextAttributes.BOLD }}>{Keybind.toString(item.keybind)}</span>
|
||||
<span style={{ fg: Theme.textMuted }}> {item.title}</span>
|
||||
<span style={{ fg: theme.text, attributes: TextAttributes.BOLD }}>{Keybind.toString(item.keybind)}</span>
|
||||
<span style={{ fg: theme.textMuted }}> {item.title}</span>
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
|
|
@ -247,21 +252,22 @@ function Option(props: {
|
|||
footer?: string
|
||||
onMouseOver?: () => void
|
||||
}) {
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<>
|
||||
<text
|
||||
flexGrow={1}
|
||||
fg={props.active ? Theme.background : props.current ? Theme.primary : Theme.text}
|
||||
fg={props.active ? theme.background : props.current ? theme.primary : theme.text}
|
||||
attributes={props.active ? TextAttributes.BOLD : undefined}
|
||||
overflow="hidden"
|
||||
wrapMode="none"
|
||||
>
|
||||
{Locale.truncate(props.title, 62)}
|
||||
<span style={{ fg: props.active ? Theme.background : Theme.textMuted }}> {props.description}</span>
|
||||
<span style={{ fg: props.active ? theme.background : theme.textMuted }}> {props.description}</span>
|
||||
</text>
|
||||
<Show when={props.footer}>
|
||||
<box flexShrink={0}>
|
||||
<text fg={props.active ? Theme.background : Theme.textMuted}>{props.footer}</text>
|
||||
<text fg={props.active ? theme.background : theme.textMuted}>{props.footer}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { batch, createContext, Show, useContext, type JSX, type ParentProps } from "solid-js"
|
||||
import { Theme } from "@tui/context/theme"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { Renderable, RGBA } from "@opentui/core"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
|
|
@ -24,6 +24,7 @@ export function Dialog(
|
|||
}>,
|
||||
) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { theme } = useTheme()
|
||||
|
||||
return (
|
||||
<box
|
||||
|
|
@ -46,8 +47,8 @@ export function Dialog(
|
|||
customBorderChars={Border}
|
||||
width={props.size === "large" ? 80 : 60}
|
||||
maxWidth={dimensions().width - 2}
|
||||
backgroundColor={Theme.backgroundPanel}
|
||||
borderColor={Theme.border}
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
borderColor={theme.border}
|
||||
paddingTop={1}
|
||||
>
|
||||
{props.children}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { createMemo, createSignal } from "solid-js"
|
|||
|
||||
export type ShimmerProps = {
|
||||
text: string
|
||||
color: string
|
||||
color: RGBA
|
||||
}
|
||||
|
||||
const DURATION = 2_500
|
||||
|
|
@ -15,7 +15,7 @@ export function Shimmer(props: ShimmerProps) {
|
|||
loop: true,
|
||||
})
|
||||
const characters = props.text.split("")
|
||||
const color = createMemo(() => RGBA.fromHex(props.color))
|
||||
const color = props.color
|
||||
|
||||
const shimmerSignals = characters.map((_, i) => {
|
||||
const [shimmer, setShimmer] = createSignal(0.4)
|
||||
|
|
@ -47,7 +47,7 @@ export function Shimmer(props: ShimmerProps) {
|
|||
{(() => {
|
||||
return characters.map((ch, i) => {
|
||||
const shimmer = shimmerSignals[i]
|
||||
const fg = RGBA.fromInts(color().r * 255, color().g * 255, color().b * 255, shimmer() * 255)
|
||||
const fg = RGBA.fromInts(color.r * 255, color.g * 255, color.b * 255, shimmer() * 255)
|
||||
return <span style={{ fg }}>{ch}</span>
|
||||
})
|
||||
})()}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { createContext, useContext, type ParentProps, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Theme } from "@tui/context/theme"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { SplitBorder } from "../component/border"
|
||||
|
||||
export interface ToastOptions {
|
||||
|
|
@ -11,6 +11,7 @@ export interface ToastOptions {
|
|||
|
||||
export function Toast() {
|
||||
const toast = useToast()
|
||||
const { theme } = useTheme()
|
||||
|
||||
return (
|
||||
<Show when={toast.currentToast}>
|
||||
|
|
@ -25,8 +26,8 @@ export function Toast() {
|
|||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
backgroundColor={Theme.backgroundPanel}
|
||||
borderColor={Theme[current().type]}
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
borderColor={theme[current().type]}
|
||||
border={["left", "right"]}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
>
|
||||
|
|
@ -65,11 +66,7 @@ const ctx = createContext<ToastContext>()
|
|||
|
||||
export function ToastProvider(props: ParentProps) {
|
||||
const value = init()
|
||||
return (
|
||||
<ctx.Provider value={value}>
|
||||
{props.children}
|
||||
</ctx.Provider>
|
||||
)
|
||||
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
|
|
@ -78,4 +75,4 @@ export function useToast() {
|
|||
throw new Error("useToast must be used within a ToastProvider")
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"defs": {
|
||||
"vesperBg": "#101010",
|
||||
"vesperFg": "#FFF",
|
||||
"vesperComment": "#8b8b8b94",
|
||||
"vesperComment": "#8b8b8b",
|
||||
"vesperKeyword": "#A0A0A0",
|
||||
"vesperFunction": "#FFC799",
|
||||
"vesperString": "#99FFE4",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue