Ver Fonte

fix: sanitize absolute paths

Adam há 2 meses atrás
pai
commit
4477132448

+ 2 - 1
packages/desktop/src/components/file-tree.tsx

@@ -1,5 +1,5 @@
 import { useLocal, type LocalFile } from "@/context/local"
-import { Collapsible } from "@/ui"
+import { Collapsible } from "@opencode-ai/ui/collapsible"
 import { FileIcon } from "@opencode-ai/ui/file-icon"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { For, Match, Switch, Show, type ComponentProps, type ParentProps } from "solid-js"
@@ -76,6 +76,7 @@ export default function FileTree(props: {
             <Switch>
               <Match when={node.type === "directory"}>
                 <Collapsible
+                  variant="ghost"
                   class="w-full"
                   forceMount={false}
                   // open={local.file.node(node.path)?.expanded}

+ 1 - 1
packages/desktop/src/components/prompt-input.tsx

@@ -1,7 +1,6 @@
 import { useFilteredList } from "@opencode-ai/ui/hooks"
 import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match } from "solid-js"
 import { createStore } from "solid-js/store"
-import { getDirectory, getFilename } from "@/utils"
 import { createFocusSignal } from "@solid-primitives/active-element"
 import { useLocal } from "@/context/local"
 import { DateTime } from "luxon"
@@ -16,6 +15,7 @@ import { Icon } from "@opencode-ai/ui/icon"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { Select } from "@opencode-ai/ui/select"
+import { getDirectory, getFilename } from "@opencode-ai/util/path"
 
 interface PromptInputProps {
   class?: string

+ 4 - 28
packages/desktop/src/context/sync.tsx

@@ -1,4 +1,3 @@
-import type { Part } from "@opencode-ai/sdk"
 import { produce } from "solid-js/store"
 import { createMemo } from "solid-js"
 import { Binary } from "@opencode-ai/util/binary"
@@ -34,29 +33,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
 
     Promise.all(Object.values(load).map((p) => p())).then(() => setStore("ready", true))
 
-    const sanitizer = createMemo(() => new RegExp(`${store.path.directory}/`, "g"))
-    const sanitize = (text: string) => text.replace(sanitizer(), "")
     const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/")
-    const sanitizePart = (part: Part) => {
-      if (part.type === "tool") {
-        if (part.state.status === "completed" || part.state.status === "error") {
-          for (const key in part.state.metadata) {
-            if (typeof part.state.metadata[key] === "string") {
-              part.state.metadata[key] = sanitize(part.state.metadata[key] as string)
-            }
-          }
-          for (const key in part.state.input) {
-            if (typeof part.state.input[key] === "string") {
-              part.state.input[key] = sanitize(part.state.input[key] as string)
-            }
-          }
-          if ("error" in part.state) {
-            part.state.error = sanitize(part.state.error as string)
-          }
-        }
-      }
-      return part
-    }
 
     return {
       data: store,
@@ -88,10 +65,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
                 .slice()
                 .sort((a, b) => a.id.localeCompare(b.id))
               for (const message of messages.data!) {
-                draft.part[message.info.id] = message.parts
-                  .slice()
-                  .map(sanitizePart)
-                  .sort((a, b) => a.id.localeCompare(b.id))
+                draft.part[message.info.id] = message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
               }
               draft.session_diff[sessionID] = diff.data ?? []
             }),
@@ -105,7 +79,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
       },
       load,
       absolute,
-      sanitize,
+      get directory() {
+        return store.path.directory
+      },
     }
   },
 })

+ 1 - 1
packages/desktop/src/pages/directory-layout.tsx

