فهرست منبع

tui design refinement (#4809)

Dax 4 ماه پیش
والد
کامیت
63bfe76720

+ 7 - 1
.opencode/opencode.jsonc

@@ -2,7 +2,7 @@
   "$schema": "https://opencode.ai/config.json",
   "plugin": ["opencode-openai-codex-auth"],
   // "enterprise": {
-  //   "url": "http://localhost:3000",
+  //   "url": "https://enterprise.dev.opencode.ai",
   // },
   "provider": {
     "opencode": {
@@ -11,4 +11,10 @@
       },
     },
   },
+  "mcp": {
+    "exa": {
+      "type": "remote",
+      "url": "https://mcp.exa.ai/mcp",
+    },
+  },
 }

+ 8 - 45
packages/opencode/src/cli/cmd/tui/app.tsx

@@ -452,51 +452,14 @@ function App() {
         }
       }}
     >
-      <box flexDirection="column" flexGrow={1}>
-        <Switch>
-          <Match when={route.data.type === "home"}>
-            <Home />
-          </Match>
-          <Match when={route.data.type === "session"}>
-            <Session />
-          </Match>
-        </Switch>
-      </box>
-      <box
-        height={1}
-        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>
-            <text fg={theme.text} attributes={TextAttributes.BOLD}>
-              code{" "}
-            </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, "~")}
-              {sync.data.vcs?.branch ? `:${sync.data.vcs.branch}` : ""}
-            </text>
-          </box>
-        </box>
-        <Show when={false}>
-          <box flexDirection="row" flexShrink={0}>
-            <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={undefined}>
-              <span style={{ bold: true }}> {local.agent.current().name.toUpperCase()}</span>
-              <span> AGENT </span>
-            </text>
-          </box>
-        </Show>
-      </box>
+      <Switch>
+        <Match when={route.data.type === "home"}>
+          <Home />
+        </Match>
+        <Match when={route.data.type === "session"}>
+          <Session />
+        </Match>
+      </Switch>
     </box>
   )
 }

+ 13 - 0
packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx

