Dax Raad 2 месяцев назад
Родитель
Сommit
dfea6780d9

+ 0 - 1
.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

+ 16 - 9
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(() => <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",

+ 18 - 7
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<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."))}
     />
   )
 }

+ 38 - 12
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<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()}
     />

+ 3 - 3
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(() => <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} />)
       }}
     />
   )

+ 73 - 73
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()
-        },
-      },
     ]
   })
 

+ 57 - 18
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 (
     <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>
   )

+ 18 - 20
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={!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>

+ 28 - 27
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",