@@ -21,7 +21,7 @@ export default function Layout(props: ParentProps) {
         {iife(() => {
           const sync = useSync()
           return (
-            <DataProvider data={sync.data}>
+            <DataProvider data={sync.data} directory={directory()}>
               <LocalProvider>{props.children}</LocalProvider>
             </DataProvider>
           )

+ 2 - 1
packages/desktop/src/pages/home.tsx

@@ -1,8 +1,9 @@
 import { useGlobalSync } from "@/context/global-sync"
-import { base64Encode, getFilename } from "@/utils"
+import { base64Encode } from "@/utils"
 import { For } from "solid-js"
 import { A } from "@solidjs/router"
 import { Button } from "@opencode-ai/ui/button"
+import { getFilename } from "@opencode-ai/util/path"
 
 export default function Home() {
   const sync = useGlobalSync()

+ 2 - 1
packages/desktop/src/pages/layout.tsx

@@ -3,7 +3,7 @@ import { DateTime } from "luxon"
 import { A, useParams } from "@solidjs/router"
 import { useLayout } from "@/context/layout"
 import { useGlobalSync } from "@/context/global-sync"
-import { base64Encode, getFilename } from "@/utils"
+import { base64Encode } from "@/utils"
 import { Mark } from "@opencode-ai/ui/logo"
 import { Button } from "@opencode-ai/ui/button"
 import { Icon } from "@opencode-ai/ui/icon"
@@ -11,6 +11,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { Collapsible } from "@opencode-ai/ui/collapsible"
 import { DiffChanges } from "@opencode-ai/ui/diff-changes"
+import { getFilename } from "@opencode-ai/util/path"
 
 export default function Layout(props: ParentProps) {
   const params = useParams()

+ 1 - 1
packages/desktop/src/pages/session.tsx

@@ -1,7 +1,6 @@
 import { For, onCleanup, onMount, Show, Match, Switch, createResource, createMemo } from "solid-js"
 import { useLocal, type LocalFile } from "@/context/local"
 import { createStore } from "solid-js/store"
-import { getDirectory, getFilename } from "@/utils"
 import { PromptInput } from "@/components/prompt-input"
 import { DateTime } from "luxon"
 import { FileIcon } from "@opencode-ai/ui/file-icon"
@@ -30,6 +29,7 @@ import type { JSX } from "solid-js"
 import { useSync } from "@/context/sync"
 import { useSession } from "@/context/session"
 import { useLayout } from "@/context/layout"
+import { getDirectory, getFilename } from "@opencode-ai/util/path"
 
 export default function Page() {
   const layout = useLayout()

+ 0 - 62
packages/desktop/src/ui/collapsible.tsx

@@ -1,62 +0,0 @@
-import { Collapsible as KobalteCollapsible } from "@kobalte/core/collapsible"
-import { Icon, IconProps } from "@opencode-ai/ui/icon"
-import { splitProps } from "solid-js"
-import type { ComponentProps, ParentProps } from "solid-js"
-
-export interface CollapsibleProps extends ComponentProps<typeof KobalteCollapsible> {}
-export interface CollapsibleTriggerProps extends ComponentProps<typeof KobalteCollapsible.Trigger> {}
-export interface CollapsibleContentProps extends ComponentProps<typeof KobalteCollapsible.Content> {}
-
-function CollapsibleRoot(props: CollapsibleProps) {
-  return <KobalteCollapsible forceMount {...props} />
-}
-
-function CollapsibleTrigger(props: CollapsibleTriggerProps) {
-  const [local, others] = splitProps(props, ["class"])
-  return (
-    <KobalteCollapsible.Trigger
-      classList={{
-        "w-full group/collapsible": true,
-        [local.class ?? ""]: !!local.class,
-      }}
-      {...others}
-    />
-  )
-}
-
-function CollapsibleContent(props: ParentProps<CollapsibleContentProps>) {
-  const [local, others] = splitProps(props, ["class", "children"])
-  return (
-    <KobalteCollapsible.Content
-      classList={{
-        "h-0 overflow-hidden transition-all duration-100 ease-out": true,
-        "data-expanded:h-fit": true,
-        [local.class]: !!local.class,
-      }}
-      {...others}
-    >
-      {local.children}
-    </KobalteCollapsible.Content>
-  )
-}
-
-function CollapsibleArrow(props: Partial<IconProps>) {
-  const [local, others] = splitProps(props, ["class", "name"])
-  return (
-    <Icon
-      name={local.name ?? "chevron-right"}
-      classList={{
-        "flex-none text-text-muted transition-transform duration-100": true,
-        "group-data-[expanded]/collapsible:rotate-90": true,
-        [local.class ?? ""]: !!local.class,
-      }}
-      {...others}
-    />
-  )
-}
-
-export const Collapsible = Object.assign(CollapsibleRoot, {
-  Trigger: CollapsibleTrigger,
-  Content: CollapsibleContent,
-  Arrow: CollapsibleArrow,
-})

+ 0 - 6
packages/desktop/src/ui/index.ts

@@ -1,6 +0,0 @@
-export {
-  Collapsible,
-  type CollapsibleProps,
-  type CollapsibleTriggerProps,
-  type CollapsibleContentProps,
-} from "./collapsible"

+ 0 - 1
packages/desktop/src/utils/index.ts

@@ -1,3 +1,2 @@
-export * from "./path"
 export * from "./dom"
 export * from "./encode"

+ 0 - 20
packages/desktop/src/utils/path.ts

@@ -1,20 +0,0 @@
-import { useSync } from "@/context/sync"
-
-export function getFilename(path: string) {
-  if (!path) return ""
-  const trimmed = path.replace(/[\/]+$/, "")
-  const parts = trimmed.split("/")
-  return parts[parts.length - 1] ?? ""
-}
-
-export function getDirectory(path: string) {
-  const sync = useSync()
-  const parts = path.split("/")
-  const dir = parts.slice(0, parts.length - 1).join("/")
-  return dir ? sync.sanitize(dir + "/") : ""
-}
-
-export function getFileExtension(path: string) {
-  const parts = path.split(".")
-  return parts[parts.length - 1]
-}

+ 205 - 198
packages/enterprise/src/routes/share/[shareID].tsx

@@ -141,219 +141,226 @@ export default function () {
       }}
     >
       <Show when={data()}>
-        {(data) => (
-          <DataProvider data={data()}>
-            {iife(() => {
-              const [store, setStore] = createStore({
-                messageId: undefined as string | undefined,
-              })
-              const match = createMemo(() => Binary.search(data().session, data().sessionID, (s) => s.id))
-              if (!match().found) throw new Error(`Session ${data().sessionID} not found`)
-              const info = createMemo(() => data().session[match().index])
-              const messages = createMemo(() =>
-                data().sessionID
-                  ? (data().message[data().sessionID]?.filter((m) => m.role === "user") ?? []).sort(
-                      (a, b) => b.time.created - a.time.created,
-                    )
-                  : [],
-              )
-              const firstUserMessage = createMemo(() => messages().at(0))
-              const activeMessage = createMemo(
-                () => messages().find((m) => m.id === store.messageId) ?? firstUserMessage(),
-              )
-              function setActiveMessage(message: UserMessage | undefined) {
-                if (message) {
-                  setStore("messageId", message.id)
-                } else {
-                  setStore("messageId", undefined)
+        {(data) => {
+          const match = createMemo(() => Binary.search(data().session, data().sessionID, (s) => s.id))
+          if (!match().found) throw new Error(`Session ${data().sessionID} not found`)
+          const info = createMemo(() => data().session[match().index])
+
+          return (
+            <DataProvider data={data()} directory={info().directory}>
+              {iife(() => {
+                const [store, setStore] = createStore({
+                  messageId: undefined as string | undefined,
+                })
+                const messages = createMemo(() =>
+                  data().sessionID
+                    ? (data().message[data().sessionID]?.filter((m) => m.role === "user") ?? []).sort(
+                        (a, b) => b.time.created - a.time.created,
+                      )
+                    : [],
+                )
+                const firstUserMessage = createMemo(() => messages().at(0))
+                const activeMessage = createMemo(
+                  () => messages().find((m) => m.id === store.messageId) ?? firstUserMessage(),
+                )
+                function setActiveMessage(message: UserMessage | undefined) {
+                  if (message) {
+                    setStore("messageId", message.id)
+                  } else {
+                    setStore("messageId", undefined)
+                  }
                 }
-              }
-              const provider = createMemo(() => activeMessage()?.model?.providerID)
-              const modelID = createMemo(() => activeMessage()?.model?.modelID)
-              const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID()))
-              const diffs = createMemo(() => {
-                const diffs = data().session_diff[data().sessionID] ?? []
-                const preloaded = data().session_diff_preload[data().sessionID] ?? []
-                return diffs.map((diff) => ({
-                  ...diff,
-                  preloaded: preloaded.find((d) => d.newFile.name === diff.file),
-                }))
-              })
+                const provider = createMemo(() => activeMessage()?.model?.providerID)
+                const modelID = createMemo(() => activeMessage()?.model?.modelID)
+                const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID()))
+                const diffs = createMemo(() => {
+                  const diffs = data().session_diff[data().sessionID] ?? []
+                  const preloaded = data().session_diff_preload[data().sessionID] ?? []
+                  return diffs.map((diff) => ({
+                    ...diff,
+                    preloaded: preloaded.find((d) => d.newFile.name === diff.file),
+                  }))
+                })
 
-              const title = () => (
-                <div class="flex flex-col gap-4 shrink-0">
-                  <div class="h-8 flex gap-4 items-center justify-start self-stretch">
-                    <div class="pl-[2.5px] pr-2 flex items-center gap-1.75 bg-surface-strong shadow-xs-border-base">
-                      <Mark class="shrink-0 w-3 my-0.5" />
-                      <div class="text-12-mono text-text-base">v{info().version}</div>
-                    </div>
-                    <div class="flex gap-2 items-center">
-                      <img src={`https://models.dev/logos/${provider()}.svg`} class="size-3.5 shrink-0 dark:invert" />
-                      <div class="text-12-regular text-text-base">{model()?.name ?? modelID()}</div>
-                    </div>
-                    <div class="text-12-regular text-text-weaker">
-                      {DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")}
+                const title = () => (
+                  <div class="flex flex-col gap-4 shrink-0">
+                    <div class="h-8 flex gap-4 items-center justify-start self-stretch">
+                      <div class="pl-[2.5px] pr-2 flex items-center gap-1.75 bg-surface-strong shadow-xs-border-base">
+                        <Mark class="shrink-0 w-3 my-0.5" />
+                        <div class="text-12-mono text-text-base">v{info().version}</div>
+                      </div>
+                      <div class="flex gap-2 items-center">
+                        <img src={`https://models.dev/logos/${provider()}.svg`} class="size-3.5 shrink-0 dark:invert" />
+                        <div class="text-12-regular text-text-base">{model()?.name ?? modelID()}</div>
+                      </div>
+                      <div class="text-12-regular text-text-weaker">
+                        {DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")}
+                      </div>
                     </div>
+                    <div class="text-left text-16-medium text-text-strong">{info().title}</div>
                   </div>
-                  <div class="text-left text-16-medium text-text-strong">{info().title}</div>
-                </div>
-              )
+                )
 
-              const turns = () => (
-                <div class="relative mt-2 pt-6 pb-8 px-4 min-w-0 w-full h-full overflow-y-auto no-scrollbar">
-                  {title()}
-                  <div class="flex flex-col gap-15 items-start justify-start mt-4">
-                    <For each={messages()}>
-                      {(message) => (
-                        <SessionTurn
-                          sessionID={data().sessionID}
-                          messageID={message.id}
-                          classes={{
-                            root: "min-w-0 w-full relative",
-                            content:
-                              "flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]",
-                          }}
-                        />
-                      )}
-                    </For>
-                  </div>
-                  <div class="flex items-center justify-center pt-20 pb-8 shrink-0">
-                    <Logo class="w-58.5 opacity-12" />
+                const turns = () => (
+                  <div class="relative mt-2 pt-6 pb-8 px-4 min-w-0 w-full h-full overflow-y-auto no-scrollbar">
+                    {title()}
+                    <div class="flex flex-col gap-15 items-start justify-start mt-4">
+                      <For each={messages()}>
+                        {(message) => (
+                          <SessionTurn
+                            sessionID={data().sessionID}
+                            messageID={message.id}
+                            classes={{
+                              root: "min-w-0 w-full relative",
+                              content:
+                                "flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]",
+                            }}
+                          />
+                        )}
+                      </For>
+                    </div>
+                    <div class="flex items-center justify-center pt-20 pb-8 shrink-0">
+                      <Logo class="w-58.5 opacity-12" />
+                    </div>
                   </div>
-                </div>
-              )
+                )
 
-              const wide = createMemo(() => diffs().length === 0)
+                const wide = createMemo(() => diffs().length === 0)
 
-              return (
-                <div class="relative bg-background-stronger w-screen h-screen overflow-hidden flex flex-col">
-                  <header class="h-12 px-6 py-2 flex items-center justify-between self-stretch bg-background-base border-b border-border-weak-base">
-                    <div class="">
-                      <a href="https://opencode.ai">
-                        <Mark />
-                      </a>
-                    </div>
-                    <div class="flex gap-3 items-center">
-                      <IconButton
-                        as={"a"}
-                        href="https://github.com/sst/opencode"
-                        target="_blank"
-                        icon="github"
-                        variant="ghost"
-                      />
-                      <IconButton
-                        as={"a"}
-                        href="https://opencode.ai/discord"
-                        target="_blank"
-                        icon="discord"
-                        variant="ghost"
-                      />
-                    </div>
-                  </header>
-                  <div class="select-text flex flex-col flex-1 min-h-0">
-                    <div class="hidden md:flex w-full flex-1 min-h-0">
-                      <div
-                        classList={{
-                          "@container relative shrink-0 pt-14 flex flex-col gap-10 min-h-0 w-full mx-auto": true,
-                          "px-21 @4xl:px-6 max-w-2xl": !wide(),
-                          "px-6 max-w-2xl": wide(),
-                        }}
-                      >
-                        {title()}
-                        <div class="flex items-start justify-start h-full min-h-0">
-                          <Show when={messages().length > 1}>
-                            <>
-                              <div class="md:hidden absolute right-full">
-                                <MessageNav
-                                  class="mt-2 mr-3"
-                                  messages={messages()}
-                                  current={activeMessage()}
-                                  onMessageSelect={setActiveMessage}
-                                  size="compact"
-                                />
-                              </div>
-                              <div
-                                classList={{
-                                  "hidden md:block": true,
-                                  "absolute right-[90%]": !wide(),
-                                  "absolute right-full": wide(),
-                                }}
-                              >
-                                <MessageNav
+                return (
+                  <div class="relative bg-background-stronger w-screen h-screen overflow-hidden flex flex-col">
+                    <header class="h-12 px-6 py-2 flex items-center justify-between self-stretch bg-background-base border-b border-border-weak-base">
+                      <div class="">
+                        <a href="https://opencode.ai">
+                          <Mark />
+                        </a>
+                      </div>
+                      <div class="flex gap-3 items-center">
+                        <IconButton
+                          as={"a"}
+                          href="https://github.com/sst/opencode"
+                          target="_blank"
+                          icon="github"
+                          variant="ghost"
+                        />
+                        <IconButton
+                          as={"a"}
+                          href="https://opencode.ai/discord"
+                          target="_blank"
+                          icon="discord"
+                          variant="ghost"
+                        />
+                      </div>
+                    </header>
+                    <div class="select-text flex flex-col flex-1 min-h-0">
+                      <div class="hidden md:flex w-full flex-1 min-h-0">
+                        <div
+                          classList={{
+                            "@container relative shrink-0 pt-14 flex flex-col gap-10 min-h-0 w-full mx-auto": true,
+                            "px-21 @4xl:px-6 max-w-2xl": !wide(),
+                            "px-6 max-w-2xl": wide(),
+                          }}
+                        >
+                          {title()}
+                          <div class="flex items-start justify-start h-full min-h-0">
+                            <Show when={messages().length > 1}>
+                              <>
+                                <div class="md:hidden absolute right-full">
+                                  <MessageNav
+                                    class="mt-2 mr-3"
+                                    messages={messages()}
+                                    current={activeMessage()}
+                                    onMessageSelect={setActiveMessage}
+                                    size="compact"
+                                  />
+                                </div>
+                                <div
                                   classList={{
-                                    "mt-2.5 mr-3": !wide(),
-                                    "mt-0.5 mr-8": wide(),
+                                    "hidden md:block": true,
+                                    "absolute right-[90%]": !wide(),
+                                    "absolute right-full": wide(),
                                   }}
-                                  messages={messages()}
-                                  current={activeMessage()}
-                                  onMessageSelect={setActiveMessage}
-                                  size={wide() ? "normal" : "compact"}
-                                />
+                                >
+                                  <MessageNav
+                                    classList={{
+                                      "mt-2.5 mr-3": !wide(),
+                                      "mt-0.5 mr-8": wide(),
+                                    }}
+                                    messages={messages()}
+                                    current={activeMessage()}
+                                    onMessageSelect={setActiveMessage}
+                                    size={wide() ? "normal" : "compact"}
+                                  />
+                                </div>
+                              </>
+                            </Show>
+                            <SessionTurn
+                              sessionID={data().sessionID}
+                              messageID={store.messageId ?? firstUserMessage()!.id!}
+                              classes={{ root: "grow", content: "flex flex-col justify-between", container: "pb-20" }}
+                            >
+                              <div class="flex items-center justify-center pb-8 shrink-0">
+                                <Logo class="w-58.5 opacity-12" />
                               </div>
-                            </>
-                          </Show>
-                          <SessionTurn
-                            sessionID={data().sessionID}
-                            messageID={store.messageId ?? firstUserMessage()!.id!}
-                            classes={{ root: "grow", content: "flex flex-col justify-between", container: "pb-20" }}
-                          >
-                            <div class="flex items-center justify-center pb-8 shrink-0">
-                              <Logo class="w-58.5 opacity-12" />
-                            </div>
-                          </SessionTurn>
+                            </SessionTurn>
+                          </div>
                         </div>
+                        <Show when={diffs().length > 0}>
+                          <div class="relative grow pt-14 flex-1 min-h-0 border-l border-border-weak-base">
+                            <SessionReview
+                              diffs={diffs()}
+                              classes={{
+                                root: "pb-20",
+                                header: "px-6",
+                                container: "px-6",
+                              }}
+                            />
+                          </div>
+                        </Show>
                       </div>
-                      <Show when={diffs().length > 0}>
-                        <div class="relative grow pt-14 flex-1 min-h-0 border-l border-border-weak-base">
-                          <SessionReview
-                            diffs={diffs()}
-                            classes={{
-                              root: "pb-20",
-                              header: "px-6",
-                              container: "px-6",
-                            }}
-                          />
-                        </div>
-                      </Show>
+                      <Switch>
+                        <Match when={diffs().length > 0}>
+                          <Tabs class="md:hidden">
+                            <Tabs.List>
+                              <Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }}>
+                                Session
+                              </Tabs.Trigger>
+                              <Tabs.Trigger value="review" class="w-1/2 !border-r-0" classes={{ button: "w-full" }}>
+                                5 Files Changed
+                              </Tabs.Trigger>
+                            </Tabs.List>
+                            <Tabs.Content value="session" class="!overflow-hidden">
+                              {turns()}
+                            </Tabs.Content>
+                            <Tabs.Content
+                              forceMount
+                              value="review"
+                              class="!overflow-hidden hidden data-[selected]:block"
+                            >
+                              <div class="relative h-full pt-8 overflow-y-auto no-scrollbar">
+                                <SessionReview
+                                  diffs={diffs()}
+                                  classes={{
+                                    root: "pb-20",
+                                    header: "px-4",
+                                    container: "px-4",
+                                  }}
+                                />
+                              </div>
+                            </Tabs.Content>
+                          </Tabs>
+                        </Match>
+                        <Match when={true}>
+                          <div class="md:hidden !overflow-hidden">{turns()}</div>
+                        </Match>
+                      </Switch>
                     </div>
-                    <Switch>
-                      <Match when={diffs().length > 0}>
-                        <Tabs class="md:hidden">
-                          <Tabs.List>
-                            <Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }}>
-                              Session
-                            </Tabs.Trigger>
-                            <Tabs.Trigger value="review" class="w-1/2 !border-r-0" classes={{ button: "w-full" }}>
-                              5 Files Changed
-                            </Tabs.Trigger>
-                          </Tabs.List>
-                          <Tabs.Content value="session" class="!overflow-hidden">
-                            {turns()}
-                          </Tabs.Content>
-                          <Tabs.Content forceMount value="review" class="!overflow-hidden hidden data-[selected]:block">
-                            <div class="relative h-full pt-8 overflow-y-auto no-scrollbar">
-                              <SessionReview
-                                diffs={diffs()}
-                                classes={{
-                                  root: "pb-20",
-                                  header: "px-4",
-                                  container: "px-4",
-                                }}
-                              />
-                            </div>
-                          </Tabs.Content>
-                        </Tabs>
-                      </Match>
-                      <Match when={true}>
-                        <div class="md:hidden !overflow-hidden">{turns()}</div>
-                      </Match>
-                    </Switch>
                   </div>
-                </div>
-              )
-            })}
-          </DataProvider>
-        )}
+                )
+              })}
+            </DataProvider>
+          )
+        }}
       </Show>
     </ErrorBoundary>
   )

