This commit is contained in:
Dax Raad 2025-12-06 18:21:32 -05:00
parent 2ac8dd6361
commit dfea6780d9
9 changed files with 251 additions and 170 deletions

View file

@ -18,7 +18,6 @@ jobs:
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
ref: ${{ github.head_ref || github.ref }}
- name: Setup Bun
uses: ./.github/actions/setup-bun

View file

@ -11,7 +11,7 @@ import { DialogProvider as DialogProviderList } from "@tui/component/dialog-prov
import { SDKProvider, useSDK } from "@tui/context/sdk"
import { SyncProvider, useSync } from "@tui/context/sync"
import { LocalProvider, useLocal } from "@tui/context/local"
import { DialogModel } from "@tui/component/dialog-model"
import { DialogModel, useConnected } from "@tui/component/dialog-model"
import { DialogStatus } from "@tui/component/dialog-status"
import { DialogThemeList } from "@tui/component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help"
@ -233,18 +233,21 @@ function App() {
),
)
const connected = useConnected()
command.register(() => [
{
title: "Switch session",
value: "session.list",
keybind: "session_list",
category: "Session",
suggested: sync.data.session.length > 0,
onSelect: () => {
dialog.replace(() => <DialogSessionList />)
},
},
{
title: "New session",
suggested: route.data.type === "session",
value: "session.new",
keybind: "session_new",
category: "Session",
@ -263,6 +266,7 @@ function App() {
title: "Switch model",
value: "model.list",
keybind: "model_list",
suggested: true,
category: "Agent",
onSelect: () => {
dialog.replace(() => <DialogModel />)
@ -270,6 +274,7 @@ function App() {
},
{
title: "Model cycle",
disabled: true,
value: "model.cycle_recent",
keybind: "model_cycle_recent",
category: "Agent",
@ -279,6 +284,7 @@ function App() {
},
{
title: "Model cycle reverse",
disabled: true,
value: "model.cycle_recent_reverse",
keybind: "model_cycle_recent_reverse",
category: "Agent",
@ -315,6 +321,15 @@ function App() {
local.agent.move(-1)
},
},
{
title: "Connect provider",
value: "provider.connect",
suggested: !connected(),
onSelect: () => {
dialog.replace(() => <DialogProviderList />)
},
category: "Provider",
},
{
title: "View status",
keybind: "status_view",
@ -332,14 +347,6 @@ function App() {
},
category: "System",
},
{
title: "Connect provider",
value: "provider.connect",
onSelect: () => {
dialog.replace(() => <DialogProviderList />)
},
category: "System",
},
{
title: "Toggle appearance",
value: "theme.switch_mode",

View file

@ -1,5 +1,5 @@
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
import { DialogSelect, type DialogSelectOption, type DialogSelectRef } from "@tui/ui/dialog-select"
import {
createContext,
createMemo,
@ -18,6 +18,7 @@ const ctx = createContext<Context>()
export type CommandOption = DialogSelectOption & {
keybind?: keyof KeybindsConfig
suggested?: boolean
}
function init() {
@ -26,7 +27,19 @@ function init() {
const dialog = useDialog()
const keybind = useKeybind()
const options = createMemo(() => {
return registrations().flatMap((x) => x())
const all = registrations().flatMap((x) => x())
const suggested = all.filter((x) => x.suggested)
return [
...suggested.map((x) => ({
...x,
category: "Suggested",
value: "suggested." + x.value,
})),
...all,
].map((x) => ({
...x,
footer: x.keybind ? keybind.print(x.keybind) : undefined,
}))
})
const suspended = () => suspendCount() > 0
@ -99,14 +112,12 @@ export function CommandProvider(props: ParentProps) {
}
function DialogCommand(props: { options: CommandOption[] }) {
const keybind = useKeybind()
let ref: DialogSelectRef<string>
return (
<DialogSelect
ref={(r) => (ref = r)}
title="Commands"
options={props.options.map((x) => ({
...x,
footer: x.keybind ? keybind.print(x.keybind) : undefined,
}))}
options={props.options.filter((x) => !ref?.filter || !x.value.startsWith("suggested."))}
/>
)
}

View file

@ -6,28 +6,41 @@ import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
import { Keybind } from "@/util/keybind"
import { iife } from "@/util/iife"
export function DialogModel() {
export function useConnected() {
const sync = useSync()
return createMemo(() =>
sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)),
)
}
export function DialogModel(props: { providerID?: string }) {
const local = useLocal()
const sync = useSync()
const dialog = useDialog()
const [ref, setRef] = createSignal<DialogSelectRef<unknown>>()
const connected = createMemo(() =>
sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)),
)
const connected = useConnected()
const providers = createDialogProviderOptions()
const showExtra = createMemo(() => {
if (!connected()) return false
if (props.providerID) return false
return true
})
const options = createMemo(() => {
const query = ref()?.filter
const favorites = connected() ? local.model.favorite() : []
const favorites = showExtra() ? local.model.favorite() : []
const recents = local.model.recent()
const recentList = recents
.filter((item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID))
.slice(0, 5)
const recentList = showExtra()
? recents
.filter(
(item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID),
)
.slice(0, 5)
: []
const favoriteOptions = !query
? favorites.flatMap((item) => {
@ -109,6 +122,7 @@ export function DialogModel() {
provider.models,
entries(),
filter(([_, info]) => info.status !== "deprecated"),
filter(([_, info]) => (props.providerID ? info.providerID === props.providerID : true)),
map(([model, info]) => {
const value = {
providerID: provider.id,
@ -150,7 +164,10 @@ export function DialogModel() {
if (inRecents) return false
return true
}),
sortBy((x) => x.title),
sortBy(
(x) => x.footer !== "Free",
(x) => x.title,
),
),
),
),
@ -169,6 +186,15 @@ export function DialogModel() {
]
})
const provider = createMemo(() =>
props.providerID ? sync.data.provider.find((x) => x.id === props.providerID) : null,
)
const title = createMemo(() => {
if (provider()) return provider()!.name
return "Select model"
})
return (
<DialogSelect
keybind={[
@ -189,7 +215,7 @@ export function DialogModel() {
},
]}
ref={setRef}
title="Select model"
title={title()}
current={local.model.current()}
options={options()}
/>

View file

@ -124,7 +124,7 @@ function AutoMethod(props: AutoMethodProps) {
}
await sdk.client.instance.dispose()
await sync.bootstrap()
dialog.replace(() => <DialogModel />)
dialog.replace(() => <DialogModel providerID={props.providerID} />)
})
return (
@ -172,7 +172,7 @@ function CodeMethod(props: CodeMethodProps) {
if (!error) {
await sdk.client.instance.dispose()
await sync.bootstrap()
dialog.replace(() => <DialogModel />)
dialog.replace(() => <DialogModel providerID={props.providerID} />)
return
}
setError(true)
@ -229,7 +229,7 @@ function ApiMethod(props: ApiMethodProps) {
})
await sdk.client.instance.dispose()
await sync.bootstrap()
dialog.replace(() => <DialogModel />)
dialog.replace(() => <DialogModel providerID={props.providerID} />)
}}
/>
)

View file

@ -106,6 +106,79 @@ export function Prompt(props: PromptProps) {
command.register(() => {
return [
{
title: "Clear prompt",
value: "prompt.clear",
category: "Prompt",
disabled: true,
onSelect: (dialog) => {
input.extmarks.clear()
input.clear()
dialog.clear()
},
},
{
title: "Submit prompt",
value: "prompt.submit",
disabled: true,
keybind: "input_submit",
category: "Prompt",
onSelect: (dialog) => {
if (!input.focused) return
submit()
dialog.clear()
},
},
{
title: "Paste",
value: "prompt.paste",
disabled: true,
keybind: "input_paste",
category: "Prompt",
onSelect: async () => {
const content = await Clipboard.read()
if (content?.mime.startsWith("image/")) {
await pasteImage({
filename: "clipboard",
mime: content.mime,
content: content.data,
})
}
},
},
{
title: "Interrupt session",
value: "session.interrupt",
keybind: "session_interrupt",
disabled: status().type === "idle",
category: "Session",
onSelect: (dialog) => {
if (autocomplete.visible) return
if (!input.focused) return
// TODO: this should be its own command
if (store.mode === "shell") {
setStore("mode", "normal")
return
}
if (!props.sessionID) return
setStore("interrupt", store.interrupt + 1)
setTimeout(() => {
setStore("interrupt", 0)
}, 5000)
if (store.interrupt >= 2) {
sdk.client.session.abort({
path: {
id: props.sessionID,
},
})
setStore("interrupt", 0)
}
dialog.clear()
},
},
{
title: "Open editor",
category: "Session",
@ -190,79 +263,6 @@ export function Prompt(props: PromptProps) {
input.cursorOffset = Bun.stringWidth(content)
},
},
{
title: "Clear prompt",
value: "prompt.clear",
category: "Prompt",
disabled: true,
onSelect: (dialog) => {
input.extmarks.clear()
input.clear()
dialog.clear()
},
},
{
title: "Submit prompt",
value: "prompt.submit",
disabled: true,
keybind: "input_submit",
category: "Prompt",
onSelect: (dialog) => {
if (!input.focused) return
submit()
dialog.clear()
},
},
{
title: "Paste",
value: "prompt.paste",
disabled: true,
keybind: "input_paste",
category: "Prompt",
onSelect: async () => {
const content = await Clipboard.read()
if (content?.mime.startsWith("image/")) {
await pasteImage({
filename: "clipboard",
mime: content.mime,
content: content.data,
})
}
},
},
{
title: "Interrupt session",
value: "session.interrupt",
keybind: "session_interrupt",
disabled: status().type === "idle",
category: "Session",
onSelect: (dialog) => {
if (autocomplete.visible) return
if (!input.focused) return
// TODO: this should be its own command
if (store.mode === "shell") {
setStore("mode", "normal")
return
}
if (!props.sessionID) return
setStore("interrupt", store.interrupt + 1)
setTimeout(() => {
setStore("interrupt", 0)
}, 5000)
if (store.interrupt >= 2) {
sdk.client.session.abort({
path: {
id: props.sessionID,
},
})
setStore("interrupt", 0)
}
dialog.clear()
},
},
]
})

View file

@ -1,7 +1,9 @@
import { createMemo, Match, Show, Switch } from "solid-js"
import { createMemo, Match, onCleanup, onMount, Show, Switch } from "solid-js"
import { useTheme } from "../../context/theme"
import { useSync } from "../../context/sync"
import { useDirectory } from "../../context/directory"
import { useConnected } from "../../component/dialog-model"
import { createStore } from "solid-js/store"
export function Footer() {
const { theme } = useTheme()
@ -10,27 +12,64 @@ export function Footer() {
const mcpError = createMemo(() => Object.values(sync.data.mcp).some((x) => x.status === "failed"))
const lsp = createMemo(() => Object.keys(sync.data.lsp))
const directory = useDirectory()
const connected = useConnected()
const [store, setStore] = createStore({
welcome: false,
})
onMount(() => {
function tick() {
if (connected()) return
if (!store.welcome) {
setStore("welcome", true)
timeout = setTimeout(() => tick(), 5000)
return
}
if (store.welcome) {
setStore("welcome", false)
timeout = setTimeout(() => tick(), 10_000)
return
}
}
let timeout = setTimeout(() => tick(), 10_000)
onCleanup(() => {
clearTimeout(timeout)
})
})
return (
<box flexDirection="row" justifyContent="space-between" gap={1} flexShrink={0}>
<text fg={theme.textMuted}>{directory()}</text>
<box gap={2} flexDirection="row" flexShrink={0}>
<text fg={theme.text}>
<span style={{ fg: theme.success }}></span> {lsp().length} LSP
</text>
<Show when={mcp().length}>
<text fg={theme.text}>
<Switch>
<Match when={mcpError()}>
<span style={{ fg: theme.error }}> </span>
</Match>
<Match when={true}>
<span style={{ fg: theme.success }}> </span>
</Match>
</Switch>
{mcp().length} MCP
</text>
</Show>
<text fg={theme.textMuted}>/status</text>
<Switch>
<Match when={store.welcome}>
<text fg={theme.text}>
Get started <span style={{ fg: theme.textMuted }}>/connect</span>
</text>
</Match>
<Match when={connected()}>
<text fg={theme.text}>
<span style={{ fg: theme.success }}></span> {lsp().length} LSP
</text>
<Show when={mcp().length}>
<text fg={theme.text}>
<Switch>
<Match when={mcpError()}>
<span style={{ fg: theme.error }}> </span>
</Match>
<Match when={true}>
<span style={{ fg: theme.success }}> </span>
</Match>
</Switch>
{mcp().length} MCP
</text>
</Show>
<text fg={theme.textMuted}>/status</text>
</Match>
</Switch>
</box>
</box>
)

View file

@ -91,31 +91,29 @@ export function Header() {
<ContextInfo context={context} cost={cost} />
</box>
</Match>
<Match when={!shareEnabled()}>
<Match when={true}>
<box flexDirection="row" justifyContent="space-between" gap={1}>
<Title session={session} />
<ContextInfo context={context} cost={cost} />
</box>
</Match>
<Match when={true}>
<Title session={session} />
<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">
{session().share!.url}
</text>
</Match>
<Match when={true}>
<text fg={theme.text} wrapMode="word">
/share <span style={{ fg: theme.textMuted }}>to create a shareable link</span>
</text>
</Match>
</Switch>
<Show when={shareEnabled()}>
<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">
{session().share!.url}
</text>
</Match>
<Match when={true}>
<text fg={theme.text} wrapMode="word">
/share <span style={{ fg: theme.textMuted }}>copy link</span>
</text>
</Match>
</Switch>
</box>
</box>
<ContextInfo context={context} cost={cost} />
</box>
</Show>
</Match>
</Switch>
</box>

View file

@ -242,6 +242,34 @@ export function Session() {
const command = useCommandDialog()
command.register(() => [
...(sync.data.config.share !== "disabled"
? [
{
title: "Share session",
value: "session.share",
suggested: route.type === "session",
keybind: "session_share" as const,
disabled: !!session()?.share?.url,
category: "Session",
onSelect: async (dialog: any) => {
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", variant: "error" }),
),
)
.then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" }))
.catch(() => toast.show({ message: "Failed to share session", variant: "error" }))
dialog.clear()
},
},
]
: []),
{
title: "Rename session",
value: "session.rename",
@ -297,33 +325,6 @@ export function Session() {
dialog.clear()
},
},
...(sync.data.config.share !== "disabled"
? [
{
title: "Share session",
value: "session.share",
keybind: "session_share" as const,
disabled: !!session()?.share?.url,
category: "Session",
onSelect: async (dialog: any) => {
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", variant: "error" }),
),
)
.then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" }))
.catch(() => toast.show({ message: "Failed to share session", variant: "error" }))
dialog.clear()
},
},
]
: []),
{
title: "Unshare session",
value: "session.unshare",