v2 message format and upgrade to ai sdk v5 (#743)

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Liang-Shih Lin <liangshihlin@proton.me>
Co-authored-by: Dominik Engelhardt <dominikengelhardt@ymail.com>
Co-authored-by: Jay V <air@live.ca>
Co-authored-by: adamdottv <2363879+adamdottv@users.noreply.github.com>
This commit is contained in:
Dax
2025-07-07 15:53:43 -04:00
committed by GitHub
parent 76b2e4539c
commit f884766445
116 changed files with 4707 additions and 6950 deletions

View File

@@ -1,8 +1,4 @@
import {
type JSX,
splitProps,
createResource,
} from "solid-js"
import { type JSX, splitProps, createResource } from "solid-js"
import { codeToHtml } from "shiki"
import styles from "./codeblock.module.css"
import { transformerNotationDiff } from "@shikijs/transformers"
@@ -30,7 +26,7 @@ function CodeBlock(props: CodeBlockProps) {
},
)
return <div innerHTML={html()} class={styles.codeblock} {...rest}></div >
return <div innerHTML={html()} class={styles.codeblock} {...rest}></div>
}
export default CodeBlock

View File

@@ -1,39 +0,0 @@
import { type JSX, splitProps, createResource } from "solid-js"
import { marked } from "marked"
import markedShiki from "marked-shiki"
import { codeToHtml } from "shiki"
import { transformerNotationDiff } from "@shikijs/transformers"
import styles from "./markdownview.module.css"
interface MarkdownViewProps extends JSX.HTMLAttributes<HTMLDivElement> {
markdown: string
}
const markedWithShiki = marked.use(
markedShiki({
highlight(code, lang) {
return codeToHtml(code, {
lang: lang || "text",
themes: {
light: "github-light",
dark: "github-dark",
},
transformers: [transformerNotationDiff()],
})
},
}),
)
function MarkdownView(props: MarkdownViewProps) {
const [local, rest] = splitProps(props, ["markdown"])
const [html] = createResource(
() => local.markdown,
async (markdown) => {
return markedWithShiki.parse(markdown)
},
)
return <div innerHTML={html()} class={styles["markdown-body"]} {...rest} />
}
export default MarkdownView

File diff suppressed because it is too large Load Diff

View File

@@ -8,4 +8,3 @@
}
}
}

View File

@@ -1,121 +0,0 @@
.diff {
display: flex;
flex-direction: column;
border: 1px solid var(--sl-color-divider);
background-color: var(--sl-color-bg-surface);
border-radius: 0.25rem;
}
.desktopView {
display: block;
}
.mobileView {
display: none;
}
.mobileBlock {
display: flex;
flex-direction: column;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr;
align-items: stretch;
}
.beforeColumn,
.afterColumn {
display: flex;
flex-direction: column;
overflow-x: visible;
min-width: 0;
align-items: stretch;
}
.beforeColumn {
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;
}
}
}
[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;
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;
left: 0.6ch;
user-select: none;
color: var(--sl-color-green-high);
}
}
@media (max-width: 40rem) {
.desktopView {
display: none;
}
.mobileView {
display: block;
}
}

View File

@@ -39,7 +39,12 @@ export function IconGemini(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
export function IconOpencode(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} viewBox="0 0 70 70" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 13H35V58H0V13ZM26.25 22.1957H8.75V48.701H26.25V22.1957Z" fill="currentColor" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M0 13H35V58H0V13ZM26.25 22.1957H8.75V48.701H26.25V22.1957Z"
fill="currentColor"
/>
<path d="M43.75 13H70V22.1957H52.5V48.701H70V57.8967H43.75V13Z" fill="currentColor" />
</svg>
)

File diff suppressed because it is too large Load Diff

View File

@@ -1,106 +0,0 @@
.markdown-body {
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;
}
}

View File