+ 11 - 0
packages/ui/src/components/message-part.css

@@ -63,6 +63,17 @@
 
 [data-component="tool-output"] {
   white-space: pre;
+  padding: 8px 12px;
+  height: fit-content;
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  justify-content: flex-start;
+
+  pre {
+    margin: 0;
+    padding: 0;
+  }
 }
 
 [data-component="edit-trigger"],

+ 44 - 29
packages/ui/src/components/message-part.tsx

@@ -16,35 +16,26 @@ import { Checkbox } from "./checkbox"
 import { Diff } from "./diff"
 import { DiffChanges } from "./diff-changes"
 import { Markdown } from "./markdown"
+import { getDirectory, getFilename } from "@opencode-ai/util/path"
+import { sanitize, sanitizePart } from "@opencode-ai/util/sanitize"
 
 export interface MessageProps {
   message: MessageType
   parts: PartType[]
+  sanitize?: RegExp
 }
 
 export interface MessagePartProps {
   part: PartType
   message: MessageType
   hideDetails?: boolean
+  sanitize?: RegExp
 }
 
 export type PartComponent = Component<MessagePartProps>
 
 export const PART_MAPPING: Record<string, PartComponent | undefined> = {}
 
-function getFilename(path: string) {
-  if (!path) return ""
-  const trimmed = path.replace(/[\/]+$/, "")
-  const parts = trimmed.split("/")
-  return parts[parts.length - 1] ?? ""
-}
-
-function getDirectory(path: string) {
-  const parts = path.split("/")
-  const dir = parts.slice(0, parts.length - 1).join("/")
-  return dir ? dir + "/" : ""
-}
-
 export function registerPartComponent(type: string, component: PartComponent) {
   PART_MAPPING[type] = component
 }
