summaryrefslogtreecommitdiffhomepage
path: root/packages/web/src/components/share
diff options
context:
space:
mode:
authorDax <[email protected]>2025-07-07 15:53:43 -0400
committerGitHub <[email protected]>2025-07-07 15:53:43 -0400
commitf884766445bbf1fbce11f1db4bc6174e72d9baa5 (patch)
treee7d9e7b3d8efc8bed249f9d29e2d8fb838a275f2 /packages/web/src/components/share
parent76b2e4539cb97bae5812ed2d832ce49d02e70c64 (diff)
downloadopencode-f884766445bbf1fbce11f1db4bc6174e72d9baa5.tar.gz
opencode-f884766445bbf1fbce11f1db4bc6174e72d9baa5.zip
v2 message format and upgrade to ai sdk v5 (#743)
Co-authored-by: GitHub Action <[email protected]> Co-authored-by: Liang-Shih Lin <[email protected]> Co-authored-by: Dominik Engelhardt <[email protected]> Co-authored-by: Jay V <[email protected]> Co-authored-by: adamdottv <[email protected]>
Diffstat (limited to 'packages/web/src/components/share')
-rw-r--r--packages/web/src/components/share/common.tsx60
-rw-r--r--packages/web/src/components/share/content-code.module.css25
-rw-r--r--packages/web/src/components/share/content-code.tsx32
-rw-r--r--packages/web/src/components/share/content-diff.module.css125
-rw-r--r--packages/web/src/components/share/content-diff.tsx231
-rw-r--r--packages/web/src/components/share/content-markdown.module.css140
-rw-r--r--packages/web/src/components/share/content-markdown.tsx65
-rw-r--r--packages/web/src/components/share/content-text.module.css57
-rw-r--r--packages/web/src/components/share/content-text.tsx35
-rw-r--r--packages/web/src/components/share/part.module.css375
-rw-r--r--packages/web/src/components/share/part.tsx664
11 files changed, 1809 insertions, 0 deletions
diff --git a/packages/web/src/components/share/common.tsx b/packages/web/src/components/share/common.tsx
new file mode 100644
index 000000000..9f5221de9
--- /dev/null
+++ b/packages/web/src/components/share/common.tsx
@@ -0,0 +1,60 @@
+import { createSignal, onCleanup, splitProps } from "solid-js"
+import type { JSX } from "solid-js/jsx-runtime"
+import { IconCheckCircle, IconHashtag } from "../icons"
+
+interface AnchorProps extends JSX.HTMLAttributes<HTMLDivElement> {
+ id: string
+}
+export function AnchorIcon(props: AnchorProps) {
+ const [local, rest] = splitProps(props, ["id", "children"])
+ const [copied, setCopied] = createSignal(false)
+
+ return (
+ <div {...rest} data-element-anchor title="Link to this message" data-status={copied() ? "copied" : ""}>
+ <a
+ href={`#${local.id}`}
+ onClick={(e) => {
+ e.preventDefault()
+
+ const anchor = e.currentTarget
+ const hash = anchor.getAttribute("href") || ""
+ const { origin, pathname, search } = window.location
+
+ navigator.clipboard
+ .writeText(`${origin}${pathname}${search}${hash}`)
+ .catch((err) => console.error("Copy failed", err))
+
+ setCopied(true)
+ setTimeout(() => setCopied(false), 3000)
+ }}
+ >
+ {local.children}
+ <IconHashtag width={18} height={18} />
+ <IconCheckCircle width={18} height={18} />
+ </a>
+ <span data-element-tooltip>Copied!</span>
+ </div>
+ )
+}
+
+export function createOverflow() {
+ const [overflow, setOverflow] = createSignal(false)
+ return {
+ get status() {
+ return overflow()
+ },
+ ref(el: HTMLElement) {
+ const ro = new ResizeObserver(() => {
+ if (el.scrollHeight > el.clientHeight + 1) {
+ setOverflow(true)
+ }
+ return
+ })
+ ro.observe(el)
+
+ onCleanup(() => {
+ ro.disconnect()
+ })
+ },
+ }
+}
diff --git a/packages/web/src/components/share/content-code.module.css b/packages/web/src/components/share/content-code.module.css
new file mode 100644
index 000000000..b95f936da
--- /dev/null
+++ b/packages/web/src/components/share/content-code.module.css
@@ -0,0 +1,25 @@
+.root {
+ max-width: var(--md-tool-width);
+ border: 1px solid var(--sl-color-divider);
+ background-color: var(--sl-color-bg-surface);
+ border-radius: 0.25rem;
+ padding: 0.5rem calc(0.5rem + 3px);
+
+ &[data-flush="true"] {
+ border: none;
+ background-color: transparent;
+ padding: 0;
+ }
+
+ pre {
+ --shiki-dark-bg: var(--sl-color-bg-surface) !important;
+ line-height: 1.6;
+ font-size: 0.75rem;
+ white-space: pre-wrap;
+ word-break: break-word;
+
+ span {
+ white-space: break-spaces;
+ }
+ }
+}
diff --git a/packages/web/src/components/share/content-code.tsx b/packages/web/src/components/share/content-code.tsx
new file mode 100644
index 000000000..b8c4f2ccd
--- /dev/null
+++ b/packages/web/src/components/share/content-code.tsx
@@ -0,0 +1,32 @@
+import { type JSX, splitProps, createResource, Suspense } from "solid-js"
+import { codeToHtml } from "shiki"
+import style from "./content-code.module.css"
+import { transformerNotationDiff } from "@shikijs/transformers"
+
+interface Props {
+ code: string
+ lang?: string
+ flush?: boolean
+}
+export function ContentCode(props: Props) {
+ const [html] = createResource(
+ () => [props.code, props.lang],
+ async ([code, lang]) => {
+ // TODO: For testing delays
+ // await new Promise((resolve) => setTimeout(resolve, 3000))
+ return (await codeToHtml(code || "", {
+ lang: lang || "text",
+ themes: {
+ light: "github-light",
+ dark: "github-dark",
+ },
+ transformers: [transformerNotationDiff()],
+ })) as string
+ },
+ )
+ return (
+ <Suspense>
+ <div innerHTML={html()} class={style.root} data-flush={props.flush === true ? true : undefined} />
+ </Suspense>
+ )
+}
diff --git a/packages/web/src/components/share/content-diff.module.css b/packages/web/src/components/share/content-diff.module.css
new file mode 100644
index 000000000..718ae3690
--- /dev/null
+++ b/packages/web/src/components/share/content-diff.module.css
@@ -0,0 +1,125 @@
+.root {
+ display: flex;
+ flex-direction: column;
+ border: 1px solid var(--sl-color-divider);
+ background-color: var(--sl-color-bg-surface);
+ border-radius: 0.25rem;
+
+ [data-component="desktop"] {
+ display: block;
+ }
+
+ [data-component="mobile"] {
+ display: none;
+ }
+
+ [data-component="diff-block"] {
+ display: flex;
+ flex-direction: column;
+ }
+
+ [data-component="diff-row"] {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ align-items: stretch;
+
+ [data-slot="before"],
+ [data-slot="after"] {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ overflow-x: visible;
+ min-width: 0;
+ align-items: stretch;
+ padding: 0 1rem;
+
+ &[data-diff-type="removed"] {
+ background-color: var(--sl-color-red-low);
+
+ pre {
+ --shiki-dark-bg: var(--sl-color-red-low) !important;
+ background-color: var(--sl-color-red-low) !important;
+ }
+
+ &::before {
+ content: "-";
+ position: absolute;
+ left: 0.5ch;
+ top: 1px;
+ user-select: none;
+ color: var(--sl-color-red-high);
+ }
+ }
+
+ &[data-diff-type="added"] {
+ background-color: var(--sl-color-green-low);
+
+ pre {
+ --shiki-dark-bg: var(--sl-color-green-low) !important;
+ background-color: var(--sl-color-green-low) !important;
+ }
+
+ &::before {
+ content: "+";
+ position: absolute;
+ user-select: none;
+ color: var(--sl-color-green-high);
+ left: 0.5ch;
+ top: 1px;
+ }
+ }
+ }
+
+ [data-slot="before"] {
+ border-right: 1px solid var(--sl-color-divider);
+ }
+ }
+
+ .diff > .row:first-child [data-section="cell"]:first-child {
+ padding-top: 0.5rem;
+ }
+
+ .diff > .row:last-child [data-section="cell"]:last-child {
+ padding-bottom: 0.5rem;
+ }
+
+ [data-section="cell"] {
+ position: relative;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+
+ width: 100%;
+ padding: 0.1875rem 0.5rem 0.1875rem 2.2ch;
+ margin: 0;
+
+ &[data-display-mobile="true"] {
+ display: none;
+ }
+
+ pre {
+ --shiki-dark-bg: var(--sl-color-bg-surface) !important;
+ background-color: var(--sl-color-bg-surface) !important;
+
+ white-space: pre-wrap;
+ word-break: break-word;
+
+ code > span:empty::before {
+ content: "\00a0";
+ white-space: pre;
+ display: inline-block;
+ width: 0;
+ }
+ }
+ }
+
+ @media (max-width: 40rem) {
+ [data-slot="desktop"] {
+ display: none;
+ }
+
+ [data-slot="mobile"] {
+ display: block;
+ }
+ }
+}
diff --git a/packages/web/src/components/share/content-diff.tsx b/packages/web/src/components/share/content-diff.tsx
new file mode 100644
index 000000000..894145c34
--- /dev/null
+++ b/packages/web/src/components/share/content-diff.tsx
@@ -0,0 +1,231 @@
+import { type Component, createMemo } from "solid-js"
+import { parsePatch } from "diff"
+import { ContentCode } from "./content-code"
+import styles from "./content-diff.module.css"
+
+type DiffRow = {
+ left: string
+ right: string
+ type: "added" | "removed" | "unchanged" | "modified"
+}
+
+interface Props {
+ diff: string
+ lang?: string
+}
+
+export function ContentDiff(props: Props) {
+ const rows = createMemo(() => {
+ const diffRows: DiffRow[] = []
+
+ try {
+ const patches = parsePatch(props.diff)
+
+ for (const patch of patches) {
+ for (const hunk of patch.hunks) {
+ const lines = hunk.lines
+ let i = 0
+
+ while (i < lines.length) {
+ const line = lines[i]
+ const content = line.slice(1)
+ const prefix = line[0]
+
+ if (prefix === "-") {
+ // Look ahead for consecutive additions to pair with removals
+ const removals: string[] = [content]
+ let j = i + 1
+
+ // Collect all consecutive removals
+ while (j < lines.length && lines[j][0] === "-") {
+ removals.push(lines[j].slice(1))
+ j++
+ }
+
+ // Collect all consecutive additions that follow
+ const additions: string[] = []
+ while (j < lines.length && lines[j][0] === "+") {
+ additions.push(lines[j].slice(1))
+ j++
+ }
+
+ // Pair removals with additions
+ const maxLength = Math.max(removals.length, additions.length)
+ for (let k = 0; k < maxLength; k++) {
+ const hasLeft = k < removals.length
+ const hasRight = k < additions.length
+
+ if (hasLeft && hasRight) {
+ // Replacement - left is removed, right is added
+ diffRows.push({
+ left: removals[k],
+ right: additions[k],
+ type: "modified",
+ })
+ } else if (hasLeft) {
+ // Pure removal
+ diffRows.push({
+ left: removals[k],
+ right: "",
+ type: "removed",
+ })
+ } else if (hasRight) {
+ // Pure addition - only create if we actually have content
+ diffRows.push({
+ left: "",
+ right: additions[k],
+ type: "added",
+ })
+ }
+ }
+
+ i = j
+ } else if (prefix === "+") {
+ // Standalone addition (not paired with removal)
+ diffRows.push({
+ left: "",
+ right: content,
+ type: "added",
+ })
+ i++
+ } else if (prefix === " ") {
+ diffRows.push({
+ left: content,
+ right: content,
+ type: "unchanged",
+ })
+ i++
+ } else {
+ i++
+ }
+ }
+ }
+ }
+ } catch (error) {
+ console.error("Failed to parse patch:", error)
+ return []
+ }
+
+ return diffRows
+ })
+
+ const mobileRows = createMemo(() => {
+ const mobileBlocks: { type: "removed" | "added" | "unchanged"; lines: string[] }[] = []
+ const currentRows = rows()
+
+ let i = 0
+ while (i < currentRows.length) {
+ const removedLines: string[] = []
+ const addedLines: string[] = []
+
+ // Collect consecutive modified/removed/added rows
+ while (
+ i < currentRows.length &&
+ (currentRows[i].type === "modified" || currentRows[i].type === "removed" || currentRows[i].type === "added")
+ ) {
+ const row = currentRows[i]
+ if (row.left && (row.type === "removed" || row.type === "modified")) {
+ removedLines.push(row.left)
+ }
+ if (row.right && (row.type === "added" || row.type === "modified")) {
+ addedLines.push(row.right)
+ }
+ i++
+ }
+
+ // Add grouped blocks
+ if (removedLines.length > 0) {
+ mobileBlocks.push({ type: "removed", lines: removedLines })
+ }
+ if (addedLines.length > 0) {
+ mobileBlocks.push({ type: "added", lines: addedLines })
+ }
+
+ // Add unchanged rows as-is
+ if (i < currentRows.length && currentRows[i].type === "unchanged") {
+ mobileBlocks.push({
+ type: "unchanged",
+ lines: [currentRows[i].left],
+ })
+ i++
+ }
+ }
+
+ return mobileBlocks
+ })
+
+ return (
+ <div class={styles.root}>
+ <div data-component="desktop">
+ {rows().map((r) => (
+ <div data-component="diff-row" data-type={r.type}>
+ <div data-slot="before" data-diff-type={r.type === "removed" || r.type === "modified" ? "removed" : ""}>
+ <ContentCode code={r.left} flush lang={props.lang} />
+ </div>
+ <div data-slot="after" data-diff-type={r.type === "added" || r.type === "modified" ? "added" : ""}>
+ <ContentCode code={r.right} lang={props.lang} flush />
+ </div>
+ </div>
+ ))}
+ </div>
+
+ <div data-component="mobile">
+ {mobileRows().map((block) => (
+ <div data-component="diff-block" data-type={block.type}>
+ {block.lines.map((line) => (
+ <ContentCode
+ code={line}
+ lang={props.lang}
+ data-section="cell"
+ data-diff-type={block.type === "removed" ? "removed" : block.type === "added" ? "added" : ""}
+ />
+ ))}
+ </div>
+ ))}
+ </div>
+ </div>
+ )
+}
+
+// const testDiff = `--- combined_before.txt 2025-06-24 16:38:08
+// +++ combined_after.txt 2025-06-24 16:38:12
+// @@ -1,21 +1,25 @@
+// unchanged line
+// -deleted line
+// -old content
+// +added line
+// +new content
+//
+// -removed empty line below
+// +added empty line above
+//
+// - tab indented
+// -trailing spaces
+// -very long line that will definitely wrap in most editors and cause potential alignment issues when displayed in a two column diff view
+// -unicode content: πŸš€ ✨ δΈ­ζ–‡
+// -mixed content with tabs and spaces
+// + space indented
+// +no trailing spaces
+// +short line
+// +very long replacement line that will also wrap and test how the diff viewer handles long line additions after short line removals
+// +different unicode: πŸŽ‰ πŸ’» ζ—₯本θͺž
+// +normalized content with consistent spacing
+// +newline to content
+//
+// -content to remove
+// -whitespace only:
+// -multiple
+// -consecutive
+// -deletions
+// -single deletion
+// +
+// +single addition
+// +first addition
+// +second addition
+// +third addition
+// line before addition
+// +first added line
+// +
+// +third added line
+// line after addition
+// final unchanged line`
diff --git a/packages/web/src/components/share/content-markdown.module.css b/packages/web/src/components/share/content-markdown.module.css
new file mode 100644
index 000000000..da1aa1128
--- /dev/null
+++ b/packages/web/src/components/share/content-markdown.module.css
@@ -0,0 +1,140 @@
+.root {
+ border: 1px solid var(--sl-color-blue-high);
+ padding: 0.5rem calc(0.5rem + 3px);
+ border-radius: 0.25rem;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 1rem;
+ align-self: flex-start;
+ max-width: var(--md-tool-width);
+
+ &[data-highlight="true"] {
+ background-color: var(--sl-color-blue-low);
+ }
+
+ [data-slot="expand-button"] {
+ flex: 0 0 auto;
+ padding: 2px 0;
+ font-size: 0.75rem;
+ }
+
+ [data-slot="markdown"] {
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 3;
+ line-clamp: 3;
+ overflow: hidden;
+
+ [data-expanded] & {
+ display: block;
+ }
+
+ font-size: 0.875rem;
+ line-height: 1.5;
+
+ p,
+ blockquote,
+ ul,
+ ol,
+ dl,
+ table,
+ pre {
+ margin-bottom: 1rem;
+ }
+
+ strong {
+ font-weight: 600;
+ }
+
+ ol {
+ list-style-position: inside;
+ padding-left: 0.75rem;
+ }
+
+ ul {
+ padding-left: 1.5rem;
+ }
+
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ font-size: 0.875rem;
+ font-weight: 600;
+ margin-bottom: 0.5rem;
+ }
+
+ & > *:last-child {
+ margin-bottom: 0;
+ }
+
+ pre {
+ --shiki-dark-bg: var(--sl-color-bg-surface) !important;
+ background-color: var(--sl-color-bg-surface) !important;
+ padding: 0.5rem 0.75rem;
+ line-height: 1.6;
+ font-size: 0.75rem;
+ white-space: pre-wrap;
+ word-break: break-word;
+
+ span {
+ white-space: break-spaces;
+ }
+ }
+
+ code {
+ font-weight: 500;
+
+ &:not(pre code) {
+ &::before {
+ content: "`";
+ font-weight: 700;
+ }
+
+ &::after {
+ content: "`";
+ font-weight: 700;
+ }
+ }
+ }
+
+ table {
+ border-collapse: collapse;
+ width: 100%;
+ }
+
+ th,
+ td {
+ border: 1px solid var(--sl-color-border);
+ padding: 0.5rem 0.75rem;
+ text-align: left;
+ }
+
+ th {
+ border-bottom: 1px solid var(--sl-color-border);
+ }
+
+ /* Remove outer borders */
+ table tr:first-child th,
+ table tr:first-child td {
+ border-top: none;
+ }
+
+ table tr:last-child td {
+ border-bottom: none;
+ }
+
+ table th:first-child,
+ table td:first-child {
+ border-left: none;
+ }
+
+ table th:last-child,
+ table td:last-child {
+ border-right: none;
+ }
+ }
+}
diff --git a/packages/web/src/components/share/content-markdown.tsx b/packages/web/src/components/share/content-markdown.tsx
new file mode 100644
index 000000000..f79271296
--- /dev/null
+++ b/packages/web/src/components/share/content-markdown.tsx
@@ -0,0 +1,65 @@
+import style from "./content-markdown.module.css"
+import { createResource, createSignal } from "solid-js"
+import { createOverflow } from "./common"
+import { transformerNotationDiff } from "@shikijs/transformers"
+import { marked } from "marked"
+import markedShiki from "marked-shiki"
+import { codeToHtml } from "shiki"
+
+const markedWithShiki = marked.use(
+ markedShiki({
+ highlight(code, lang) {
+ return codeToHtml(code, {
+ lang: lang || "text",
+ themes: {
+ light: "github-light",
+ dark: "github-dark",
+ },
+ transformers: [transformerNotationDiff()],
+ })
+ },
+ }),
+)
+
+interface Props {
+ text: string
+ expand?: boolean
+ highlight?: boolean
+}
+export function ContentMarkdown(props: Props) {
+ const [html] = createResource(
+ () => strip(props.text),
+ async (markdown) => {
+ return markedWithShiki.parse(markdown)
+ },
+ )
+ const [expanded, setExpanded] = createSignal(false)
+ const overflow = createOverflow()
+
+ return (
+ <div
+ class={style.root}
+ data-highlight={props.highlight === true ? true : undefined}
+ data-expanded={expanded() || props.expand === true ? true : undefined}
+ >
+ <div data-slot="markdown" ref={overflow.ref} innerHTML={html()} />
+
+ {!props.expand && overflow.status && (
+ <button
+ type="button"
+ data-component="text-button"
+ data-slot="expand-button"
+ onClick={() => setExpanded((e) => !e)}
+ >
+ {expanded() ? "Show less" : "Show more"}
+ </button>
+ )}
+ </div>
+ )
+}
+
+function strip(text: string): string {
+ const wrappedRe = /^\s*<([A-Za-z]\w*)>\s*([\s\S]*?)\s*<\/\1>\s*$/
+ const match = text.match(wrappedRe)
+ return match ? match[2] : text
+}
diff --git a/packages/web/src/components/share/content-text.module.css b/packages/web/src/components/share/content-text.module.css
new file mode 100644
index 000000000..f8d0b0b93
--- /dev/null
+++ b/packages/web/src/components/share/content-text.module.css
@@ -0,0 +1,57 @@
+.root {
+ color: var(--sl-color-text);
+ background-color: var(--sl-color-bg-surface);
+ padding: 0.5rem calc(0.5rem + 3px);
+ border-radius: 0.25rem;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 1rem;
+ align-self: flex-start;
+ max-width: var(--md-tool-width);
+ font-size: 0.875rem;
+
+ &[data-compact] {
+ font-size: 0.75rem;
+ color: var(--sl-color-text-dimmed);
+ }
+
+ [data-slot="text"] {
+ line-height: 1.5;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 3;
+ line-clamp: 3;
+ overflow: hidden;
+
+ [data-expanded] & {
+ display: block;
+ }
+ }
+
+ [data-slot="expand-button"] {
+ flex: 0 0 auto;
+ padding: 2px 0;
+ font-size: 0.75rem;
+ }
+
+ &[data-theme="invert"] {
+ background-color: var(--sl-color-blue-high);
+ color: var(--sl-color-text-invert);
+
+ [data-slot="expand-button"] {
+ opacity: 0.85;
+ color: var(--sl-color-text-invert);
+
+ &:hover {
+ opacity: 1;
+ }
+ }
+ }
+
+ &[data-theme="blue"] {
+ background-color: var(--sl-color-blue-low);
+ }
+}
diff --git a/packages/web/src/components/share/content-text.tsx b/packages/web/src/components/share/content-text.tsx
new file mode 100644
index 000000000..c52e0dfcc
--- /dev/null
+++ b/packages/web/src/components/share/content-text.tsx
@@ -0,0 +1,35 @@
+import style from "./content-text.module.css"
+import { createSignal } from "solid-js"
+import { createOverflow } from "./common"
+
+interface Props {
+ text: string
+ expand?: boolean
+ compact?: boolean
+}
+export function ContentText(props: Props) {
+ const [expanded, setExpanded] = createSignal(false)
+ const overflow = createOverflow()
+
+ return (
+ <div
+ class={style.root}
+ data-expanded={expanded() || props.expand === true ? true : undefined}
+ data-compact={props.compact === true ? true : undefined}
+ >
+ <pre data-slot="text" ref={overflow.ref}>
+ {props.text}
+ </pre>
+ {((!props.expand && overflow.status) || expanded()) && (
+ <button
+ type="button"
+ data-component="text-button"
+ data-slot="expand-button"
+ onClick={() => setExpanded((e) => !e)}
+ >
+ {expanded() ? "Show less" : "Show more"}
+ </button>
+ )}
+ </div>
+ )
+}
diff --git a/packages/web/src/components/share/part.module.css b/packages/web/src/components/share/part.module.css
new file mode 100644
index 000000000..9145cddf7
--- /dev/null
+++ b/packages/web/src/components/share/part.module.css
@@ -0,0 +1,375 @@
+.root {
+ display: flex;
+ gap: 0.625rem;
+
+ [data-component="decoration"] {
+ flex: 0 0 auto;
+ display: flex;
+ flex-direction: column;
+ gap: 0.625rem;
+ align-items: center;
+ justify-content: flex-start;
+
+ [data-slot="anchor"] {
+ position: relative;
+
+ a:first-child {
+ display: block;
+ flex: 0 0 auto;
+ width: 18px;
+ opacity: 0.65;
+
+ svg {
+ color: var(--sl-color-text-secondary);
+ display: block;
+
+ &:nth-child(3) {
+ color: var(--sl-color-green-high);
+ }
+ }
+
+ svg:nth-child(2),
+ svg:nth-child(3) {
+ display: none;
+ }
+
+ &:hover {
+ svg:nth-child(1) {
+ display: none;
+ }
+
+ svg:nth-child(2) {
+ display: block;
+ }
+ }
+ }
+
+ [data-copied] & {
+ a,
+ a:hover {
+ svg:nth-child(1),
+ svg:nth-child(2) {
+ display: none;
+ }
+
+ svg:nth-child(3) {
+ display: block;
+ }
+ }
+ }
+ }
+
+ [data-slot="bar"] {
+ width: 3px;
+ height: 100%;
+ border-radius: 1px;
+ background-color: var(--sl-color-hairline);
+ }
+
+ [data-slot="tooltip"] {
+ position: absolute;
+ top: 50%;
+ left: calc(100% + 12px);
+ transform: translate(0, -50%);
+ line-height: 1.1;
+ padding: 0.375em 0.5em calc(0.375em + 2px);
+ background: var(--sl-color-white);
+ color: var(--sl-color-text-invert);
+ font-size: 0.6875rem;
+ border-radius: 7px;
+ white-space: nowrap;
+
+ z-index: 1;
+ opacity: 0;
+ visibility: hidden;
+
+ &::after {
+ content: "";
+ position: absolute;
+ top: 50%;
+ left: -15px;
+ transform: translateY(-50%);
+ border: 8px solid transparent;
+ border-right-color: var(--sl-color-white);
+ }
+
+ [data-copied] & {
+ opacity: 1;
+ visibility: visible;
+ }
+ }
+ }
+
+ [data-component="content"] {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ flex-grow: 1;
+ }
+
+ [data-component="spacer"] {
+ height: 0rem;
+ }
+
+ [data-component="content-footer"] {
+ align-self: flex-start;
+ font-size: 0.75rem;
+ color: var(--sl-color-text-dimmed);
+ }
+
+ [data-component="step-start"] {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.375rem;
+ padding-bottom: 1rem;
+
+ [data-slot="provider"] {
+ line-height: 18px;
+ font-size: 0.875rem;
+ text-transform: uppercase;
+ letter-spacing: -0.5px;
+ color: var(--sl-color-text-secondary);
+ }
+
+ [data-slot="model"] {
+ line-height: 1.5;
+ }
+ }
+
+ [data-component="button-text"] {
+ cursor: pointer;
+ appearance: none;
+ background-color: transparent;
+ border: none;
+ padding: 0;
+ color: var(--sl-color-text-secondary);
+ font-size: 0.75rem;
+
+ &:hover {
+ color: var(--sl-color-text);
+ }
+
+ &[data-more] {
+ display: flex;
+ align-items: center;
+ gap: 0.125rem;
+
+ span[data-slot="icon"] {
+ line-height: 1;
+ opacity: 0.85;
+
+ svg {
+ display: block;
+ }
+ }
+ }
+ }
+
+ [data-component="tool"] {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.375rem;
+ padding-bottom: 1rem;
+ }
+
+ [data-component="tool-title"] {
+ line-height: 18px;
+ font-size: 0.875rem;
+ color: var(--sl-color-text-secondary);
+ max-width: var(--md-tool-width);
+ display: flex;
+ align-items: flex-start;
+ gap: 0.375rem;
+
+ [data-slot="name"] {
+ text-transform: uppercase;
+ letter-spacing: -0.5px;
+ }
+
+ [data-slot="target"] {
+ color: var(--sl-color-text);
+ word-break: break-all;
+ font-weight: 500;
+ }
+ }
+
+ [data-component="tool-result"] {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.5rem;
+ }
+
+ [data-component="todos"] {
+ list-style-type: none;
+ padding: 0;
+ margin: 0;
+ width: 100%;
+ max-width: var(--sm-tool-width);
+ border: 1px solid var(--sl-color-divider);
+ border-radius: 0.25rem;
+
+ [data-slot="item"] {
+ margin: 0;
+ position: relative;
+ padding-left: 1.5rem;
+ font-size: 0.75rem;
+ padding: 0.375rem 0.625rem 0.375rem 1.75rem;
+ border-bottom: 1px solid var(--sl-color-divider);
+ line-height: 1.5;
+ word-break: break-word;
+
+ &:last-child {
+ border-bottom: none;
+ }
+
+ & > span {
+ position: absolute;
+ display: inline-block;
+ left: 0.5rem;
+ top: calc(0.5rem + 1px);
+ width: 0.75rem;
+ height: 0.75rem;
+ border: 1px solid var(--sl-color-divider);
+ border-radius: 0.15rem;
+
+ &::before {
+ }
+ }
+
+ &[data-status="pending"] {
+ color: var(--sl-color-text);
+ }
+
+ &[data-status="in_progress"] {
+ color: var(--sl-color-text);
+
+ & > span {
+ border-color: var(--sl-color-orange);
+ }
+
+ & > span::before {
+ content: "";
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ width: calc(0.75rem - 2px - 4px);
+ height: calc(0.75rem - 2px - 4px);
+ box-shadow: inset 1rem 1rem var(--sl-color-orange-low);
+ }
+ }
+
+ &[data-status="completed"] {
+ color: var(--sl-color-text-secondary);
+
+ & > span {
+ border-color: var(--sl-color-green-low);
+ }
+
+ & > span::before {
+ content: "";
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ width: calc(0.75rem - 2px - 4px);
+ height: calc(0.75rem - 2px - 4px);
+ box-shadow: inset 1rem 1rem var(--sl-color-green);
+
+ transform-origin: bottom left;
+ clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
+ }
+ }
+ }
+ }
+
+ [data-component="terminal"] {
+ width: 100%;
+ max-width: var(--sm-tool-width);
+
+ [data-slot="body"] {
+ display: flex;
+ flex-direction: column;
+ border: 1px solid var(--sl-color-divider);
+ border-radius: 0.25rem;
+ overflow: hidden;
+ }
+
+ [data-slot="header"] {
+ position: relative;
+ border-bottom: 1px solid var(--sl-color-divider);
+ width: 100%;
+ height: 1.625rem;
+ text-align: center;
+ padding: 0 3.25rem;
+
+ > span {
+ max-width: min(100%, 140ch);
+ display: inline-block;
+ white-space: nowrap;
+ overflow: hidden;
+ line-height: 1.625rem;
+ font-size: 0.75rem;
+ text-overflow: ellipsis;
+ color: var(--sl-color-text-dimmed);
+ }
+
+ &::before {
+ content: "";
+ position: absolute;
+ pointer-events: none;
+ top: 8px;
+ left: 10px;
+ width: 2rem;
+ height: 0.5rem;
+ line-height: 0;
+ background-color: var(--sl-color-hairline);
+ mask-image: var(--term-icon);
+ mask-repeat: no-repeat;
+ }
+ }
+
+ [data-slot="content"] {
+ display: flex;
+ flex-direction: column;
+ padding: 0.5rem calc(0.5rem + 3px);
+
+ pre {
+ --shiki-dark-bg: var(--sl-color-bg) !important;
+ background-color: var(--sl-color-bg) !important;
+ line-height: 1.6;
+ font-size: 0.75rem;
+ white-space: pre-wrap;
+ word-break: break-word;
+ }
+ }
+ }
+
+ [data-component="tool-args"] {
+ display: inline-grid;
+ align-items: center;
+ grid-template-columns: max-content max-content minmax(0, 1fr);
+ max-width: var(--md-tool-width);
+ gap: 0.25rem 0.375rem;
+
+ & > div:nth-child(3n + 1) {
+ width: 8px;
+ height: 2px;
+ border-radius: 1px;
+ background: var(--sl-color-divider);
+ }
+
+ & > div:nth-child(3n + 2),
+ & > div:nth-child(3n + 3) {
+ font-size: 0.75rem;
+ line-height: 1.5;
+ }
+
+ & > div:nth-child(3n + 3) {
+ padding-left: 0.125rem;
+ word-break: break-word;
+ color: var(--sl-color-text-secondary);
+ }
+ }
+}
diff --git a/packages/web/src/components/share/part.tsx b/packages/web/src/components/share/part.tsx
new file mode 100644
index 000000000..3ee2c61a3
--- /dev/null
+++ b/packages/web/src/components/share/part.tsx
@@ -0,0 +1,664 @@
+import { createMemo, createSignal, For, Match, Show, Switch, type JSX, type ParentProps } from "solid-js"
+import {
+ IconCheckCircle,
+ IconChevronDown,
+ IconChevronRight,
+ IconHashtag,
+ IconSparkles,
+ IconGlobeAlt,
+ IconDocument,
+ IconQueueList,
+ IconCommandLine,
+ IconDocumentPlus,
+ IconPencilSquare,
+ IconRectangleStack,
+ IconMagnifyingGlass,
+ IconDocumentMagnifyingGlass,
+} from "../icons"
+import styles from "./part.module.css"
+import type { MessageV2 } from "opencode/session/message-v2"
+import { ContentText } from "./content-text"
+import { ContentMarkdown } from "./content-markdown"
+import { DateTime } from "luxon"
+import CodeBlock from "../CodeBlock"
+import map from "lang-map"
+import type { Diagnostic } from "vscode-languageserver-types"
+
+import { ContentCode } from "./content-code"
+import { ContentDiff } from "./content-diff"
+
+export interface PartProps {
+ index: number
+ message: MessageV2.Info
+ part: MessageV2.AssistantPart | MessageV2.UserPart
+ last: boolean
+}
+
+export function Part(props: PartProps) {
+ const [copied, setCopied] = createSignal(false)
+ const id = createMemo(() => props.message.id + "-" + props.index)
+
+ return (
+ <div
+ class={styles.root}
+ id={id()}
+ data-component="part"
+ data-type={props.part.type}
+ data-role={props.message.role}
+ data-copied={copied() ? true : undefined}
+ >
+ <div data-component="decoration">
+ <div data-slot="anchor" title="Link to this message">
+ <a
+ href={`#${id()}`}
+ onClick={(e) => {
+ e.preventDefault()
+ const anchor = e.currentTarget
+ const hash = anchor.getAttribute("href") || ""
+ const { origin, pathname, search } = window.location
+ navigator.clipboard
+ .writeText(`${origin}${pathname}${search}${hash}`)
+ .catch((err) => console.error("Copy failed", err))
+
+ setCopied(true)
+ setTimeout(() => setCopied(false), 3000)
+ }}
+ >
+ <Switch>
+ <Match when={props.part.type === "tool" && props.part.tool === "todowrite"}>
+ <IconQueueList width={18} height={18} />
+ </Match>
+ <Match when={props.part.type === "tool" && props.part.tool === "todoread"}>
+ <IconQueueList width={18} height={18} />
+ </Match>
+ <Match when={props.part.type === "tool" && props.part.tool === "bash"}>
+ <IconCommandLine width={18} height={18} />
+ </Match>
+ <Match when={props.part.type === "tool" && props.part.tool === "edit"}>
+ <IconPencilSquare width={18} height={18} />
+ </Match>
+ <Match when={props.part.type === "tool" && props.part.tool === "write"}>
+ <IconDocumentPlus width={18} height={18} />
+ </Match>
+ <Match when={props.part.type === "tool" && props.part.tool === "read"}>
+ <IconDocument width={18} height={18} />
+ </Match>
+ <Match when={props.part.type === "tool" && props.part.tool === "grep"}>
+ <IconDocumentMagnifyingGlass width={18} height={18} />
+ </Match>
+ <Match when={props.part.type === "tool" && props.part.tool === "list"}>
+ <IconRectangleStack width={18} height={18} />
+ </Match>
+ <Match when={props.part.type === "tool" && props.part.tool === "glob"}>
+ <IconMagnifyingGlass width={18} height={18} />
+ </Match>
+ <Match when={props.part.type === "tool" && props.part.tool === "webfetch"}>
+ <IconGlobeAlt width={18} height={18} />
+ </Match>
+ <Match when={props.part.type === "tool" && props.part.tool === "task"}>
+ <IconRectangleStack width={18} height={18} />
+ </Match>
+ <Match when={true}>
+ <IconSparkles width={18} height={18} />
+ </Match>
+ </Switch>
+ <IconHashtag width={18} height={18} />
+ <IconCheckCircle width={18} height={18} />
+ </a>
+ <span data-slot="tooltip">Copied!</span>
+ </div>
+ <div data-slot="bar"></div>
+ </div>
+ <div data-component="content">
+ {props.message.role === "user" && props.part.type === "text" && (
+ <>
+ <ContentText text={props.part.text} expand={props.last} /> <Spacer />
+ </>
+ )}
+ {props.message.role === "assistant" && props.part.type === "text" && (
+ <>
+ <ContentMarkdown expand={props.last} text={props.part.text} />
+ {props.last && props.message.role === "assistant" && props.message.time.completed && (
+ <Footer
+ title={DateTime.fromMillis(props.message.time.completed).toLocaleString(
+ DateTime.DATETIME_FULL_WITH_SECONDS,
+ )}
+ >
+ {DateTime.fromMillis(props.message.time.completed).toLocaleString(DateTime.DATETIME_MED)}
+ </Footer>
+ )}
+ <Spacer />
+ </>
+ )}
+ {props.part.type === "step-start" && props.message.role === "assistant" && (
+ <div data-component="step-start">
+ <div data-slot="provider">{props.message.providerID}</div>
+ <div data-slot="model">{props.message.modelID}</div>
+ </div>
+ )}
+ {props.part.type === "tool" &&
+ props.part.state.status === "completed" &&
+ props.message.role === "assistant" && (
+ <div data-component="tool" data-tool={props.part.tool}>
+ <Switch>
+ <Match when={props.part.tool === "grep"}>
+ <GrepTool
+ message={props.message}
+ id={props.part.id}
+ tool={props.part.tool}
+ state={props.part.state}
+ />
+ </Match>
+ <Match when={props.part.tool === "glob"}>
+ <GlobTool
+ message={props.message}
+ id={props.part.id}
+ tool={props.part.tool}
+ state={props.part.state}
+ />
+ </Match>
+ <Match when={props.part.tool === "list"}>
+ <ListTool
+ message={props.message}
+ id={props.part.id}
+ tool={props.part.tool}
+ state={props.part.state}
+ />
+ </Match>
+ <Match when={props.part.tool === "read"}>
+ <ReadTool
+ message={props.message}
+ id={props.part.id}
+ tool={props.part.tool}
+ state={props.part.state}
+ />
+ </Match>
+ <Match when={props.part.tool === "write"}>
+ <WriteTool
+ message={props.message}
+ id={props.part.id}
+ tool={props.part.tool}
+ state={props.part.state}
+ />
+ </Match>
+ <Match when={props.part.tool === "edit"}>
+ <EditTool
+ message={props.message}
+ id={props.part.id}
+ tool={props.part.tool}
+ state={props.part.state}
+ />
+ </Match>
+ <Match when={props.part.tool === "bash"}>
+ <BashTool
+ id={props.part.id}
+ tool={props.part.tool}
+ state={props.part.state}
+ message={props.message}
+ />
+ </Match>
+ <Match when={props.part.tool === "todowrite"}>
+ <TodoWriteTool
+ message={props.message}
+ id={props.part.id}
+ tool={props.part.tool}
+ state={props.part.state}
+ />
+ </Match>
+ <Match when={props.part.tool === "webfetch"}>
+ <WebFetchTool
+ message={props.message}
+ id={props.part.id}
+ tool={props.part.tool}
+ state={props.part.state}
+ />
+ </Match>
+ <Match when={true}>
+ <FallbackTool
+ message={props.message}
+ id={props.part.id}
+ tool={props.part.tool}
+ state={props.part.state}
+ />
+ </Match>
+ </Switch>
+ </div>
+ )}
+ </div>
+ </div>
+ )
+}
+
+type ToolProps = {
+ id: MessageV2.ToolPart["id"]
+ tool: MessageV2.ToolPart["tool"]
+ state: MessageV2.ToolStateCompleted
+ message: MessageV2.Assistant
+ isLastPart?: boolean
+}
+
+interface Todo {
+ id: string
+ content: string
+ status: "pending" | "in_progress" | "completed"
+ priority: "low" | "medium" | "high"
+}
+
+function stripWorkingDirectory(filePath?: string, workingDir?: string) {
+ if (filePath === undefined || workingDir === undefined) return filePath
+
+ const prefix = workingDir.endsWith("/") ? workingDir : workingDir + "/"
+
+ if (filePath === workingDir) {
+ return ""
+ }
+
+ if (filePath.startsWith(prefix)) {
+ return filePath.slice(prefix.length)
+ }
+
+ return filePath
+}
+
+function getShikiLang(filename: string) {
+ const ext = filename.split(".").pop()?.toLowerCase() ?? ""
+ const langs = map.languages(ext)
+ const type = langs?.[0]?.toLowerCase()
+
+ const overrides: Record<string, string> = {
+ conf: "shellscript",
+ }
+
+ return type ? (overrides[type] ?? type) : "plaintext"
+}
+
+function getDiagnostics(diagnosticsByFile: Record<string, Diagnostic[]>, currentFile: string): JSX.Element[] {
+ const result: JSX.Element[] = []
+
+ if (diagnosticsByFile === undefined || diagnosticsByFile[currentFile] === undefined) return result
+
+ for (const diags of Object.values(diagnosticsByFile)) {
+ for (const d of diags) {
+ if (d.severity !== 1) continue
+
+ const line = d.range.start.line + 1
+ const column = d.range.start.character + 1
+
+ result.push(
+ <pre>
+ <span data-color="red" data-marker="label">
+ Error
+ </span>
+ <span data-color="dimmed" data-separator>
+ [{line}:{column}]
+ </span>
+ <span>{d.message}</span>
+ </pre>,
+ )
+ }
+ }
+
+ return result
+}
+
+function formatErrorString(error: string): JSX.Element {
+ const errorMarker = "Error: "
+ const startsWithError = error.startsWith(errorMarker)
+
+ return startsWithError ? (
+ <pre>
+ <span data-color="red" data-marker="label" data-separator>
+ Error
+ </span>
+ <span>{error.slice(errorMarker.length)}</span>
+ </pre>
+ ) : (
+ <pre>
+ <span data-color="dimmed">{error}</span>
+ </pre>
+ )
+}
+
+export function TodoWriteTool(props: ToolProps) {
+ const priority: Record<Todo["status"], number> = {
+ in_progress: 0,
+ pending: 1,
+ completed: 2,
+ }
+ const todos = createMemo(() =>
+ ((props.state.input?.todos ?? []) as Todo[]).slice().sort((a, b) => priority[a.status] - priority[b.status]),
+ )
+ const starting = () => todos().every((t: Todo) => t.status === "pending")
+ const finished = () => todos().every((t: Todo) => t.status === "completed")
+
+ return (
+ <>
+ <div data-component="tool-title">
+ <span data-slot="name">
+ <Switch fallback="Updating plan">
+ <Match when={starting()}>Creating plan</Match>
+ <Match when={finished()}>Completing plan</Match>
+ </Switch>
+ </span>
+ </div>
+ <Show when={todos().length > 0}>
+ <ul data-component="todos">
+ <For each={todos()}>
+ {(todo) => (
+ <li data-slot="item" data-status={todo.status}>
+ <span></span>
+ {todo.content}
+ </li>
+ )}
+ </For>
+ </ul>
+ </Show>
+ </>
+ )
+}
+
+export function GrepTool(props: ToolProps) {
+ return (
+ <>
+ <div data-component="tool-title">
+ <span data-slot="name">Grep</span>
+ <span data-slot="target">&ldquo;{props.state.input.pattern}&rdquo;</span>
+ </div>
+ <div data-component="tool-result">
+ <Switch>
+ <Match when={props.state.metadata?.matches && props.state.metadata?.matches > 0}>
+ <ResultsButton
+ showCopy={props.state.metadata?.matches === 1 ? "1 match" : `${props.state.metadata?.matches} matches`}
+ >
+ <ContentText expand compact text={props.state.output} />
+ </ResultsButton>
+ </Match>
+ <Match when={props.state.output}>
+ <ContentText expand compact text={props.state.output} data-size="sm" data-color="dimmed" />
+ </Match>
+ </Switch>
+ </div>
+ </>
+ )
+}
+
+export function ListTool(props: ToolProps) {
+ const path = createMemo(() =>
+ props.state.input?.path !== props.message.path.cwd
+ ? stripWorkingDirectory(props.state.input?.path, props.message.path.cwd)
+ : props.state.input?.path,
+ )
+
+ return (
+ <>
+ <div data-component="tool-title">
+ <span data-slot="name">LS</span>
+ <span data-slot="target" title={props.state.input?.path}>
+ {path()}
+ </span>
+ </div>
+ <div data-component="tool-result">
+ <Switch>
+ <Match when={props.state.output}>
+ <ResultsButton>
+ <ContentText expand compact text={props.state.output} />
+ </ResultsButton>
+ </Match>
+ </Switch>
+ </div>
+ </>
+ )
+}
+
+export function WebFetchTool(props: ToolProps) {
+ return (
+ <>
+ <div data-component="tool-title">
+ <span data-slot="name">Fetch</span>
+ <span data-slot="target">{props.state.input.url}</span>
+ </div>
+ <div data-component="tool-result">
+ <Switch>
+ <Match when={props.state.metadata?.error}>
+ <div data-component="error">{formatErrorString(props.state.output)}</div>
+ </Match>
+ <Match when={props.state.output}>
+ <ResultsButton>
+ <CodeBlock lang={props.state.input.format || "text"} code={props.state.output} />
+ </ResultsButton>
+ </Match>
+ </Switch>
+ </div>
+ </>
+ )
+}
+
+export function ReadTool(props: ToolProps) {
+ const filePath = createMemo(() => stripWorkingDirectory(props.state.input?.filePath, props.message.path.cwd))
+
+ return (
+ <>
+ <div data-component="tool-title">
+ <span data-slot="name">Read</span>
+ <span data-slot="target" title={props.state.input?.filePath}>
+ {filePath()}
+ </span>
+ </div>
+ <div data-component="tool-result">
+ <Switch>
+ <Match when={props.state.metadata?.error}>
+ <div data-component="error">{formatErrorString(props.state.output)}</div>
+ </Match>
+ <Match when={typeof props.state.metadata?.preview === "string"}>
+ <ResultsButton showCopy="Show preview" hideCopy="Hide preview">
+ <ContentCode lang={getShikiLang(filePath() || "")} code={props.state.metadata?.preview} />
+ </ResultsButton>
+ </Match>
+ <Match when={typeof props.state.metadata?.preview !== "string" && props.state.output}>
+ <ResultsButton>
+ <ContentText expand compact text={props.state.output} />
+ </ResultsButton>
+ </Match>
+ </Switch>
+ </div>
+ </>
+ )
+}
+
+export function WriteTool(props: ToolProps) {
+ const filePath = createMemo(() => stripWorkingDirectory(props.state.input?.filePath, props.message.path.cwd))
+ const diagnostics = createMemo(() => getDiagnostics(props.state.metadata?.diagnostics, props.state.input.filePath))
+
+ return (
+ <>
+ <div data-component="tool-title">
+ <span data-slot="name">Write</span>
+ <span data-slot="target" title={props.state.input?.filePath}>
+ {filePath()}
+ </span>
+ </div>
+ <Show when={diagnostics().length > 0}>
+ <div data-component="error">{diagnostics()}</div>
+ </Show>
+ <div data-component="tool-result">
+ <Switch>
+ <Match when={props.state.metadata?.error}>
+ <div data-component="error">{formatErrorString(props.state.output)}</div>
+ </Match>
+ <Match when={props.state.input?.content}>
+ <ResultsButton showCopy="Show contents" hideCopy="Hide contents">
+ <ContentCode lang={getShikiLang(filePath() || "")} code={props.state.input?.content} />
+ </ResultsButton>
+ </Match>
+ </Switch>
+ </div>
+ </>
+ )
+}
+
+export function EditTool(props: ToolProps) {
+ const filePath = createMemo(() => stripWorkingDirectory(props.state.input.filePath, props.message.path.cwd))
+ const diagnostics = createMemo(() => getDiagnostics(props.state.metadata?.diagnostics, props.state.input.filePath))
+
+ return (
+ <>
+ <div data-component="tool-title">
+ <span data-slot="name">Edit</span>
+ <span data-slot="target" title={props.state.input?.filePath}>
+ {filePath()}
+ </span>
+ </div>
+ <div data-component="tool-result">
+ <Switch>
+ <Match when={props.state.metadata?.error}>
+ <div data-component="error">{formatErrorString(props.state.metadata?.message || "")}</div>
+ </Match>
+ <Match when={props.state.metadata?.diff}>
+ <div data-component="diff">
+ <ContentDiff diff={props.state.metadata?.diff} lang={getShikiLang(filePath() || "")} />
+ </div>
+ </Match>
+ </Switch>
+ </div>
+ <Show when={diagnostics().length > 0}>
+ <div data-component="error">{diagnostics()}</div>
+ </Show>
+ </>
+ )
+}
+
+export function BashTool(props: ToolProps) {
+ return (
+ <>
+ <div data-component="terminal" data-size="sm">
+ <div data-slot="body">
+ <div data-slot="header">
+ <span>{props.state.metadata.description}</span>
+ </div>
+ <div data-slot="content">
+ <ContentCode flush lang="bash" code={props.state.input.command} />
+ <ContentCode flush lang="console" code={props.state.metadata?.stdout || ""} />
+ </div>
+ </div>
+ </div>
+ </>
+ )
+}
+
+export function GlobTool(props: ToolProps) {
+ return (
+ <>
+ <div data-component="tool-title">
+ <span data-slot="name">Glob</span>
+ <span data-slot="target">&ldquo;{props.state.input.pattern}&rdquo;</span>
+ </div>
+ <Switch>
+ <Match when={props.state.metadata?.count && props.state.metadata?.count > 0}>
+ <div data-component="tool-result">
+ <ResultsButton
+ showCopy={props.state.metadata?.count === 1 ? "1 result" : `${props.state.metadata?.count} results`}
+ >
+ <ContentText expand compact text={props.state.output} />
+ </ResultsButton>
+ </div>
+ </Match>
+ <Match when={props.state.output}>
+ <ContentText expand text={props.state.output} data-size="sm" data-color="dimmed" />
+ </Match>
+ </Switch>
+ </>
+ )
+}
+
+interface ResultsButtonProps extends ParentProps {
+ showCopy?: string
+ hideCopy?: string
+}
+function ResultsButton(props: ResultsButtonProps) {
+ const [show, setShow] = createSignal(false)
+
+ return (
+ <>
+ <button type="button" data-component="button-text" data-more onClick={() => setShow((e) => !e)}>
+ <span>{show() ? props.hideCopy || "Hide results" : props.showCopy || "Show results"}</span>
+ <span data-slot="icon">
+ <Show when={show()} fallback={<IconChevronRight width={11} height={11} />}>
+ <IconChevronDown width={11} height={11} />
+ </Show>
+ </span>
+ </button>
+ <Show when={show()}>{props.children}</Show>
+ </>
+ )
+}
+
+export function Spacer() {
+ return <div data-component="spacer"></div>
+}
+
+function Footer(props: ParentProps<{ title: string }>) {
+ return (
+ <div data-component="content-footer" title={props.title}>
+ {props.children}
+ </div>
+ )
+}
+
+export function FallbackTool(props: ToolProps) {
+ return (
+ <>
+ <div data-component="tool-title">
+ <span data-slot="name">{props.tool}</span>
+ </div>
+ <div data-component="tool-args">
+ <For each={flattenToolArgs(props.state.input)}>
+ {(arg) => (
+ <>
+ <div></div>
+ <div>{arg[0]}</div>
+ <div>{arg[1]}</div>
+ </>
+ )}
+ </For>
+ </div>
+ <Switch>
+ <Match when={props.state.output}>
+ <div data-component="tool-result">
+ <ResultsButton>
+ <ContentText expand compact text={props.state.output} data-size="sm" data-color="dimmed" />
+ </ResultsButton>
+ </div>
+ </Match>
+ </Switch>
+ </>
+ )
+}
+
+// Converts nested objects/arrays into [path, value] pairs.
+// E.g. {a:{b:{c:1}}, d:[{e:2}, 3]} => [["a.b.c",1], ["d[0].e",2], ["d[1]",3]]
+function flattenToolArgs(obj: any, prefix: string = ""): Array<[string, any]> {
+ const entries: Array<[string, any]> = []
+
+ for (const [key, value] of Object.entries(obj)) {
+ const path = prefix ? `${prefix}.${key}` : key
+
+ if (value !== null && typeof value === "object") {
+ if (Array.isArray(value)) {
+ value.forEach((item, index) => {
+ const arrayPath = `${path}[${index}]`
+ if (item !== null && typeof item === "object") {
+ entries.push(...flattenToolArgs(item, arrayPath))
+ } else {
+ entries.push([arrayPath, item])
+ }
+ })
+ } else {
+ entries.push(...flattenToolArgs(value, path))
+ }
+ } else {
+ entries.push([path, value])
+ }
+ }
+
+ return entries
+}