Browse Source

refactor(tui): reduce legacy sync usage

Dax Raad 1 month ago
parent
commit
0fa9e5039e

+ 6 - 8
packages/client/src/effect/api/api.ts

@@ -476,18 +476,16 @@ export interface IntegrationApi<E = never> {
 type Endpoint11_0Request = Parameters<RawClient["server.mcp"]["mcp.list"]>[0]
 export type Endpoint11_0Input = { readonly location?: Endpoint11_0Request["query"]["location"] }
 export type Endpoint11_0Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.list"]>>
-export type ServerMcpListOperation<E = never> = (input?: Endpoint11_0Input) => Effect.Effect<Endpoint11_0Output, E>
+export type McpListOperation<E = never> = (input?: Endpoint11_0Input) => Effect.Effect<Endpoint11_0Output, E>
 
 type Endpoint11_1Request = Parameters<RawClient["server.mcp"]["mcp.resource.catalog"]>[0]
 export type Endpoint11_1Input = { readonly location?: Endpoint11_1Request["query"]["location"] }
 export type Endpoint11_1Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.resource.catalog"]>>
-export type ServerMcpResourceCatalogOperation<E = never> = (
-  input?: Endpoint11_1Input,
-) => Effect.Effect<Endpoint11_1Output, E>
+export type McpResourceCatalogOperation<E = never> = (input?: Endpoint11_1Input) => Effect.Effect<Endpoint11_1Output, E>
 
-export interface ServerMcpApi<E = never> {
-  readonly list: ServerMcpListOperation<E>
-  readonly resource: { readonly catalog: ServerMcpResourceCatalogOperation<E> }
+export interface McpApi<E = never> {
+  readonly list: McpListOperation<E>
+  readonly resource: { readonly catalog: McpResourceCatalogOperation<E> }
 }
 
 type Endpoint12_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]
@@ -955,7 +953,7 @@ export interface AppApi<E = never> {
   readonly generate: GenerateApi<E>
   readonly provider: ProviderApi<E>
   readonly integration: IntegrationApi<E>
-  readonly "server.mcp": ServerMcpApi<E>
+  readonly mcp: McpApi<E>
   readonly credential: CredentialApi<E>
   readonly project: ProjectApi<E>
   readonly form: FormApi<E>

+ 1 - 1
packages/client/src/effect/generated/client.ts

@@ -1134,7 +1134,7 @@ const adaptClient = (raw: RawClient) => ({
   generate: adaptGroup8(raw["server.generate"]),
   provider: adaptGroup9(raw["server.provider"]),
   integration: adaptGroup10(raw["server.integration"]),
-  "server.mcp": adaptGroup11(raw["server.mcp"]),
+  mcp: adaptGroup11(raw["server.mcp"]),
   credential: adaptGroup12(raw["server.credential"]),
   project: adaptGroup13(raw["server.project"]),
   form: adaptGroup14(raw["server.form"]),

+ 9 - 9
packages/client/src/promise/generated/client.ts

@@ -90,10 +90,10 @@ import type {
   IntegrationAttemptCompleteOutput,
   IntegrationAttemptCancelInput,
   IntegrationAttemptCancelOutput,
-  ServerMcpListInput,
-  ServerMcpListOutput,
-  ServerMcpResourceCatalogInput,
-  ServerMcpResourceCatalogOutput,
+  McpListInput,
+  McpListOutput,
+  McpResourceCatalogInput,
+  McpResourceCatalogOutput,
   CredentialUpdateInput,
   CredentialUpdateOutput,
   CredentialRemoveInput,
@@ -941,9 +941,9 @@ export function make(options: ClientOptions) {
           ),
       },
     },
-    "server.mcp": {
-      list: (input?: ServerMcpListInput, requestOptions?: RequestOptions) =>
-        request<ServerMcpListOutput>(
+    mcp: {
+      list: (input?: McpListInput, requestOptions?: RequestOptions) =>
+        request<McpListOutput>(
           {
             method: "GET",
             path: `/api/mcp`,
@@ -955,8 +955,8 @@ export function make(options: ClientOptions) {
           requestOptions,
         ),
       resource: {
-        catalog: (input?: ServerMcpResourceCatalogInput, requestOptions?: RequestOptions) =>
-          request<ServerMcpResourceCatalogOutput>(
+        catalog: (input?: McpResourceCatalogInput, requestOptions?: RequestOptions) =>
+          request<McpResourceCatalogOutput>(
             {
               method: "GET",
               path: `/api/mcp/resource`,

+ 4 - 4
packages/client/src/promise/generated/types.ts

@@ -3346,24 +3346,24 @@ export type IntegrationAttemptCancelInput = {
 
 export type IntegrationAttemptCancelOutput = void
 
-export type ServerMcpListInput = {
+export type McpListInput = {
   readonly location?: {
     readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
   }["location"]
 }
 
-export type ServerMcpListOutput = {
+export type McpListOutput = {
   location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
   data: Array<McpServer>
 }
 
-export type ServerMcpResourceCatalogInput = {
+export type McpResourceCatalogInput = {
   readonly location?: {
     readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
   }["location"]
 }
 
-export type ServerMcpResourceCatalogOutput = {
+export type McpResourceCatalogOutput = {
   location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
   data: McpResourceCatalog
 }

+ 1 - 0
packages/protocol/src/client.ts

@@ -54,6 +54,7 @@ export const groupNames = {
   "server.event": "event",
   "server.pty": "pty",
   "server.shell": "shell",
+  "server.mcp": "mcp",
   "server.question": "question",
   "server.reference": "reference",
   "server.project": "project",

+ 6 - 8
packages/tui/src/app.tsx

@@ -418,10 +418,10 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
   const keymap = useOpencodeKeymap()
   const event = useEvent()
   const sdk = useSDK()
+  const sync = useSync()
   const toast = useToast()
   const themeState = useTheme()
   const { theme, mode, setMode, locked, lock, unlock } = themeState
-  const sync = useSync()
   const data = useData()
   const project = useProject()
   const exit = useExit()
@@ -435,7 +435,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
   // the same problem on every refresh while still re-alerting if the state changes.
   const mcpAlerted: Record<string, string> = {}
   createEffect(() => {
-    for (const server of data.location.mcp.list() ?? []) {
+    for (const server of data.location.mcp.server.list() ?? []) {
       const status = server.status
       if (status.status !== "failed" && status.status !== "needs_auth") {
         delete mcpAlerted[server.name]
@@ -520,7 +520,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
   }
   const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
   const [pasteSummaryEnabled, setPasteSummaryEnabled] = createSignal(
-    kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary),
+    kv.get("paste_summary_enabled", true),
   )
 
   // Update terminal window title based on current route and session
@@ -574,7 +574,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
 
   let continued = false
   createEffect(() => {
-    if (continued || sync.status === "loading" || !args.continue) return
+    if (continued || !args.continue) return
     continued = true
     const location = data.location.default()
     void sdk.api.session
@@ -600,12 +600,10 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
       .catch(toast.error)
   })
 
-  // Handle --session with --fork: wait for sync to be fully complete before forking
-  // (session list loads in non-blocking phase for --session, so we must wait for "complete"
-  // to avoid a race where reconcile overwrites the newly forked session)
+  // Handle --session with --fork once.
   let forked = false
   createEffect(() => {
-    if (forked || sync.status !== "complete" || !args.sessionID || !args.fork) return
+    if (forked || !args.sessionID || !args.fork) return
     forked = true
     void sdk.api.session
       .fork({ sessionID: args.sessionID })

+ 1 - 1
packages/tui/src/component/dialog-mcp.tsx

@@ -45,7 +45,7 @@ export function DialogMcp() {
 
   const servers = createMemo(() =>
     pipe(
-      data.location.mcp.list() ?? [],
+      data.location.mcp.server.list() ?? [],
       sortBy(
         (server) => statusMeta(server.status, theme).rank,
         (server) => server.name,

+ 1 - 102
packages/tui/src/component/dialog-status.tsx

@@ -1,48 +1,17 @@
 import { TextAttributes } from "@opentui/core"
-import { fileURLToPath } from "bun"
 import { useTheme } from "../context/theme"
 import { useDialog } from "../ui/dialog"
-import { useSync } from "../context/sync"
 import { useData } from "../context/data"
 import { For, Match, Switch, Show, createMemo } from "solid-js"
 
 export type DialogStatusProps = {}
 
 export function DialogStatus() {
-  const sync = useSync()
   const data = useData()
   const { theme } = useTheme()
   const dialog = useDialog()
 
-  const mcp = createMemo(() => data.location.mcp.list() ?? [])
-  const enabledFormatters = createMemo(() => sync.data.formatter.filter((f) => f.enabled))
-
-  const plugins = createMemo(() => {
-    const list = sync.data.config.plugin ?? []
-    const result = list.map((item) => {
-      const value = typeof item === "string" ? item : item[0]
-      if (value.startsWith("file://")) {
-        const path = fileURLToPath(value)
-        const parts = path.split("/")
-        const filename = parts.pop() || path
-        if (!filename.includes(".")) return { name: filename }
-        const basename = filename.split(".")[0]
-        if (basename === "index") {
-          const dirname = parts.pop()
-          const name = dirname || basename
-          return { name }
-        }
-        return { name: basename }
-      }
-      const index = value.lastIndexOf("@")
-      if (index <= 0) return { name: value, version: "latest" }
-      const name = value.substring(0, index)
-      const version = value.substring(index + 1)
-      return { name, version }
-    })
-    return result.toSorted((a, b) => a.name.localeCompare(b.name))
-  })
-
+  const mcp = createMemo(() => data.location.mcp.server.list() ?? [])
   return (
     <box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
       <box flexDirection="row" justifyContent="space-between">
@@ -94,76 +63,6 @@ export function DialogStatus() {
           </For>
         </box>
       </Show>
-      {sync.data.lsp.length > 0 && (
-        <box>
-          <text fg={theme.text}>{sync.data.lsp.length} LSP Servers</text>
-          <For each={sync.data.lsp}>
-            {(item) => (
-              <box flexDirection="row" gap={1}>
-                <text
-                  flexShrink={0}
-                  style={{
-                    fg: {
-                      connected: theme.success,
-                      error: theme.error,
-                    }[item.status],
-                  }}
-                >
-                  •
-                </text>
-                <text fg={theme.text} wrapMode="word">
-                  <b>{item.id}</b> <span style={{ fg: theme.textMuted }}>{item.root}</span>
-                </text>
-              </box>
-            )}
-          </For>
-        </box>
-      )}
-      <Show when={enabledFormatters().length > 0} fallback={<text fg={theme.text}>No Formatters</text>}>
-        <box>
-          <text fg={theme.text}>{enabledFormatters().length} Formatters</text>
-          <For each={enabledFormatters()}>
-            {(item) => (
-              <box flexDirection="row" gap={1}>
-                <text
-                  flexShrink={0}
-                  style={{
-                    fg: theme.success,
-                  }}
-                >
-                  •
-                </text>
-                <text wrapMode="word" fg={theme.text}>
-                  <b>{item.name}</b>
-                </text>
-              </box>
-            )}
-          </For>
-        </box>
-      </Show>
-      <Show when={plugins().length > 0} fallback={<text fg={theme.text}>No Plugins</text>}>
-        <box>
-          <text fg={theme.text}>{plugins().length} Plugins</text>
-          <For each={plugins()}>
-            {(item) => (
-              <box flexDirection="row" gap={1}>
-                <text
-                  flexShrink={0}
-                  style={{
-                    fg: theme.success,
-                  }}
-                >
-                  •
-                </text>
-                <text wrapMode="word" fg={theme.text}>
-                  <b>{item.name}</b>
-                  {item.version && <span style={{ fg: theme.textMuted }}> @{item.version}</span>}
-                </text>
-              </box>
-            )}
-          </For>
-        </box>
-      </Show>
     </box>
   )
 }

+ 2 - 4
packages/tui/src/component/prompt/autocomplete.tsx

@@ -8,7 +8,6 @@ import { createStore } from "solid-js/store"
 import { useEditorContext } from "../../context/editor"
 import { useProject } from "../../context/project"
 import { useSDK } from "../../context/sdk"
-import { useSync } from "../../context/sync"
 import { useData } from "../../context/data"
 import { getScrollAcceleration } from "../../util/scroll"
 import { useTuiPaths } from "../../context/runtime"
@@ -86,7 +85,6 @@ export function Autocomplete(props: {
 }) {
   const editor = useEditorContext()
   const sdk = useSDK()
-  const sync = useSync()
   const data = useData()
   const project = useProject()
   const slashes = useCommandSlashes()
@@ -285,7 +283,7 @@ export function Autocomplete(props: {
   })
 
   function normalizeMentionPath(filePath: string) {
-    const baseDir = location()?.directory || sync.path.directory || paths.cwd
+    const baseDir = location()?.directory || project.instance.directory() || paths.cwd
     const absolute = path.resolve(filePath)
     const relative = path.relative(baseDir, absolute)
 
@@ -363,7 +361,7 @@ export function Autocomplete(props: {
     const options: AutocompleteOption[] = []
     const width = props.anchor().width - 4
 
-    for (const res of Object.values(sync.data.mcp_resource)) {
+    for (const res of data.location.mcp.resource.list(location()) ?? []) {
       options.push({
         display: Locale.truncateMiddle(res.name, width),
         // Match the name only; matching the URI caused unrelated fuzzy hits.

+ 1 - 3
packages/tui/src/component/prompt/index.tsx

@@ -23,7 +23,6 @@ import { Spinner } from "../spinner"
 import { useSDK } from "../../context/sdk"
 import { useRoute } from "../../context/route"
 import { useProject } from "../../context/project"
-import { useSync } from "../../context/sync"
 import { useEvent } from "../../context/event"
 import { editorSelectionKey, useEditorContext, type EditorSelection } from "../../context/editor"
 import { normalizePromptContent, openEditor } from "../../editor"
@@ -153,7 +152,6 @@ export function Prompt(props: PromptProps) {
   const editor = useEditorContext()
   const route = useRoute()
   const project = useProject()
-  const sync = useSync()
   const data = useData()
   const currentLocation = useLocation()
   const tuiConfig = useTuiConfig()
@@ -1193,7 +1191,7 @@ export function Prompt(props: PromptProps) {
     const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
     if (
       (lineCount >= 3 || pastedContent.length > 150) &&
-      kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary)
+      kv.get("paste_summary_enabled", true)
     ) {
       pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
       return

+ 35 - 9
packages/tui/src/context/data.tsx

@@ -9,6 +9,7 @@ import type {
   FormInfo,
   IntegrationInfo,
   LocationRef,
+  McpResource,
   McpServer,
   ModelInfo,
   PermissionSavedInfo,
@@ -43,7 +44,10 @@ type LocationData = {
   agent?: AgentInfo[]
   command?: CommandInfo[]
   integration?: IntegrationInfo[]
-  mcp?: McpServer[]
+  mcp?: {
+    server?: McpServer[]
+    resource?: McpResource[]
+  }
   model?: ModelInfo[]
   provider?: ProviderV2Info[]
   reference?: ReferenceInfo[]
@@ -801,7 +805,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
         // so the mcp list refreshes here rather than off integration.updated.
         case "mcp.status.changed":
           if (bootstrapping) break
-          void result.location.mcp.refresh(event.location)
+          void result.location.mcp.server.refresh(event.location)
+          break
+        case "mcp.resources.changed":
+          void result.location.mcp.resource.refresh(event.location)
           break
       }
     }
@@ -987,13 +994,31 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
           },
         },
         mcp: {
-          list(location?: LocationRef) {
-            return store.location[locationKey(location ?? defaultLocation())]?.mcp
+          server: {
+            list(location?: LocationRef) {
+              return store.location[locationKey(location ?? defaultLocation())]?.mcp?.server
+            },
+            async refresh(ref?: LocationRef) {
+              const result = await sdk.api.mcp.list({ location: locationQuery(ref) })
+              const key = locationKey(result.location)
+              setStore("location", key, {
+                ...store.location[key],
+                mcp: { ...store.location[key]?.mcp, server: result.data },
+              })
+            },
           },
-          async refresh(ref?: LocationRef) {
-            const result = await sdk.api["server.mcp"].list({ location: locationQuery(ref) })
-            const key = locationKey(result.location)
-            setStore("location", key, { ...store.location[key], mcp: result.data })
+          resource: {
+            list(location?: LocationRef) {
+              return store.location[locationKey(location ?? defaultLocation())]?.mcp?.resource
+            },
+            async refresh(ref?: LocationRef) {
+              const result = await sdk.api.mcp.resource.catalog({ location: locationQuery(ref) })
+              const key = locationKey(result.location)
+              setStore("location", key, {
+                ...store.location[key],
+                mcp: { ...store.location[key]?.mcp, resource: result.data.resources },
+              })
+            },
           },
         },
         model: {
@@ -1089,7 +1114,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
         result.location.refresh(),
         result.location.agent.refresh(),
         result.location.integration.refresh(),
-        result.location.mcp.refresh(),
+        result.location.mcp.server.refresh(),
+        result.location.mcp.resource.refresh(),
         result.location.model.refresh(),
         result.location.provider.refresh(),
         result.location.reference.refresh(),

+ 1 - 5
packages/tui/src/context/directory.ts

@@ -1,17 +1,13 @@
 import { createMemo } from "solid-js"
 import { useProject } from "./project"
-import { useSync } from "./sync"
 import { abbreviateHome } from "../runtime"
 import { useTuiPaths } from "./runtime"
 
 export function useDirectory() {
   const project = useProject()
-  const sync = useSync()
   const paths = useTuiPaths()
   return createMemo(() => {
     const directory = project.instance.path().directory || paths.cwd
-    const result = abbreviateHome(directory, paths.home)
-    if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch
-    return result
+    return abbreviateHome(directory, paths.home)
   })
 }

+ 3 - 16
packages/tui/src/context/local.tsx

@@ -1,7 +1,6 @@
 import { createStore } from "solid-js/store"
 import { createSimpleContext } from "./helper"
 import { batch, createEffect, createMemo } from "solid-js"
-import { useSync } from "./sync"
 import { useEvent } from "./event"
 import path from "path"
 import { useTuiPaths } from "./runtime"
@@ -52,7 +51,6 @@ export function recentModels(
 export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
   name: "Local",
   init: () => {
-    const sync = useSync()
     const data = useData()
     const sdk = useSDK()
     const toast = useToast()
@@ -210,16 +208,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
           }
         }
 
-        if (sync.data.config.model) {
-          const { providerID, modelID } = parseModel(sync.data.config.model)
-          if (isModelValid({ providerID, modelID })) {
-            return {
-              providerID,
-              modelID,
-            }
-          }
-        }
-
         for (const item of modelStore.recent) {
           if (isModelValid(item)) {
             return item
@@ -507,12 +495,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
 
     const mcp = {
       isEnabled(name: string) {
-        const status = sync.data.mcp[name]
-        return status?.status === "connected"
+        return data.location.mcp.server.list()?.find((item) => item.name === name)?.status.status === "connected"
       },
       async toggle(name: string) {
-        const status = sync.data.mcp[name]
-        if (status?.status === "connected") {
+        const status = data.location.mcp.server.list()?.find((item) => item.name === name)?.status.status
+        if (status === "connected") {
           // Disable: disconnect the MCP
           await sdk.client.mcp.disconnect({ name })
         } else {

+ 15 - 9
packages/tui/src/plugin/adapters.tsx

@@ -100,7 +100,7 @@ function mapOptionCb<Value>(cb?: (item: TuiDialogSelectOption<Value>) => void) {
 function stateApi(sync: ReturnType<typeof useSync>, data: ReturnType<typeof useData>): TuiPluginApi["state"] {
   return {
     get ready() {
-      return sync.ready
+      return true
     },
     get config() {
       return sync.data.config
@@ -120,7 +120,7 @@ function stateApi(sync: ReturnType<typeof useSync>, data: ReturnType<typeof useD
     },
     session: {
       count() {
-        return sync.data.session.length
+        return data.session.list().length
       },
       get(sessionID) {
         return sync.session.get(sessionID)
@@ -150,13 +150,19 @@ function stateApi(sync: ReturnType<typeof useSync>, data: ReturnType<typeof useD
       return sync.data.lsp.map((item) => ({ id: item.id, root: item.root, status: item.status }))
     },
     mcp() {
-      return Object.entries(sync.data.mcp)
-        .sort(([a], [b]) => a.localeCompare(b))
-        .map(([name, item]) => ({
-          name,
-          status: item.status,
-          error: item.status === "failed" ? item.error : undefined,
-        }))
+      return (data.location.mcp.server.list() ?? [])
+        .toSorted((a, b) => a.name.localeCompare(b.name))
+        .flatMap((item) =>
+          item.status.status === "pending"
+            ? []
+            : [
+                {
+                  name: item.name,
+                  status: item.status.status,
+                  error: item.status.status === "failed" ? item.status.error : undefined,
+                },
+              ],
+        )
     },
   }
 }

+ 2 - 4
packages/tui/src/routes/home.tsx

@@ -1,7 +1,6 @@
 import { Prompt, type PromptRef } from "../component/prompt"
 import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js"
 import { Logo } from "../component/logo"
-import { useSync } from "../context/sync"
 import { Toast } from "../ui/toast"
 import { useArgs } from "../context/args"
 import { useRouteData } from "../context/route"
@@ -24,7 +23,6 @@ const placeholder = {
 
 export function Home() {
   const pluginRuntime = usePluginRuntime()
-  const sync = useSync()
   const route = useRouteData("home")
   const promptRef = usePromptRef()
   const [ref, setRef] = createSignal<PromptRef | undefined>()
@@ -61,12 +59,12 @@ export function Home() {
     once = true
   }
 
-  // Wait for sync and model store to be ready before auto-submitting --prompt
+  // Wait for the model store to be ready before auto-submitting --prompt.
   createEffect(() => {
     const r = ref()
     if (sent) return
     if (!r) return
-    if (!sync.ready || !local.model.ready) return
+    if (!local.model.ready) return
     if (!args.prompt) return
     if (r.current.text !== args.prompt) return
     sent = true

+ 3 - 3
packages/tui/src/routes/home/session-destination.tsx

@@ -7,8 +7,8 @@ import {
   type ParentProps,
   type Setter,
 } from "solid-js"
-import { useSync } from "../../context/sync"
 import { useTuiPaths } from "../../context/runtime"
+import { useProject } from "../../context/project"
 
 export type HomeSessionDestination = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new"; name: string }
 
@@ -21,11 +21,11 @@ type Context = {
 const HomeSessionDestinationContext = createContext<Context>()
 
 export function HomeSessionDestinationProvider(props: ParentProps) {
-  const sync = useSync()
+  const project = useProject()
   const paths = useTuiPaths()
   const [selected, setDestination] = createSignal<HomeSessionDestination>()
   const destination = createMemo<HomeSessionDestination>(
-    () => selected() ?? { type: "directory", directory: sync.path.directory || paths.cwd, subdirectory: false },
+    () => selected() ?? { type: "directory", directory: project.instance.directory() || paths.cwd, subdirectory: false },
   )
   return (
     <HomeSessionDestinationContext.Provider

+ 6 - 8
packages/tui/src/routes/session/footer.tsx

@@ -1,6 +1,5 @@
 import { createMemo, Match, onCleanup, onMount, Show, Switch } from "solid-js"
 import { useTheme } from "../../context/theme"
-import { useSync } from "../../context/sync"
 import { useData } from "../../context/data"
 import { useDirectory } from "../../context/directory"
 import { useConnected } from "../../component/use-connected"
@@ -9,12 +8,14 @@ import { useRoute } from "../../context/route"
 
 export function Footer() {
   const { theme } = useTheme()
-  const sync = useSync()
   const data = useData()
   const route = useRoute()
-  const mcp = createMemo(() => (data.location.mcp.list() ?? []).filter((x) => x.status.status === "connected").length)
-  const mcpError = createMemo(() => (data.location.mcp.list() ?? []).some((x) => x.status.status === "failed"))
-  const lsp = createMemo(() => Object.keys(sync.data.lsp))
+  const mcp = createMemo(
+    () => (data.location.mcp.server.list() ?? []).filter((x) => x.status.status === "connected").length,
+  )
+  const mcpError = createMemo(() =>
+    (data.location.mcp.server.list() ?? []).some((x) => x.status.status === "failed"),
+  )
   const permissions = createMemo(() => {
     if (route.data.type !== "session") return []
     return data.session.permission.list(route.data.sessionID) ?? []
@@ -68,9 +69,6 @@ export function Footer() {
                 {permissions().length > 1 ? "s" : ""}
               </text>
             </Show>
-            <text fg={theme.text}>
-              <span style={{ fg: lsp().length > 0 ? theme.success : theme.textMuted }}>•</span> {lsp().length} LSP
-            </text>
             <Show when={mcp()}>
               <text fg={theme.text}>
                 <Switch>

+ 64 - 0
packages/tui/test/cli/tui/data.test.tsx

@@ -1305,6 +1305,70 @@ test("refreshes integrations after integration updates", async () => {
   }
 })
 
+test("refreshes MCP resources after catalog updates", async () => {
+  const events = createEventStream()
+  let requests = 0
+  const calls = createFetch((url) => {
+    if (url.pathname !== "/api/mcp/resource") return
+    requests++
+    return json({
+      location: { directory, project: { id: "proj_test", directory } },
+      data: {
+        resources:
+          requests === 1
+            ? []
+            : [{ server: "docs", name: "API reference", uri: "https://example.com/api", description: "API docs" }],
+        templates: [],
+      },
+    })
+  }, events)
+  let data!: ReturnType<typeof useData>
+  let ready!: () => void
+  const mounted = new Promise<void>((resolve) => {
+    ready = resolve
+  })
+
+  function Probe() {
+    data = useData()
+    onMount(ready)
+    return <box />
+  }
+
+  const app = await testRender(() => (
+    <TestTuiContexts>
+      <SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
+        <ProjectProvider>
+          <DataProvider>
+            <Probe />
+          </DataProvider>
+        </ProjectProvider>
+      </SDKProvider>
+    </TestTuiContexts>
+  ))
+
+  try {
+    await mounted
+    await wait(() => data.location.mcp.resource.list() !== undefined)
+    expect(data.location.mcp.resource.list()).toEqual([])
+
+    emitEvent(events, {
+      id: "evt_mcp_resources",
+      created: 0,
+      type: "mcp.resources.changed",
+      data: { server: "docs" },
+    })
+    await wait(() => data.location.mcp.resource.list()?.length === 1)
+    expect(data.location.mcp.resource.list()?.[0]).toEqual({
+      server: "docs",
+      name: "API reference",
+      uri: "https://example.com/api",
+      description: "API docs",
+    })
+  } finally {
+    app.renderer.destroy()
+  }
+})
+
 test("refreshes effective catalog data after catalog updates", async () => {
   const events = createEventStream()
   const requests = { model: 0, provider: 0 }

+ 5 - 0
packages/tui/test/fixture/tui-sdk.ts

@@ -103,6 +103,11 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
       return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
     if (url.pathname === "/api/mcp")
       return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
+    if (url.pathname === "/api/mcp/resource")
+      return json({
+        location: { directory, project: { id: "proj_test", directory: worktree } },
+        data: { resources: [], templates: [] },
+      })
     if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
     if (url.pathname === "/api/session/active") return json({ data: {} })
     if (url.pathname === "/api/permission/request")