@@ -197,11 +197,24 @@ function ApiMethod(props: ApiMethodProps) {
   const dialog = useDialog()
   const sdk = useSDK()
   const sync = useSync()
+  const { theme } = useTheme()
 
   return (
     <DialogPrompt
       title={props.title}
       placeholder="API key"
+      description={
+        props.providerID === "opencode" ? (
+          <box gap={1}>
+            <text fg={theme.textMuted}>
+              OpenCode Zen gives you access to all the best coding models at the cheapest prices with a single API key.
+            </text>
+            <text>
+              Go to <span style={{ fg: theme.primary }}>https://opencode.ai/zen</span> to get a key
+            </text>
+          </box>
+        ) : undefined
+      }
       onConfirm={async (value) => {
         if (!value) return
         sdk.client.auth.set({

+ 5 - 0
packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx

@@ -292,6 +292,11 @@ export function Autocomplete(props: {
         description: "open editor",
         onSelect: () => command.trigger("prompt.editor", "prompt"),
       },
+      {
+        display: "/connect",
+        description: "connect to a provider",
+        onSelect: () => command.trigger("provider.connect"),
+      },
       {
         display: "/help",
         description: "show help",

+ 7 - 6
packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

@@ -637,11 +637,7 @@ export function Prompt(props: PromptProps) {
             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"))}`
-                  : undefined
-              }
+              placeholder={props.sessionID ? undefined : "Build anything..."}
               textColor={theme.text}
               focusedTextColor={theme.text}
               minHeight={1}
@@ -781,7 +777,12 @@ export function Prompt(props: PromptProps) {
                   return
                 }
               }}
-              ref={(r: TextareaRenderable) => (input = r)}
+              ref={(r: TextareaRenderable) => {
+                input = r
+                setTimeout(() => {
+                  input.cursorColor = highlight()
+                }, 0)
+              }}
               onMouseDown={(r: MouseEvent) => r.target?.focus()}
               focusedBackgroundColor={theme.backgroundElement}
               cursorColor={highlight()}

+ 12 - 0
packages/opencode/src/cli/cmd/tui/context/directory.ts

@@ -0,0 +1,12 @@
+import { createMemo } from "solid-js"
+import { useSync } from "./sync"
+import { Global } from "@/global"
+
+export function useDirectory() {
+  const sync = useSync()
+  return createMemo(() => {
+    const result = process.cwd().replace(Global.Path.home, "~")
+    if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch
+    return result
+  })
+}

+ 30 - 22
packages/opencode/src/cli/cmd/tui/routes/home.tsx

@@ -8,6 +8,8 @@ import { Locale } from "@/util/locale"
 import { useSync } from "../context/sync"
 import { Toast } from "../ui/toast"
 import { useArgs } from "../context/args"
+import { Global } from "@/global"
+import { useDirectory } from "../context/directory"
 
 // TODO: what is the best way to do this?
 let once = false
@@ -15,6 +17,7 @@ let once = false
 export function Home() {
   const sync = useSync()
   const { theme } = useTheme()
+  const mcp = createMemo(() => Object.keys(sync.data.mcp).length > 0)
   const mcpError = createMemo(() => {
     return Object.values(sync.data.mcp).some((x) => x.status === "failed")
   })
@@ -47,31 +50,36 @@ export function Home() {
       once = true
     }
   })
+  const directory = useDirectory()
 
   return (
-    <box flexGrow={1} justifyContent="center" alignItems="center" paddingLeft={2} paddingRight={2} gap={1}>
-      <Logo />
-      <box width={39}>
-        <HelpRow keybind="command_list">Commands</HelpRow>
-        <HelpRow keybind="session_list">List sessions</HelpRow>
-        <HelpRow keybind="model_list">Switch model</HelpRow>
-        <HelpRow keybind="agent_cycle">Switch agent</HelpRow>
+    <>
+      <box flexGrow={1} justifyContent="center" alignItems="center" paddingLeft={2} paddingRight={2} gap={1}>
+        <Logo />
+        <box width="100%" maxWidth={75} zIndex={1000} paddingTop={1}>
+          <Prompt ref={(r) => (prompt = r)} hint={Hint} />
+        </box>
+        <Toast />
       </box>
-      <box width="100%" maxWidth={75} zIndex={1000} paddingTop={1}>
-        <Prompt ref={(r) => (prompt = r)} hint={Hint} />
+      <box paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={2} flexDirection="row" flexShrink={0} gap={2}>
+        <text fg={theme.textMuted}>{directory()}</text>
+        <box gap={1} flexDirection="row" flexShrink={0}>
+          <Show when={mcp()}>
+            <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>
+              {Object.keys(sync.data.mcp).length} MCP
+            </text>
+            <text fg={theme.textMuted}>/status</text>
+          </Show>
+        </box>
       </box>
-      <Toast />
-    </box>
-  )
-}
-
-function HelpRow(props: ParentProps<{ keybind: keyof KeybindsConfig }>) {
-  const keybind = useKeybind()
-  const { theme } = useTheme()
-  return (
-    <box flexDirection="row" justifyContent="space-between" width="100%">
-      <text fg={theme.text}>{props.children}</text>
-      <text fg={theme.primary}>{keybind.print(props.keybind)}</text>
-    </box>
+    </>
   )
 }

+ 37 - 0
packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx

@@ -0,0 +1,37 @@
+import { createMemo, Match, Show, Switch } from "solid-js"
+import { useTheme } from "../../context/theme"
+import { useSync } from "../../context/sync"
+import { useDirectory } from "../../context/directory"
+
+export function Footer() {
+  const { theme } = useTheme()
+  const sync = useSync()
+  const mcp = createMemo(() => Object.keys(sync.data.mcp))
+  const mcpError = createMemo(() => Object.values(sync.data.mcp).some((x) => x.status === "failed"))
+  const lsp = createMemo(() => Object.keys(sync.data.lsp))
+  const directory = useDirectory()
+  return (
+    <box flexDirection="row" justifyContent="space-between" gap={1}>
+      <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>
+      </box>
+    </box>
+  )
+}

+ 61 - 32
packages/opencode/src/cli/cmd/tui/routes/session/header.tsx

@@ -3,15 +3,16 @@ import { useRouteData } from "@tui/context/route"
 import { useSync } from "@tui/context/sync"
 import { pipe, sumBy } from "remeda"
 import { useTheme } from "@tui/context/theme"
-import { SplitBorder } from "@tui/component/border"
+import { SplitBorder, EmptyBorder } from "@tui/component/border"
 import type { AssistantMessage, Session } from "@opencode-ai/sdk"
+import { useDirectory } from "../../context/directory"
+import { useKeybind } from "../../context/keybind"
 
 const Title = (props: { session: Accessor<Session> }) => {
   const { theme } = useTheme()
   return (
     <text fg={theme.text}>
-      <span style={{ bold: true, fg: theme.accent }}>#</span>{" "}
-      <span style={{ bold: true }}>{props.session().title}</span>
+      <span style={{ bold: true }}>#</span> <span style={{ bold: true }}>{props.session().title}</span>
     </text>
   )
 }
@@ -53,43 +54,71 @@ export function Header() {
     const model = sync.data.provider.find((x) => x.id === last.providerID)?.models[last.modelID]
     let result = total.toLocaleString()
     if (model?.limit.context) {
-      result += "/" + Math.round((total / model.limit.context) * 100) + "%"
+      result += "  " + Math.round((total / model.limit.context) * 100) + "%"
     }
     return result
   })
 
   const { theme } = useTheme()
+  const keybind = useKeybind()
 
   return (
-    <box paddingLeft={1} paddingRight={1} {...SplitBorder} borderColor={theme.backgroundElement} flexShrink={0}>
-      <Show
-        when={shareEnabled()}
-        fallback={
-          <box flexDirection="row" justifyContent="space-between" gap={1}>
-            <Title session={session} />
-            <ContextInfo context={context} cost={cost} />
-          </box>
-        }
+    <box flexShrink={0}>
+      <box
+        paddingTop={1}
+        paddingBottom={1}
+        paddingLeft={2}
+        paddingRight={1}
+        {...SplitBorder}
+        border={["left"]}
+        borderColor={theme.border}
+        flexShrink={0}
+        backgroundColor={theme.backgroundPanel}
       >
-        <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>
-          </box>
-          <ContextInfo context={context} cost={cost} />
-        </box>
-      </Show>
+        <Switch>
+          <Match when={session()?.parentID}>
+            <box flexDirection="row" gap={2}>
+              <text fg={theme.text}>
+                <b>Subagent session</b>
+              </text>
+              <text fg={theme.text}>
+                Prev <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle_reverse")}</span>
+              </text>
+              <text fg={theme.text}>
+                Next <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle")}</span>
+              </text>
+              <box flexGrow={1} flexShrink={1} />
+              <ContextInfo context={context} cost={cost} />
+            </box>
+          </Match>
+          <Match when={!shareEnabled()}>
+            <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>
+              </box>
+              <ContextInfo context={context} cost={cost} />
+            </box>
+          </Match>
+        </Switch>
+      </box>
     </box>
   )
 }

+ 12 - 25
packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

@@ -62,6 +62,7 @@ import { Toast, useToast } from "../../ui/toast"
 import { useKV } from "../../context/kv.tsx"
 import { Editor } from "../../util/editor"
 import stripAnsi from "strip-ansi"
+import { Footer } from "./footer.tsx"
 
 addDefaultParsers(parsers.parsers)
 
@@ -114,7 +115,12 @@ export function Session() {
   const [showTimestamps, setShowTimestamps] = createSignal(kv.get("timestamps", "hide") === "show")
 
   const wide = createMemo(() => dimensions().width > 120)
-  const sidebarVisible = createMemo(() => sidebar() === "show" || (sidebar() === "auto" && wide()))
+  const sidebarVisible = createMemo(() => {
+    if (session()?.parentID) return false
+    if (sidebar() === "show") return true
+    if (sidebar() === "auto" && wide()) return true
+    return false
+  })
   const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
 
   const scrollAcceleration = createMemo(() => {
@@ -736,31 +742,9 @@ export function Session() {
         sync,
       }}
     >
-      <box flexDirection="row" paddingBottom={1} paddingTop={1} paddingLeft={2} paddingRight={2} gap={2}>
-        <box flexGrow={1} gap={1}>
+      <box flexDirection="row">
+        <box flexGrow={1} paddingBottom={1} paddingTop={1} paddingLeft={2} paddingRight={2} gap={1}>
           <Show when={session()}>
-            <Show when={session().parentID}>
-              <box
-                backgroundColor={theme.backgroundPanel}
-                justifyContent="space-between"
-                flexDirection="row"
-                paddingTop={1}
-                paddingBottom={1}
-                flexShrink={0}
-                paddingLeft={2}
-                paddingRight={2}
-              >
-                <text fg={theme.text}>
-                  Previous <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle_reverse")}</span>
-                </text>
-                <text fg={theme.text}>
-                  <b>Viewing subagent session</b>
-                </text>
-                <text fg={theme.text}>
-                  <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle")}</span> Next
-                </text>
-              </box>
-            </Show>
             <Show when={!sidebarVisible()}>
               <Header />
             </Show>
@@ -885,6 +869,9 @@ export function Session() {
                 sessionID={route.sessionID}
               />
             </box>
+            <Show when={!sidebarVisible()}>
+              <Footer />
+            </Show>
           </Show>
           <Toast />
         </box>

+ 195 - 134
packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx

@@ -1,9 +1,14 @@
 import { useSync } from "@tui/context/sync"
-import { createMemo, For, Show, Switch, Match, createSignal } from "solid-js"
+import { createMemo, For, Show, Switch, Match } from "solid-js"
+import { createStore } from "solid-js/store"
 import { useTheme } from "../../context/theme"
 import { Locale } from "@/util/locale"
 import path from "path"
 import type { AssistantMessage } from "@opencode-ai/sdk"
+import { Global } from "@/global"
+import { Installation } from "@/installation"
+import { useKeybind } from "../../context/keybind"
+import { useDirectory } from "../../context/directory"
 
 export function Sidebar(props: { sessionID: string }) {
   const sync = useSync()
@@ -13,10 +18,12 @@ export function Sidebar(props: { sessionID: string }) {
   const todo = createMemo(() => sync.data.todo[props.sessionID] ?? [])
   const messages = createMemo(() => sync.data.message[props.sessionID] ?? [])
 
-  const [mcpExpanded, setMcpExpanded] = createSignal(true)
-  const [diffExpanded, setDiffExpanded] = createSignal(true)
-  const [todoExpanded, setTodoExpanded] = createSignal(true)
-  const [lspExpanded, setLspExpanded] = createSignal(true)
+  const [expanded, setExpanded] = createStore({
+    mcp: true,
+    diff: true,
+    todo: true,
+    lsp: true,
+  })
 
   // Sort MCP servers alphabetically for consistent display order
   const mcpEntries = createMemo(() => Object.entries(sync.data.mcp).sort(([a], [b]) => a.localeCompare(b)))
@@ -41,87 +48,104 @@ export function Sidebar(props: { sessionID: string }) {
     }
   })
 
+  const keybind = useKeybind()
+  const directory = useDirectory()
+
+  const hasProviders = createMemo(() =>
+    sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)),
+  )
+
   return (
     <Show when={session()}>
-      <scrollbox width={40}>
-        <box flexShrink={0} gap={1} paddingRight={1}>
-          <box>
-            <text fg={theme.text}>
-              <b>{session().title}</b>
-            </text>
-            <Show when={session().share?.url}>
-              <text fg={theme.textMuted}>{session().share!.url}</text>
-            </Show>
-          </box>
-          <box>
-            <text fg={theme.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>
-          </box>
-          <Show when={mcpEntries().length > 0}>
+      <box
+        backgroundColor={theme.backgroundPanel}
+        width={42}
+        paddingTop={1}
+        paddingBottom={1}
+        paddingLeft={2}
+        paddingRight={2}
+      >
+        <scrollbox flexGrow={1}>
+          <box flexShrink={0} gap={1} paddingRight={1}>
             <box>
-              <box
-                flexDirection="row"
-                gap={1}
-                onMouseDown={() => mcpEntries().length > 2 && setMcpExpanded(!mcpExpanded())}
-              >
-                <Show when={mcpEntries().length > 2}>
-                  <text fg={theme.text}>{mcpExpanded() ? "▼" : "▶"}</text>
-                </Show>
-                <text fg={theme.text}>
-                  <b>MCP</b>
-                </text>
-              </box>
-              <Show when={mcpEntries().length <= 2 || mcpExpanded()}>
-                <For each={mcpEntries()}>
-                  {([key, item]) => (
-                    <box flexDirection="row" gap={1}>
-                      <text
-                        flexShrink={0}
-                        style={{
-                          fg: {
-                            connected: theme.success,
-                            failed: theme.error,
-                            disabled: theme.textMuted,
-                          }[item.status],
-                        }}
-                      >
-                        •
-                      </text>
-                      <text fg={theme.text} wrapMode="word">
-                        {key}{" "}
-                        <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 === "disabled"}>Disabled in configuration</Match>
-                          </Switch>
-                        </span>
-                      </text>
-                    </box>
-                  )}
-                </For>
+              <text fg={theme.text}>
+                <b>{session().title}</b>
+              </text>
+              <Show when={session().share?.url}>
+                <text fg={theme.textMuted}>{session().share!.url}</text>
               </Show>
             </box>
-          </Show>
-          <Show when={sync.data.lsp.length > 0}>
+            <box>
+              <text fg={theme.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>
+            </box>
+            <Show when={mcpEntries().length > 0}>
+              <box>
+                <box
+                  flexDirection="row"
+                  gap={1}
+                  onMouseDown={() => mcpEntries().length > 2 && setExpanded("mcp", !expanded.mcp)}
+                >
+                  <Show when={mcpEntries().length > 2}>
+                    <text fg={theme.text}>{expanded.mcp ? "▼" : "▶"}</text>
+                  </Show>
+                  <text fg={theme.text}>
+                    <b>MCP</b>
+                  </text>
+                </box>
+                <Show when={mcpEntries().length <= 2 || expanded.mcp}>
+                  <For each={mcpEntries()}>
+                    {([key, item]) => (
+                      <box flexDirection="row" gap={1}>
+                        <text
+                          flexShrink={0}
+                          style={{
+                            fg: {
+                              connected: theme.success,
+                              failed: theme.error,
+                              disabled: theme.textMuted,
+                            }[item.status],
+                          }}
+                        >
+                          •
+                        </text>
+                        <text fg={theme.text} wrapMode="word">
+                          {key}{" "}
+                          <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 === "disabled"}>Disabled in configuration</Match>
+                            </Switch>
+                          </span>
+                        </text>
+                      </box>
+                    )}
+                  </For>
+                </Show>
+              </box>
+            </Show>
             <box>
               <box
                 flexDirection="row"
                 gap={1}
-                onMouseDown={() => sync.data.lsp.length > 2 && setLspExpanded(!lspExpanded())}
+                onMouseDown={() => sync.data.lsp.length > 2 && setExpanded("lsp", !expanded.lsp)}
               >
                 <Show when={sync.data.lsp.length > 2}>
-                  <text fg={theme.text}>{lspExpanded() ? "▼" : "▶"}</text>
+                  <text fg={theme.text}>{expanded.lsp ? "▼" : "▶"}</text>
                 </Show>
                 <text fg={theme.text}>
                   <b>LSP</b>
                 </text>
               </box>
-              <Show when={sync.data.lsp.length <= 2 || lspExpanded()}>
+              <Show when={sync.data.lsp.length <= 2 || expanded.lsp}>
+                <Show when={sync.data.lsp.length === 0}>
+                  <text fg={theme.textMuted}>LSPs will activate as files are read</text>
+                </Show>
                 <For each={sync.data.lsp}>
                   {(item) => (
                     <box flexDirection="row" gap={1}>
@@ -144,78 +168,115 @@ export function Sidebar(props: { sessionID: string }) {
                 </For>
               </Show>
             </box>
-          </Show>
-          <Show when={todo().length > 0}>
-            <box>
-              <box
-                flexDirection="row"
-                gap={1}
-                onMouseDown={() => todo().length > 2 && setTodoExpanded(!todoExpanded())}
-              >
-                <Show when={todo().length > 2}>
-                  <text fg={theme.text}>{todoExpanded() ? "▼" : "▶"}</text>
+            <Show when={todo().length > 0 && todo().some((t) => t.status !== "completed")}>
+              <box>
+                <box
+                  flexDirection="row"
+                  gap={1}
+                  onMouseDown={() => todo().length > 2 && setExpanded("todo", !expanded.todo)}
+                >
+                  <Show when={todo().length > 2}>
+                    <text fg={theme.text}>{expanded.todo ? "▼" : "▶"}</text>
+                  </Show>
+                  <text fg={theme.text}>
+                    <b>Todo</b>
+                  </text>
+                </box>
+                <Show when={todo().length <= 2 || expanded.todo}>
+                  <For each={todo()}>
+                    {(todo) => (
+                      <text style={{ fg: todo.status === "in_progress" ? theme.success : theme.textMuted }}>
+                        [{todo.status === "completed" ? "✓" : " "}] {todo.content}
+                      </text>
+                    )}
+                  </For>
                 </Show>
-                <text fg={theme.text}>
-                  <b>Todo</b>
-                </text>
               </box>
-              <Show when={todo().length <= 2 || todoExpanded()}>
-                <For each={todo()}>
-                  {(todo) => (
-                    <text style={{ fg: todo.status === "in_progress" ? theme.success : theme.textMuted }}>
-                      [{todo.status === "completed" ? "✓" : " "}] {todo.content}
-                    </text>
-                  )}
-                </For>
-              </Show>
-            </box>
-          </Show>
-          <Show when={diff().length > 0}>
-            <box>
-              <box
-                flexDirection="row"
-                gap={1}
-                onMouseDown={() => diff().length > 2 && setDiffExpanded(!diffExpanded())}
-              >
-                <Show when={diff().length > 2}>
-                  <text fg={theme.text}>{diffExpanded() ? "▼" : "▶"}</text>
+            </Show>
+            <Show when={diff().length > 0}>
+              <box>
+                <box
+                  flexDirection="row"
+                  gap={1}
+                  onMouseDown={() => diff().length > 2 && setExpanded("diff", !expanded.diff)}
+                >
+                  <Show when={diff().length > 2}>
+                    <text fg={theme.text}>{expanded.diff ? "▼" : "▶"}</text>
+                  </Show>
+                  <text fg={theme.text}>
+                    <b>Modified Files</b>
+                  </text>
+                </box>
+                <Show when={diff().length <= 2 || expanded.diff}>
+                  <For each={diff() || []}>
+                    {(item) => {
+                      const file = createMemo(() => {
+                        const splits = item.file.split(path.sep).filter(Boolean)
+                        const last = splits.at(-1)!
+                        const rest = splits.slice(0, -1).join(path.sep)
+                        if (!rest) return last
+                        return Locale.truncateMiddle(rest, 30 - last.length) + "/" + last
+                      })
+                      return (
+                        <box flexDirection="row" gap={1} justifyContent="space-between">
+                          <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>
+                            </Show>
+                            <Show when={item.deletions}>
+                              <text fg={theme.diffRemoved}>-{item.deletions}</text>
+                            </Show>
+                          </box>
+                        </box>
+                      )
+                    }}
+                  </For>
                 </Show>
-                <text fg={theme.text}>
-                  <b>Modified Files</b>
+              </box>
+            </Show>
+          </box>
+        </scrollbox>
+
+        <box flexShrink={0} gap={1}>
+          <Show when={!hasProviders()}>
+            <box
+              backgroundColor={theme.backgroundElement}
+              paddingTop={1}
+              paddingBottom={1}
+              paddingLeft={2}
+              paddingRight={2}
+              flexDirection="row"
+              gap={1}
+            >
+              <text flexShrink={0}>⬖</text>
+              <box flexGrow={1} gap={1}>
+                <text>
+                  <b>Getting started</b>
                 </text>
+                <text fg={theme.textMuted}>OpenCode includes free models so you can start immediately.</text>
+                <text fg={theme.textMuted}>
+                  Connect from 75+ providers to use other models, including Claude, GPT, Gemini etc
+                </text>
+                <box flexDirection="row" gap={1} justifyContent="space-between">
+                  <text>Connect provider</text>
+                  <text fg={theme.textMuted}>/connect</text>
+                </box>
               </box>
-              <Show when={diff().length <= 2 || diffExpanded()}>
-                <For each={diff() || []}>
-                  {(item) => {
-                    const file = createMemo(() => {
-                      const splits = item.file.split(path.sep).filter(Boolean)
-                      const last = splits.at(-1)!
-                      const rest = splits.slice(0, -1).join(path.sep)
-                      if (!rest) return last
-                      return Locale.truncateMiddle(rest, 30 - last.length) + "/" + last
-                    })
-                    return (
-                      <box flexDirection="row" gap={1} justifyContent="space-between">
-                        <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>
-                          </Show>
-                          <Show when={item.deletions}>
-                            <text fg={theme.diffRemoved}>-{item.deletions}</text>
-                          </Show>
-                        </box>
-                      </box>
-                    )
-                  }}
-                </For>
-              </Show>
             </box>
           </Show>
+          <text fg={theme.textMuted}>{directory()}</text>
+          <text fg={theme.textMuted}>
+            <span style={{ fg: theme.success }}>•</span> <b>Open</b>
+            <span style={{ fg: theme.text }}>
+              <b>Code</b>
+            </span>{" "}
+            <span>{Installation.VERSION}</span>
+          </text>
         </box>
-      </scrollbox>
+      </box>
     </Show>
   )
 }