Compare commits

...

6 Commits

Author SHA1 Message Date
David Hill
f77a24ad87 feat(ui): use check icon for success cards 2026-03-09 22:30:30 +00:00
David Hill
0941a40953 refactor(ui): derive Card accent from theme icon tokens 2026-03-09 22:29:11 +00:00
David Hill
eaaf2c64db docs(ui): add ToolErrorCard story 2026-03-09 22:29:05 +00:00
David Hill
269b6da131 feat(ui): render tool failures with collapsible ToolErrorCard 2026-03-09 22:14:33 +00:00
David Hill
16f50213a7 feat(ui): restyle Card and add title/description slots 2026-03-09 22:14:21 +00:00
David Hill
bdc423d622 tui: fix ordered list indentation in markdown rendering 2026-03-09 16:15:48 +00:00
10 changed files with 419 additions and 87 deletions

View File

@@ -2,28 +2,89 @@
width: 100%;
display: flex;
flex-direction: column;
background-color: var(--surface-inset-base);
border: 1px solid var(--border-weaker-base);
transition: background-color 0.15s ease;
position: relative;
background: transparent;
border: none;
border-radius: var(--radius-md);
padding: 6px 12px;
overflow: clip;
padding: 10px 12px 10px 10px;
&[data-variant="error"] {
background-color: var(--surface-critical-weak);
border: 1px solid var(--border-critical-base);
color: rgba(218, 51, 25, 0.6);
&:has([data-slot="card-title"]) {
gap: 8px;
}
/* text-12-regular */
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-style: normal;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large); /* 166.667% */
letter-spacing: var(--letter-spacing-normal);
/* text-14-regular */
font-family: var(--font-family-sans);
font-size: var(--font-size-base);
font-style: normal;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-strong);
&[data-component="icon"] {
color: var(--icon-critical-active);
}
--card-gap: 8px;
--card-icon: 16px;
--card-indent: calc(var(--card-icon) + var(--card-gap));
--card-accent: var(--icon-active);
&[data-icon="none"] {
--card-indent: 0px;
}
&::before {
content: "";
position: absolute;
left: 0;
top: 8px;
bottom: 8px;
width: 2px;
border-radius: 2px;
background-color: var(--card-accent);
}
:where([data-card="title"], [data-slot="card-title"]) {
color: var(--text-strong);
font-weight: var(--font-weight-medium);
}
:where([data-slot="card-title"]) {
display: flex;
align-items: center;
gap: var(--card-gap);
}
:where([data-slot="card-title"]) [data-component="icon"] {
color: var(--card-accent);
}
:where([data-slot="card-title-icon"]) {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--card-icon);
height: var(--card-icon);
flex: 0 0 auto;
}
:where([data-slot="card-title-icon"][data-placeholder]) [data-component="icon"] {
color: var(--text-weak);
}
:where([data-slot="card-title-icon"])
[data-slot="icon-svg"]
:is(path, line, polyline, polygon, rect, circle, ellipse)[stroke] {
stroke-width: 1.5px !important;
}
:where([data-card="description"], [data-slot="card-description"]) {
color: var(--text-base);
padding-left: var(--card-indent);
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
}
:where([data-card="actions"], [data-slot="card-actions"]) {
padding-left: var(--card-indent);
}
}

View File