@@ -15,76 +15,42 @@
--lg-tool-width: 56rem;
--term-icon: url("data:image/svg+xml,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2060%2016'%20preserveAspectRatio%3D'xMidYMid%20meet'%3E%3Ccircle%20cx%3D'8'%20cy%3D'8'%20r%3D'8'%2F%3E%3Ccircle%20cx%3D'30'%20cy%3D'8'%20r%3D'8'%2F%3E%3Ccircle%20cx%3D'52'%20cy%3D'8'%20r%3D'8'%2F%3E%3C%2Fsvg%3E");
}
[data-element-button-text] {
cursor: pointer;
appearance: none;
background-color: transparent;
border: none;
padding: 0;
color: var(--sl-color-text-secondary);
&:hover {
color: var(--sl-color-text);
}
&[data-element-button-more] {
[data-component="header"] {
display: flex;
align-items: center;
gap: 0.125rem;
span[data-button-icon] {
line-height: 1;
opacity: 0.85;
svg {
display: block;
}
}
}
}
[data-element-label] {
text-transform: uppercase;
letter-spacing: -0.5px;
color: var(--sl-color-text-dimmed);
}
.header {
display: flex;
flex-direction: column;
gap: 1rem;
@media (max-width: 30rem) {
flex-direction: column;
gap: 1rem;
}
[data-section="title"] {
h1 {
font-size: 2.75rem;
font-weight: 500;
line-height: 1.2;
letter-spacing: -0.05em;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
@media (max-width: 30rem) {
font-size: 1.75rem;
line-height: 1.25;
-webkit-line-clamp: 3;
}
@media (max-width: 30rem) {
gap: 1rem;
}
}
[data-section="row"] {
[data-component="header-title"] {
font-size: 2.75rem;
font-weight: 500;
line-height: 1.2;
letter-spacing: -0.05em;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
line-clamp: 3;
overflow: hidden;
@media (max-width: 30rem) {
font-size: 1.75rem;
line-height: 1.25;
-webkit-line-clamp: 3;
}
}
[data-component="header-details"] {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
[data-section="stats"] {
[data-component="header-stats"] {
list-style-type: none;
padding: 0;
margin: 0;
@@ -92,41 +58,62 @@
gap: 0.5rem 0.875rem;
flex-wrap: wrap;
li {
[data-slot="item"] {
display: flex;
align-items: center;
gap: 0.5rem;
gap: 0.3125rem;
font-size: 0.875rem;
span[data-placeholder] {
color: var(--sl-color-text-dimmed);
}
}
[data-slot="icon"] {
flex: 0 0 auto;
color: var(--sl-color-text-dimmed);
opacity: 0.85;
svg {
display: block;
}
}
[data-slot="model"] {
color: var(--sl-color-text);
}
}
[data-section="stats"] {
li {
gap: 0.3125rem;
[data-component="header-time"] {
color: var(--sl-color-text-dimmed);
font-size: 0.875rem;
}
[data-stat-icon] {
flex: 0 0 auto;
color: var(--sl-color-text-dimmed);
[data-component="text-button"] {
cursor: pointer;
appearance: none;
background-color: transparent;
border: none;
padding: 0;
color: var(--sl-color-text-secondary);
&:hover {
color: var(--sl-color-text);
}
&[data-element-button-more] {
display: flex;
align-items: center;
gap: 0.125rem;
span[data-button-icon] {
line-height: 1;
opacity: 0.85;
svg {
display: block;
}
}
span[data-stat-model] {
color: var(--sl-color-text);
}
}
}
[data-section="time"] {
span {
color: var(--sl-color-text-dimmed);
font-size: 0.875rem;
}
}
}
@@ -170,10 +157,12 @@
svg:nth-child(3) {
display: none;
}
&:hover {
svg:nth-child(1) {
display: none;
}
svg:nth-child(2) {
display: block;
}
@@ -213,12 +202,14 @@
opacity: 1;
visibility: visible;
}
a,
a:hover {
svg:nth-child(1),
svg:nth-child(2) {
display: none;
}
svg:nth-child(3) {
display: block;
}
@@ -264,7 +255,7 @@
}
b {
color: var(--sl-color-text);
color: var(--sl-color-text);
word-break: break-all;
font-weight: 500;
}
@@ -348,8 +339,7 @@
}
[data-part-type="tool-grep"] {
&:not(:has([data-part-tool-args]))
> [data-section="content"] > [data-part-tool-body] {
&:not(:has([data-part-tool-args])) > [data-section="content"] > [data-part-tool-body] {
gap: 0.5rem;
}
}
@@ -374,6 +364,7 @@
}
}
}
[data-part-type="summary"] {
& > [data-section="decoration"] {
span:first-child {
@@ -388,15 +379,19 @@
&[data-status="connected"] {
background-color: var(--sl-color-green);
}
&[data-status="connecting"] {
background-color: var(--sl-color-orange);
}
&[data-status="disconnected"] {
background-color: var(--sl-color-divider);
}
&[data-status="reconnecting"] {
background-color: var(--sl-color-orange);
}
&[data-status="error"] {
background-color: var(--sl-color-red);
}
@@ -493,14 +488,20 @@
}
}
&[data-background="none"] { background-color: transparent; }
&[data-background="blue"] { background-color: var(--sl-color-blue-low); }
&[data-background="none"] {
background-color: transparent;
}
&[data-background="blue"] {
background-color: var(--sl-color-blue-low);
}
&[data-expanded="true"] {
pre {
display: block;
}
}
&[data-expanded="false"] {
pre {
display: -webkit-box;
@@ -536,20 +537,25 @@
span {
margin-right: 0.25rem;
&:last-child {
margin-right: 0;
}
}
span[data-color="red"] {
color: var(--sl-color-red);
}
span[data-color="dimmed"] {
color: var(--sl-color-text-dimmed);
}
span[data-marker="label"] {
text-transform: uppercase;
letter-spacing: -0.5px;
}
span[data-separator] {
margin-right: 0.375rem;
}
@@ -561,6 +567,7 @@
display: block;
}
}
&[data-expanded="false"] {
[data-section="content"] {
display: -webkit-box;
@@ -575,7 +582,6 @@
padding: 2px 0;
font-size: 0.75rem;
}
}
.message-terminal {
@@ -611,7 +617,7 @@
}
&::before {
content: '';
content: "";
position: absolute;
pointer-events: none;
top: 8px;
@@ -651,6 +657,7 @@
display: block;
}
}
&[data-expanded="false"] {
pre {
display: -webkit-box;
@@ -693,6 +700,7 @@
display: block;
}
}
&[data-expanded="false"] {
[data-element-markdown] {
display: -webkit-box;
@@ -750,10 +758,14 @@
&[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 {
border-color: var(--sl-color-orange);
}
& > span::before {
content: "";
position: absolute;
@@ -764,10 +776,14 @@
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 {
border-color: var(--sl-color-green-low);
}
& > span::before {
content: "";
position: absolute;
@@ -798,7 +814,9 @@
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease, opacity 0.5s ease;
transition:
all 0.15s ease,
opacity 0.5s ease;
z-index: 100;
appearance: none;
opacity: 1;

View File

@@ -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()
})
},
}
}

View File

@@ -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;
}
}
}

View File

@@ -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>
)
}

View File

@@ -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;
}
}
}