@@ -57,21 +48,27 @@ export function Message(props: MessageProps) {
       </Match>
       <Match when={props.message.role === "assistant" && props.message}>
         {(assistantMessage) => (
-          <AssistantMessageDisplay message={assistantMessage() as AssistantMessage} parts={props.parts} />
+          <AssistantMessageDisplay
+            message={assistantMessage() as AssistantMessage}
+            parts={props.parts}
+            sanitize={props.sanitize}
+          />
         )}
       </Match>
     </Switch>
   )
 }
 
-export function AssistantMessageDisplay(props: { message: AssistantMessage; parts: PartType[] }) {
+export function AssistantMessageDisplay(props: { message: AssistantMessage; parts: PartType[]; sanitize?: RegExp }) {
   const filteredParts = createMemo(() => {
     return props.parts?.filter((x) => {
       if (x.type === "reasoning") return false
       return x.type !== "tool" || (x as ToolPart).tool !== "todoread"
     })
   })
-  return <For each={filteredParts()}>{(part) => <Part part={part} message={props.message} />}</For>
+  return (
+    <For each={filteredParts()}>{(part) => <Part part={part} message={props.message} sanitize={props.sanitize} />}</For>
+  )
 }
 
 export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) {
@@ -88,7 +85,13 @@ export function Part(props: MessagePartProps) {
   const component = createMemo(() => PART_MAPPING[props.part.type])
   return (
     <Show when={component()}>
-      <Dynamic component={component()} part={props.part} message={props.message} hideDetails={props.hideDetails} />
+      <Dynamic
+        component={component()}
+        part={props.part}
+        message={props.message}
+        hideDetails={props.hideDetails}
+        sanitize={props.sanitize}
+      />
     </Show>
   )
 }
@@ -99,6 +102,7 @@ export interface ToolProps {
   tool: string
   output?: string
   hideDetails?: boolean
+  sanitize?: RegExp
 }
 
 export type ToolComponent = Component<ToolProps>
@@ -166,6 +170,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
             metadata={metadata}
             output={part.state.status === "completed" ? part.state.output : undefined}
             hideDetails={props.hideDetails}
+            sanitize={props.sanitize}
           />
         </Match>
       </Switch>
@@ -177,10 +182,11 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
 
 PART_MAPPING["text"] = function TextPartDisplay(props) {
   const part = props.part as TextPart
+  const sanitized = createMemo(() => (props.sanitize ? (sanitizePart(part, props.sanitize) as TextPart) : part))
   return (
     <Show when={part.text.trim()}>
       <div data-component="text-part">
-        <Markdown text={part.text.trim()} />
+        <Markdown text={sanitized().text.trim()} />
       </div>
     </Show>
   )
@@ -205,7 +211,7 @@ ToolRegistry.register({
         icon="glasses"
         trigger={{
           title: "Read",
-          subtitle: props.input.filePath ? getFilename(props.input.filePath) : "",
+          subtitle: props.input.filePath ? getFilename(sanitize(props.input.filePath, props.sanitize)) : "",
         }}
       />
     )
@@ -216,9 +222,12 @@ ToolRegistry.register({
   name: "list",
   render(props) {
     return (
-      <BasicTool icon="bullet-list" trigger={{ title: "List", subtitle: getDirectory(props.input.path || "/") }}>
+      <BasicTool
+        icon="bullet-list"
+        trigger={{ title: "List", subtitle: getDirectory(sanitize(props.input.path, props.sanitize) || "/") }}
+      >
         <Show when={false && props.output}>
-          <div data-component="tool-output">{props.output}</div>
+          <div data-component="tool-output">{sanitize(props.output, props.sanitize)}</div>
         </Show>
       </BasicTool>
     )
@@ -321,12 +330,14 @@ ToolRegistry.register({
         icon="console"
         trigger={{
           title: "Shell",
-          subtitle: "Ran " + props.input.command,
+          subtitle: props.input.description,
         }}
       >
-        <Show when={false && props.output}>
-          <div data-component="tool-output">{props.output}</div>
-        </Show>
+        <div data-component="tool-output">
+          <Markdown
+            text={`\`\`\`command\n$ ${sanitize(props.input.command, props.sanitize)}${props.output ? "\n\n" + props.output : ""}\n\`\`\``}
+          />
+        </div>
       </BasicTool>
     )
   },
@@ -344,9 +355,13 @@ ToolRegistry.register({
               <div data-slot="message-part-title">Edit</div>
               <div data-slot="message-part-path">
                 <Show when={props.input.filePath?.includes("/")}>
-                  <span data-slot="message-part-directory">{getDirectory(props.input.filePath!)}</span>
+                  <span data-slot="message-part-directory">
+                    {getDirectory(sanitize(props.input.filePath!, props.sanitize))}
+                  </span>
                 </Show>
-                <span data-slot="message-part-filename">{getFilename(props.input.filePath ?? "")}</span>
+                <span data-slot="message-part-filename">
+                  {getFilename(sanitize(props.input.filePath ?? "", props.sanitize))}
+                </span>
               </div>
             </div>
             <div data-slot="message-part-actions">
@@ -361,11 +376,11 @@ ToolRegistry.register({
           <div data-component="edit-content">
             <Diff
               before={{
-                name: getFilename(props.metadata.filediff.path),
+                name: getFilename(sanitize(props.metadata.filediff.path, props.sanitize)),
                 contents: props.metadata.filediff.before,
               }}
               after={{
-                name: getFilename(props.metadata.filediff.path),
+                name: getFilename(sanitize(props.metadata.filediff.path, props.sanitize)),
                 contents: props.metadata.filediff.after,
               }}
             />

+ 2 - 1
packages/ui/src/components/message-progress.tsx

@@ -6,6 +6,7 @@ import type { AssistantMessage as AssistantMessageType, ToolPart } from "@openco
 
 export function MessageProgress(props: { assistantMessages: () => AssistantMessageType[]; done?: boolean }) {
   const data = useData()
+  const sanitizer = createMemo(() => (data.directory ? new RegExp(`${data.directory}/`, "g") : undefined))
   const parts = createMemo(() => props.assistantMessages().flatMap((m) => data.part[m.id]))
   const done = createMemo(() => props.done ?? false)
   const currentTask = createMemo(
@@ -152,7 +153,7 @@ export function MessageProgress(props: { assistantMessages: () => AssistantMessa
                       )
                       return (
                         <div data-slot="message-progress-item">
-                          <Part message={message()!} part={part} />
+                          <Part message={message()!} part={part} sanitize={sanitizer()} />
                         </div>
                       )
                     }}

+ 4 - 2
packages/ui/src/components/session-turn.tsx

@@ -31,6 +31,7 @@ export function SessionTurn(
   const match = Binary.search(data.session, props.sessionID, (s) => s.id)
   if (!match.found) throw new Error(`Session ${props.sessionID} not found`)
 
+  const sanitizer = createMemo(() => (data.directory ? new RegExp(`${data.directory}/`, "g") : undefined))
   const messages = createMemo(() => (props.sessionID ? (data.message[props.sessionID] ?? []) : []))
   const userMessages = createMemo(() =>
     messages()
@@ -116,7 +117,7 @@ export function SessionTurn(
                   </div>
                 </div>
                 <div data-slot="session-turn-message-content">
-                  <Message message={msg()} parts={parts()} />
+                  <Message message={msg()} parts={parts()} sanitize={sanitizer()} />
                 </div>
                 {/* Summary */}
                 <Show when={completed()}>
@@ -222,10 +223,11 @@ export function SessionTurn(
                                     <Message
                                       message={assistantMessage}
                                       parts={parts().filter((p) => p?.id !== last()?.id)}
+                                      sanitize={sanitizer()}
                                     />
                                   )
                                 }
-                                return <Message message={assistantMessage} parts={parts()} />
+                                return <Message message={assistantMessage} parts={parts()} sanitize={sanitizer()} />
                               }}
                             </For>
                             <Show when={error()}>

+ 2 - 2
packages/ui/src/context/data.tsx

@@ -23,7 +23,7 @@ type Data = {
 
 export const { use: useData, provider: DataProvider } = createSimpleContext({
   name: "Data",
-  init: (props: { data: Data }) => {
-    return props.data
+  init: (props: { data: Data; directory: string }) => {
+    return { ...props.data, directory: props.directory }
   },
 })

+ 28 - 0
packages/util/src/sanitize.ts

@@ -0,0 +1,28 @@
+import { Part } from "@opencode-ai/sdk"
+
+export const sanitize = (text: string | undefined, remove?: RegExp) => (remove ? text?.replace(remove, "") : text) ?? ""
+
+export const sanitizePart = (part: Part, remove: RegExp) => {
+  if (part.type === "text") {
+    part.text = sanitize(part.text, remove)
+  } else if (part.type === "reasoning") {
+    part.text = sanitize(part.text, remove)
+  } else if (part.type === "tool") {
+    if (part.state.status === "completed" || part.state.status === "error") {
+      for (const key in part.state.metadata) {
+        if (typeof part.state.metadata[key] === "string") {
+          part.state.metadata[key] = sanitize(part.state.metadata[key] as string, remove)
+        }
+      }
+      for (const key in part.state.input) {
+        if (typeof part.state.input[key] === "string") {
+          part.state.input[key] = sanitize(part.state.input[key] as string, remove)
+        }
+      }
+      if ("error" in part.state) {
+        part.state.error = sanitize(part.state.error as string, remove)
+      }
+    }
+  }
+  return part
+}