From dfea6780d9a8c4b28e7f3481144e76deb9b80edf Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 6 Dec 2025 18:21:32 -0500 Subject: [PATCH] sync --- .github/workflows/format.yml | 1 - packages/opencode/src/cli/cmd/tui/app.tsx | 25 +-- .../cli/cmd/tui/component/dialog-command.tsx | 25 ++- .../cli/cmd/tui/component/dialog-model.tsx | 50 ++++-- .../cli/cmd/tui/component/dialog-provider.tsx | 6 +- .../cli/cmd/tui/component/prompt/index.tsx | 146 +++++++++--------- .../src/cli/cmd/tui/routes/session/footer.tsx | 75 ++++++--- .../src/cli/cmd/tui/routes/session/header.tsx | 38 +++-- .../src/cli/cmd/tui/routes/session/index.tsx | 55 +++---- 9 files changed, 251 insertions(+), 170 deletions(-) diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index f27678b2e..96e316c5f 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -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 diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index bd2a23355..e7fa4a6de 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -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(() => ) }, }, { 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(() => ) @@ -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(() => ) + }, + 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(() => ) - }, - category: "System", - }, { title: "Toggle appearance", value: "theme.switch_mode", diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx index b9ba4a9ba..06b52863d 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx @@ -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() 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 return ( (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."))} /> ) } diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index 95e2dbcde..0ea4cbd68 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -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>() - 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 ( diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index 8ba7845f2..b96fc2d09 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -124,7 +124,7 @@ function AutoMethod(props: AutoMethodProps) { } await sdk.client.instance.dispose() await sync.bootstrap() - dialog.replace(() => ) + dialog.replace(() => ) }) return ( @@ -172,7 +172,7 @@ function CodeMethod(props: CodeMethodProps) { if (!error) { await sdk.client.instance.dispose() await sync.bootstrap() - dialog.replace(() => ) + dialog.replace(() => ) return } setError(true) @@ -229,7 +229,7 @@ function ApiMethod(props: ApiMethodProps) { }) await sdk.client.instance.dispose() await sync.bootstrap() - dialog.replace(() => ) + dialog.replace(() => ) }} /> ) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 98370b061..b7a02b850 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -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() - }, - }, ] }) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx index 8e2b1accd..063e9051e 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx @@ -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 ( {directory()} - - {lsp().length} LSP - - - - - - - - - - - - {mcp().length} MCP - - - /status + + + + Get started /connect + + + + + {lsp().length} LSP + + + + + + + + + + + + {mcp().length} MCP + + + /status + + ) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx index eb780f521..d024bb5f5 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx @@ -91,31 +91,29 @@ export function Header() { - + <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> diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 4d1b50ccf..74dfff1e8 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -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",