summaryrefslogtreecommitdiffhomepage
path: root/packages/ui
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/ui
parenteaeea45ace01e20405d49d1a659030a4d131dc83 (diff)
downloadopencode-447713244820e875e192a5815c4d8bc76c03b40f.tar.gz
opencode-447713244820e875e192a5815c4d8bc76c03b40f.zip
fix: sanitize absolute paths
Diffstat (limited to 'packages/ui')
-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
5 files changed, 63 insertions, 34 deletions
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 }
},
})