@@ -1,5 +1,5 @@
// @ts-nocheck
import { Card } from "./card"
import { Card, CardActions, CardDescription, CardTitle } from "./card"
import { Button } from "./button"
const docs = `### Overview
@@ -49,15 +49,13 @@ export default {
render: (props: { variant?: "normal" | "error" | "warning" | "success" | "info" }) => {
return (
<Card variant={props.variant}>
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 500 }}>Card title</div>
<div style={{ color: "var(--text-weak)", fontSize: "13px" }}>Small supporting text.</div>
</div>
<Button size="small" variant="ghost">
<CardTitle>Card title</CardTitle>
<CardDescription>Small supporting text.</CardDescription>
<CardActions>
<Button size="small" variant="secondary">
Action
</Button>
</div>
</CardActions>
</Card>
)
},

View File

@@ -1,22 +1,135 @@
import { type ComponentProps, splitProps } from "solid-js"
import { createContext, type ComponentProps, splitProps, useContext } from "solid-js"
import { Icon, type IconProps } from "./icon"
export interface CardProps extends ComponentProps<"div"> {
variant?: "normal" | "error" | "warning" | "success" | "info"
/**
* Optional card icon used by `CardTitle`.
*
* - `undefined`: picks a default icon based on `variant` (error/warning/info)
* - `false`/`null`: disables the icon
* - `Icon` name: forces a specific icon
*/
icon?: IconProps["name"] | false | null
}
type Ctx = {
variant: NonNullable<CardProps["variant"]>
mode: "none" | "set" | "placeholder"
icon?: IconProps["name"]
}
const Ctx = createContext<Ctx>()
function pick(variant: NonNullable<CardProps["variant"]>) {
if (variant === "error") return "circle-ban-sign" as const
if (variant === "warning") return "warning" as const
if (variant === "success") return "circle-check" as const
if (variant === "info") return "help" as const
return
}
function mix(style: ComponentProps<"div">["style"], value?: string) {
if (!value) return style
if (!style) return { "--card-accent": value }
if (typeof style === "string") return `${style};--card-accent:${value};`
return { ...(style as Record<string, string | number>), "--card-accent": value }
}
export function Card(props: CardProps) {
const [split, rest] = splitProps(props, ["variant", "class", "classList"])
const [split, rest] = splitProps(props, ["variant", "icon", "style", "class", "classList"])
const variant = () => split.variant || "normal"
const accent = () => {
const v = variant()
if (v === "error") return "var(--icon-critical-base)"
if (v === "warning") return "var(--icon-warning-active)"
if (v === "success") return "var(--icon-success-active)"
if (v === "info") return "var(--icon-info-active)"
return
}
const mode = () => {
if (split.icon === false || split.icon === null) return "none" as const
if (typeof split.icon === "string") return "set" as const
return pick(variant()) ? ("set" as const) : ("placeholder" as const)
}
const icon = () => {
if (split.icon === false || split.icon === null) return
if (typeof split.icon === "string") return split.icon
return pick(variant())
}
return (
<Ctx.Provider value={{ variant: variant(), mode: mode(), icon: icon() }}>
<div
{...rest}
data-component="card"
data-variant={variant()}
data-icon={mode()}
style={mix(split.style, accent())}
classList={{
...(split.classList ?? {}),
[split.class ?? ""]: !!split.class,
}}
>
{props.children}
</div>
</Ctx.Provider>
)
}
export function CardTitle(props: ComponentProps<"div">) {
const [split, rest] = splitProps(props, ["class", "classList", "children"])
const ctx = useContext(Ctx)
const show = () => ctx?.mode !== "none"
const name = () => ctx?.icon ?? ("dash" as const)
const placeholder = () => !ctx?.icon
return (
<div
{...rest}
data-component="card"
data-variant={split.variant || "normal"}
data-slot="card-title"
classList={{
...(split.classList ?? {}),
[split.class ?? ""]: !!split.class,
}}
>
{props.children}
{show() ? (
<span data-slot="card-title-icon" data-placeholder={placeholder() || undefined}>
<Icon name={name()} size="small" />
</span>
) : null}
{split.children}
</div>
)
}
export function CardDescription(props: ComponentProps<"div">) {
const [split, rest] = splitProps(props, ["class", "classList", "children"])
return (
<div
{...rest}
data-slot="card-description"
classList={{
...(split.classList ?? {}),
[split.class ?? ""]: !!split.class,
}}
>
{split.children}
</div>
)
}
export function CardActions(props: ComponentProps<"div">) {
const [split, rest] = splitProps(props, ["class", "classList", "children"])
return (
<div
{...rest}
data-slot="card-actions"
classList={{
...(split.classList ?? {}),
[split.class ?? ""]: !!split.class,
}}
>
{split.children}
</div>
)
}

View File

@@ -60,6 +60,7 @@
ol {
margin-top: 0.5rem;
margin-bottom: 1rem;
margin-left: 0;
padding-left: 1.5rem;
list-style-position: outside;
}
@@ -70,6 +71,7 @@
ol {
list-style-type: decimal;
padding-left: 2.25rem;
}
li {
@@ -98,6 +100,10 @@
padding-left: 1rem; /* Minimal indent for nesting only */
}
li > ol {
padding-left: 1.75rem;
}
/* Blockquotes */
blockquote {
border-left: 2px solid var(--border-weak-base);

View File

@@ -305,41 +305,6 @@
}
}
[data-component="tool-error"] {
display: flex;
align-items: start;
gap: 8px;
[data-slot="icon-svg"] {
color: var(--icon-critical-base);
margin-top: 4px;
}
[data-slot="message-part-tool-error-content"] {
display: flex;
align-items: start;
gap: 8px;
}
[data-slot="message-part-tool-error-title"] {
font-family: var(--font-family-sans);
font-size: var(--font-size-base);
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-on-critical-base);
white-space: nowrap;
}
[data-slot="message-part-tool-error-message"] {
color: var(--text-on-critical-weak);
max-height: 240px;
overflow-y: auto;
word-break: break-word;
}
}
[data-component="tool-output"] {
white-space: pre;
padding: 0;
@@ -713,7 +678,6 @@
[data-component="user-message"] [data-slot="user-message-text"],
[data-component="text-part"],
[data-component="reasoning-part"],
[data-component="tool-error"],
[data-component="tool-output"],
[data-component="bash-output"],
[data-component="edit-content"],

View File

@@ -39,6 +39,7 @@ import { Card } from "./card"
import { Collapsible } from "./collapsible"
import { FileIcon } from "./file-icon"
import { Icon } from "./icon"
import { ToolErrorCard } from "./tool-error-card"
import { Checkbox } from "./checkbox"
import { DiffChanges } from "./diff-changes"
import { Markdown } from "./markdown"
@@ -1189,25 +1190,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
</div>
)
}
const [title, ...rest] = cleaned.split(": ")
return (
<Card variant="error">
<div data-component="tool-error">
<Icon name="circle-ban-sign" size="small" />
<Switch>
<Match when={title && title.length < 30}>
<div data-slot="message-part-tool-error-content">
<div data-slot="message-part-tool-error-title">{title}</div>
<span data-slot="message-part-tool-error-message">{rest.join(": ")}</span>
</div>
</Match>
<Match when={true}>
<span data-slot="message-part-tool-error-message">{cleaned}</span>
</Match>
</Switch>
</div>
</Card>
)
return <ToolErrorCard tool={part().tool} error={error()} />
}}
</Match>
<Match when={true}>

View File

@@ -0,0 +1,26 @@
[data-component="card"][data-kind="tool-error-card"] {
padding: 0;
&::before {
content: none;
}
[data-component="tool-error-card-icon"] [data-component="icon"] {
color: var(--card-accent);
}
&[data-open="true"] [data-slot="tool-error-card-content"] {
position: relative;
}
&[data-open="true"] [data-slot="tool-error-card-content"]::before {
content: "";
position: absolute;
left: calc((var(--card-icon) / 2) - 1px);
top: calc(var(--card-gap) * -1);
bottom: 0;
width: 2px;
border-radius: 2px;
background-color: var(--card-accent);
}
}

View File

@@ -0,0 +1,96 @@
// @ts-nocheck
import { ToolErrorCard } from "./tool-error-card"
const docs = `### Overview
Tool call failure summary styled like a tool trigger.
### API
- Required: \`tool\` (tool id, e.g. apply_patch, bash)
- Required: \`error\` (error string)
### Behavior
- Collapsible; click header to expand/collapse.
`
const samples = [
{
tool: "apply_patch",
error:
"apply_patch verification failed: Failed to find expected lines in /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/session-turn.tsx",
},
{
tool: "bash",
error: "bash Command failed: exit code 1: bun test --watch",
},
{
tool: "read",
error:
"read File not found: /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/does-not-exist.tsx",
},
{
tool: "glob",
error: "glob Pattern error: Invalid glob pattern: **/*[",
},
{
tool: "grep",
error: "grep Regex error: Invalid regular expression: (unterminated group",
},
{
tool: "webfetch",
error: "webfetch Request failed: 502 Bad Gateway",
},
{
tool: "websearch",
error: "websearch Rate limited: Please try again in 30 seconds",
},
{
tool: "codesearch",
error: "codesearch Timeout: exceeded 120s",
},
{
tool: "question",
error: "question Dismissed: user dismissed this question",
},
]
export default {
title: "UI/ToolErrorCard",
id: "components-tool-error-card",
component: ToolErrorCard,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: docs,
},
},
},
args: {
tool: "apply_patch",
error: samples[0].error,
},
argTypes: {
tool: {
control: "select",
options: ["apply_patch", "bash", "read", "glob", "grep", "webfetch", "websearch", "codesearch", "question"],
},
error: {
control: "text",
},
},
render: (props: { tool: string; error: string }) => {
return <ToolErrorCard tool={props.tool} error={props.error} />
},
}
export const All = {
render: () => {
return (
<div style="display: flex; flex-direction: column; gap: 12px; max-width: 720px;">
{samples.map((item) => (
<ToolErrorCard tool={item.tool} error={item.error} />
))}
</div>
)
},
}