View File

@@ -1,7 +1,7 @@
import { type Component, createMemo } from "solid-js"
import { parsePatch } from "diff"
import CodeBlock from "./CodeBlock"
import styles from "./diffview.module.css"
import { ContentCode } from "./content-code"
import styles from "./content-diff.module.css"
type DiffRow = {
left: string
@@ -9,14 +9,12 @@ type DiffRow = {
type: "added" | "removed" | "unchanged" | "modified"
}
interface DiffViewProps {
interface Props {
diff: string
lang?: string
class?: string
}
const DiffView: Component<DiffViewProps> = (props) => {
export function ContentDiff(props: Props) {
const rows = createMemo(() => {
const diffRows: DiffRow[] = []
@@ -33,20 +31,20 @@ const DiffView: Component<DiffViewProps> = (props) => {
const content = line.slice(1)
const prefix = line[0]
if (prefix === '-') {
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] === '-') {
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] === '+') {
while (j < lines.length && lines[j][0] === "+") {
additions.push(lines[j].slice(1))
j++
}
@@ -62,39 +60,39 @@ const DiffView: Component<DiffViewProps> = (props) => {
diffRows.push({
left: removals[k],
right: additions[k],
type: "modified"
type: "modified",
})
} else if (hasLeft) {
// Pure removal
diffRows.push({
left: removals[k],
right: "",
type: "removed"
type: "removed",
})
} else if (hasRight) {
// Pure addition - only create if we actually have content
diffRows.push({
left: "",
right: additions[k],
type: "added"
type: "added",
})
}
}
i = j
} else if (prefix === '+') {
} else if (prefix === "+") {
// Standalone addition (not paired with removal)
diffRows.push({
left: "",
right: content,
type: "added"
type: "added",
})
i++
} else if (prefix === ' ') {
} else if (prefix === " ") {
diffRows.push({
left: content,
right: content,
type: "unchanged"
type: "unchanged",
})
i++
} else {
@@ -112,7 +110,7 @@ const DiffView: Component<DiffViewProps> = (props) => {
})
const mobileRows = createMemo(() => {
const mobileBlocks: { type: 'removed' | 'added' | 'unchanged', lines: string[] }[] = []
const mobileBlocks: { type: "removed" | "added" | "unchanged"; lines: string[] }[] = []
const currentRows = rows()
let i = 0
@@ -121,15 +119,15 @@ const DiffView: Component<DiffViewProps> = (props) => {
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')) {
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')) {
if (row.left && (row.type === "removed" || row.type === "modified")) {
removedLines.push(row.left)
}
if (row.right && (row.type === 'added' || row.type === 'modified')) {
if (row.right && (row.type === "added" || row.type === "modified")) {
addedLines.push(row.right)
}
i++
@@ -137,17 +135,17 @@ const DiffView: Component<DiffViewProps> = (props) => {
// Add grouped blocks
if (removedLines.length > 0) {
mobileBlocks.push({ type: 'removed', lines: removedLines })
mobileBlocks.push({ type: "removed", lines: removedLines })
}
if (addedLines.length > 0) {
mobileBlocks.push({ type: 'added', lines: addedLines })
mobileBlocks.push({ type: "added", lines: addedLines })
}
// Add unchanged rows as-is
if (i < currentRows.length && currentRows[i].type === 'unchanged') {
if (i < currentRows.length && currentRows[i].type === "unchanged") {
mobileBlocks.push({
type: 'unchanged',
lines: [currentRows[i].left]
type: "unchanged",
lines: [currentRows[i].left],
})
i++
}
@@ -157,40 +155,29 @@ const DiffView: Component<DiffViewProps> = (props) => {
})
return (
<div class={`${styles.diff} ${props.class ?? ""}`}>
<div class={styles.desktopView}>
<div class={styles.root}>
<div data-component="desktop">
{rows().map((r) => (
<div class={styles.row}>
<div class={styles.beforeColumn}>
<CodeBlock
code={r.left}
lang={props.lang}
data-section="cell"
data-diff-type={r.type === "removed" || r.type === "modified" ? "removed" : ""}
/>
<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 class={styles.afterColumn}>
<CodeBlock
code={r.right}
lang={props.lang}
data-section="cell"
data-diff-type={r.type === "added" || r.type === "modified" ? "added" : ""}
/>
<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 class={styles.mobileView}>
<div data-component="mobile">
{mobileRows().map((block) => (
<div class={styles.mobileBlock}>
<div data-component="diff-block" data-type={block.type}>
{block.lines.map((line) => (
<CodeBlock
<ContentCode
code={line}
lang={props.lang}
data-section="cell"
data-diff-type={block.type === 'removed' ? 'removed' :
block.type === 'added' ? 'added' : ''}
data-diff-type={block.type === "removed" ? "removed" : block.type === "added" ? "added" : ""}
/>
))}
</div>
@@ -200,8 +187,6 @@ const DiffView: Component<DiffViewProps> = (props) => {
)
}
export default DiffView
// const testDiff = `--- combined_before.txt 2025-06-24 16:38:08
// +++ combined_after.txt 2025-06-24 16:38:12
// @@ -1,21 +1,25 @@
@@ -210,12 +195,12 @@ export default DiffView
// -old content
// +added line
// +new content
//
//
// -removed empty line below
// +added empty line above
//
//
// - tab indented
// -trailing spaces
// -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
@@ -226,14 +211,14 @@ export default DiffView
// +different unicode: 🎉 💻 日本語
// +normalized content with consistent spacing
// +newline to content
//
//
// -content to remove
// -whitespace only:
// -whitespace only:
// -multiple
// -consecutive
// -deletions
// -single deletion
// +
// +
// +single addition
// +first addition
// +second addition

View File

@@ -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;
}
}
}

View File

@@ -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
}

View File

@@ -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);
}
}

View File

@@ -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>
)
}

View File

@@ -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);
}
}
}

View File

@@ -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
}

View File

@@ -39,12 +39,12 @@ opencode run Explain the use of context in Go
#### Flags
| Flag | Short | Description |
| ----------------- | ----- | --------------------- |
| `--continue` | `-c` | Continue the last session |
| `--session` | `-s` | Session ID to continue |
| `--share` | | Share the session |
| `--model` | `-m` | Model to use in the form of provider/model |
| Flag | Short | Description |
| ------------ | ----- | ------------------------------------------ |
| `--continue` | `-c` | Continue the last session |
| `--session` | `-s` | Session ID to continue |
| `--share` | | Share the session |
| `--model` | `-m` | Model to use in the form of provider/model |
---
@@ -122,8 +122,8 @@ opencode upgrade v0.1.48
The opencode CLI takes the following flags.
| Flag | Short | Description |
| ----------------- | ----- | --------------------- |
| `--help` | `-h` | Display help |
| `--version` | | Print version number |
| `--print-logs` | | Print logs to stderr |
| Flag | Short | Description |
| -------------- | ----- | -------------------- |
| `--help` | `-h` | Display help |
| `--version` | | Print version number |
| `--print-logs` | | Print logs to stderr |

View File

@@ -39,7 +39,7 @@ You can configure the providers and models you want to use in your opencode conf
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"provider": { },
"provider": {},
"model": ""
}
```
@@ -70,7 +70,7 @@ You can customize your keybinds through the `keybinds` option.
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"keybinds": { }
"keybinds": {}
}
```
@@ -85,7 +85,7 @@ You can configure MCP servers you want to use through the `mcp` option.
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"mcp": { }
"mcp": {}
}
```
@@ -105,6 +105,7 @@ You can disable providers that are loaded automatically through the `disabled_pr
```
The `disabled_providers` option accepts an array of provider IDs. When a provider is disabled:
- It won't be loaded even if environment variables are set
- It won't be loaded even if API keys are configured through `opencode auth login`
- The provider's models won't appear in the model selection list

View File

@@ -3,7 +3,7 @@ title: Intro
description: Get started with opencode.
---
import { Tabs, TabItem } from '@astrojs/starlight/components';
import { Tabs, TabItem } from "@astrojs/starlight/components"
[**opencode**](/) is an AI coding agent built for the terminal. It features:
@@ -21,26 +21,10 @@ import { Tabs, TabItem } from '@astrojs/starlight/components';
## Install
<Tabs>
<TabItem label="npm">
```bash
npm install -g opencode-ai
```
</TabItem>
<TabItem label="Bun">
```bash
bun install -g opencode-ai
```
</TabItem>
<TabItem label="pnpm">
```bash
pnpm install -g opencode-ai
```
</TabItem>
<TabItem label="Yarn">
```bash
yarn global add opencode-ai
```
</TabItem>
<TabItem label="npm">```bash npm install -g opencode-ai ```</TabItem>
<TabItem label="Bun">```bash bun install -g opencode-ai ```</TabItem>
<TabItem label="pnpm">```bash pnpm install -g opencode-ai ```</TabItem>
<TabItem label="Yarn">```bash yarn global add opencode-ai ```</TabItem>
</Tabs>
You can also install the opencode binary through the following.

View File

@@ -31,17 +31,20 @@ You can also just create this file manually. Here's an example of some things yo
This is an SST v3 monorepo with TypeScript. The project uses bun workspaces for package management.
## Project Structure
- `packages/` - Contains all workspace packages (functions, core, web, etc.)
- `infra/` - Infrastructure definitions split by service (storage.ts, api.ts, web.ts)
- `sst.config.ts` - Main SST configuration with dynamic imports
## Code Standards
- Use TypeScript with strict mode enabled
- Shared code goes in `packages/core/` with proper exports configuration
- Functions go in `packages/functions/`
- Infrastructure should be split into logical files in `infra/`
## Monorepo Conventions
- Import shared modules using workspace names: `@my-app/core/example`
```

