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() {
-
+
-
-
-
-
-
-
-
-
- {session().share!.url}
-
-
-
-
- /share to create a shareable link
-
-
-
+
+
+
+
+
+
+ {session().share!.url}
+
+
+
+
+ /share copy link
+
+
+
+
-
-
+
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",