summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-11-25 16:02:27 -0600
committerAdam <[email protected]>2025-11-25 16:02:31 -0600
commit447713244820e875e192a5815c4d8bc76c03b40f (patch)
treea3df06b1df0585a8387c37826ea516ce08169289 /packages
parenteaeea45ace01e20405d49d1a659030a4d131dc83 (diff)
downloadopencode-447713244820e875e192a5815c4d8bc76c03b40f.tar.gz
opencode-447713244820e875e192a5815c4d8bc76c03b40f.zip
fix: sanitize absolute paths
Diffstat (limited to 'packages')
-rw-r--r--packages/desktop/src/components/file-tree.tsx3
-rw-r--r--packages/desktop/src/components/prompt-input.tsx2
-rw-r--r--packages/desktop/src/context/sync.tsx32
-rw-r--r--packages/desktop/src/pages/directory-layout.tsx2
-rw-r--r--packages/desktop/src/pages/home.tsx3
-rw-r--r--packages/desktop/src/pages/layout.tsx3
-rw-r--r--packages/desktop/src/pages/session.tsx2
-rw-r--r--packages/desktop/src/ui/collapsible.tsx62
-rw-r--r--packages/desktop/src/ui/index.ts6
-rw-r--r--packages/desktop/src/utils/index.ts1
-rw-r--r--packages/desktop/src/utils/path.ts20
-rw-r--r--packages/enterprise/src/routes/share/[shareID].tsx403
-rw-r--r--packages/ui/src/components/message-part.css11
-rw-r--r--packages/ui/src/components/message-part.tsx73
-rw-r--r--packages/ui/src/components/message-progress.tsx3
-rw-r--r--packages/ui/src/components/session-turn.tsx6
-rw-r--r--packages/ui/src/context/data.tsx4
-rw-r--r--packages/util/src/sanitize.ts28
18 files changed, 309 insertions, 355 deletions
diff --git a/packages/desktop/src/components/file-tree.tsx b/packages/desktop/src/components/file-tree.tsx
index f3729a8d3..0841c71d1 100644
--- a/packages/desktop/src/components/file-tree.tsx
+++ b/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}
diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx
index 94d8ff882..976924223 100644
--- a/packages/desktop/src/components/prompt-input.tsx
+++ b/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
diff --git a/packages/desktop/src/context/sync.tsx b/packages/desktop/src/context/sync.tsx
index 38058c370..3eb921a31 100644
--- a/packages/desktop/src/context/sync.tsx
+++ b/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
+ },
}
},
})
diff --git a/packages/desktop/src/pages/directory-layout.tsx b/packages/desktop/src/pages/directory-layout.tsx
index 2fe750fda..de16eff30 100644
--- a/packages/desktop/src/pages/directory-layout.tsx
+++ b/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>
)
diff --git a/packages/desktop/src/pages/home.tsx b/packages/desktop/src/pages/home.tsx
index e773fff57..58fcb20ce 100644
--- a/packages/desktop/src/pages/home.tsx
+++ b/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()
diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx
index c9bb559d8..15180c885 100644
--- a/packages/desktop/src/pages/layout.tsx
+++ b/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()
diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx
index 0eb0f37ce..40acac663 100644
--- a/packages/desktop/src/pages/session.tsx
+++ b/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()
diff --git a/packages/desktop/src/ui/collapsible.tsx b/packages/desktop/src/ui/collapsible.tsx
deleted file mode 100644
index 5fbb6c7a4..000000000
--- a/packages/desktop/src/ui/collapsible.tsx
+++ /dev/null
@@ -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,
-})
diff --git a/packages/desktop/src/ui/index.ts b/packages/desktop/src/ui/index.ts
deleted file mode 100644
index 8cbf0834f..000000000
--- a/packages/desktop/src/ui/index.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-export {
- Collapsible,
- type CollapsibleProps,
- type CollapsibleTriggerProps,
- type CollapsibleContentProps,
-} from "./collapsible"
diff --git a/packages/desktop/src/utils/index.ts b/packages/desktop/src/utils/index.ts
index 63a656cc4..e50efe837 100644
--- a/packages/desktop/src/utils/index.ts
+++ b/packages/desktop/src/utils/index.ts
@@ -1,3 +1,2 @@
-export * from "./path"
export * from "./dom"
export * from "./encode"
diff --git a/packages/desktop/src/utils/path.ts b/packages/desktop/src/utils/path.ts
deleted file mode 100644
index d23568ae6..000000000
--- a/packages/desktop/src/utils/path.ts
+++ /dev/null
@@ -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]
-}
diff --git a/packages/enterprise/src/routes/share/[shareID].tsx b/packages/enterprise/src/routes/share/[shareID].tsx
index 292038094..42dd7e957 100644
--- a/packages/enterprise/src/routes/share/[shareID].tsx
+++ b/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>
)
diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css
index dd6166112..0b1e8d490 100644
--- a/packages/ui/src/components/message-part.css
+++ b/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"],
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index dd920f101..40740fa1f 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/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,
}}
/>
diff --git a/packages/ui/src/components/message-progress.tsx b/packages/ui/src/components/message-progress.tsx
index ca42d26ec..adb245ab4 100644
--- a/packages/ui/src/components/message-progress.tsx
+++ b/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>
)
}}
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx
index a7bd456a4..d146bae95 100644
--- a/packages/ui/src/components/session-turn.tsx
+++ b/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()}>
diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx
index c2766a5af..ad5a212ab 100644
--- a/packages/ui/src/context/data.tsx
+++ b/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 }
},
})
diff --git a/packages/util/src/sanitize.ts b/packages/util/src/sanitize.ts
new file mode 100644
index 000000000..892fe87cf
--- /dev/null
+++ b/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
+}