View File

@@ -13,18 +13,18 @@ By default, opencode uses our own `opencode` theme.
opencode comes with several built-in themes.
| Name | Description |
| --- | --- |
| `system` | Adapts to your terminal's background color |
| `tokyonight` | Based on the Tokyonight theme |
| `everforest` | Based on the Everforest theme |
| `ayu` | Based on the Ayu dark theme |
| `catppuccin` | Based on the Catppuccin theme |
| `gruvbox` | Based on the Gruvbox theme |
| `kanagawa` | Based on the Kanagawa theme |
| `nord` | Based on the Nord theme |
| `matrix` | Hacker-style green on black theme |
| `one-dark` | Based on the Atom One Dark theme |
| Name | Description |
| ------------ | ------------------------------------------ |
| `system` | Adapts to your terminal's background color |
| `tokyonight` | Based on the Tokyonight theme |
| `everforest` | Based on the Everforest theme |
| `ayu` | Based on the Ayu dark theme |
| `catppuccin` | Based on the Catppuccin theme |
| `gruvbox` | Based on the Gruvbox theme |
| `kanagawa` | Based on the Kanagawa theme |
| `nord` | Based on the Nord theme |
| `matrix` | Hacker-style green on black theme |
| `one-dark` | Based on the Atom One Dark theme |
And more, we are constantly adding new themes.
@@ -61,7 +61,7 @@ You can select a theme by bringing up the theme select with the `/theme` command
## Custom themes
opencode supports a flexible JSON-based theme system that allows users to create and customize themes easily.
opencode supports a flexible JSON-based theme system that allows users to create and customize themes easily.
---

View File

@@ -2,9 +2,9 @@ declare module "lang-map" {
/** Returned by calling `map()` */
export interface MapReturn {
/** All extensions keyed by language name */
extensions: Record<string, string[]>;
extensions: Record<string, string[]>
/** All languages keyed by file-extension */
languages: Record<string, string[]>;
languages: Record<string, string[]>
}
/**
@@ -14,14 +14,14 @@ declare module "lang-map" {
* const { extensions, languages } = map();
* ```
*/
function map(): MapReturn;
function map(): MapReturn
/** Static method: get extensions for a given language */
namespace map {
function extensions(language: string): string[];
function extensions(language: string): string[]
/** Static method: get languages for a given extension */
function languages(extension: string): string[];
function languages(extension: string): string[]
}
export = map;
export = map
}