View File

@@ -0,0 +1,84 @@
import { type ComponentProps, createMemo, createSignal, Show, splitProps } from "solid-js"
import { Card, CardDescription } from "./card"
import { Collapsible } from "./collapsible"
import { Icon } from "./icon"
import { useI18n } from "../context/i18n"
export interface ToolErrorCardProps extends Omit<ComponentProps<typeof Card>, "children" | "variant" | "icon"> {
tool: string
error: string
}
export function ToolErrorCard(props: ToolErrorCardProps) {
const i18n = useI18n()
const [open, setOpen] = createSignal(true)
const [split, rest] = splitProps(props, ["tool", "error"])
const name = createMemo(() => {
const map: Record<string, string> = {
read: "ui.tool.read",
list: "ui.tool.list",
glob: "ui.tool.glob",
grep: "ui.tool.grep",
webfetch: "ui.tool.webfetch",
websearch: "ui.tool.websearch",
codesearch: "ui.tool.codesearch",
bash: "ui.tool.shell",
apply_patch: "ui.tool.patch",
question: "ui.tool.questions",
}
const key = map[split.tool]
if (!key) return split.tool
return i18n.t(key)
})
const cleaned = createMemo(() => split.error.replace(/^Error:\s*/, "").trim())
const tail = createMemo(() => {
const value = cleaned()
const prefix = `${split.tool} `
if (value.startsWith(prefix)) return value.slice(prefix.length)
return value
})
const subtitle = createMemo(() => {
const parts = tail().split(": ")
if (parts.length <= 1) return "Failed"
const head = (parts[0] ?? "").trim()
if (!head) return "Failed"
return head[0] ? head[0].toUpperCase() + head.slice(1) : "Failed"
})
const body = createMemo(() => {
const parts = tail().split(": ")
if (parts.length <= 1) return cleaned()
return parts.slice(1).join(": ").trim() || cleaned()
})
return (
<Card {...rest} data-kind="tool-error-card" data-open={open() ? "true" : "false"} variant="error">
<Collapsible class="tool-collapsible" open={open()} onOpenChange={setOpen}>
<Collapsible.Trigger>
<div data-component="tool-trigger">
<div data-slot="basic-tool-tool-trigger-content">
<span data-slot="basic-tool-tool-indicator" data-component="tool-error-card-icon">
<Icon name="circle-ban-sign" size="small" />
</span>
<div data-slot="basic-tool-tool-info">
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title">{name()}</span>
<span data-slot="basic-tool-tool-subtitle">{subtitle()}</span>
</div>
</div>
</div>
</div>
<Collapsible.Arrow />
</div>
</Collapsible.Trigger>
<Collapsible.Content>
<div data-slot="tool-error-card-content">
<Show when={body()}>{(value) => <CardDescription>{value()}</CardDescription>}</Show>
</div>
</Collapsible.Content>
</Collapsible>
</Card>
)
}

View File

@@ -13,6 +13,7 @@
@import "../components/basic-tool.css" layer(components);
@import "../components/button.css" layer(components);
@import "../components/card.css" layer(components);
@import "../components/tool-error-card.css" layer(components);
@import "../components/checkbox.css" layer(components);
@import "../components/file.css" layer(components);
@import "../components/collapsible.css" layer(components);