Compare commits

..

7 Commits

Author SHA1 Message Date
Dax Raad 00ea5082e7 add typescript lsp timeout if it fails to start 2025-07-08 18:33:12 -04:00
Dax Raad 4a878b88c0 properly load typescript lsp in subpaths 2025-07-08 18:18:45 -04:00
Dax Raad 6de955847c big rework of LSP system 2025-07-08 18:14:49 -04:00
Jay V 3ba5d528b4 docs: share bugs 2025-07-08 18:14:36 -04:00
Jay V f99e2b3429 docs: share error part 2025-07-08 18:00:08 -04:00
Jay V 7e4e6f6e51 docs: share page bugs 2025-07-08 17:18:38 -04:00
Jay V 0514f3f43b docs: share image model 2025-07-08 17:18:38 -04:00
16 changed files with 353 additions and 202 deletions
@@ -25,7 +25,6 @@ export const SymbolsCommand = cmd({
builder: (yargs) => yargs.positional("query", { type: "string", demandOption: true }),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
await LSP.touchFile("./src/index.ts", true)
using _ = Log.Default.time("symbols")
const results = await LSP.workspaceSymbol(args.query)
console.log(JSON.stringify(results, null, 2))
@@ -45,7 +45,7 @@ const FilesCommand = cmd({
const files = await Ripgrep.files({
cwd: app.path.cwd,
query: args.query,
glob: args.glob,
glob: args.glob ? [args.glob] : undefined,
limit: args.limit,
})
console.log(files.join("\n"))
+9 -4
View File
@@ -185,10 +185,15 @@ export namespace Ripgrep {
return filepath
}
export async function files(input: { cwd: string; query?: string; glob?: string; limit?: number }) {
const commands = [
`${$.escape(await filepath())} --files --follow --hidden --glob='!.git/*' ${input.glob ? `--glob='${input.glob}'` : ``}`,
]
export async function files(input: { cwd: string; query?: string; glob?: string[]; limit?: number }) {
const commands = [`${$.escape(await filepath())} --files --follow --hidden --glob='!.git/*'`]
if (input.glob) {
for (const g of input.glob) {
commands[0] += ` --glob='${g}'`
}
}
if (input.query) commands.push(`${await Fzf.filepath()} --filter=${input.query}`)
if (input.limit) commands.push(`head -n ${input.limit}`)
const joined = commands.join(" | ")
+32 -20
View File
@@ -34,46 +34,54 @@ export namespace LSPClient {
),
}
export async function create(serverID: string, server: LSPServer.Handle) {
export async function create(input: { serverID: string; server: LSPServer.Handle; root: string }) {
const app = App.info()
log.info("starting client", { id: serverID })
const l = log.clone().tag("serverID", input.serverID)
l.info("starting client")
const connection = createMessageConnection(
new StreamMessageReader(server.process.stdout),
new StreamMessageWriter(server.process.stdin),
new StreamMessageReader(input.server.process.stdout),
new StreamMessageWriter(input.server.process.stdin),
)
const diagnostics = new Map<string, Diagnostic[]>()
connection.onNotification("textDocument/publishDiagnostics", (params) => {
const path = new URL(params.uri).pathname
log.info("textDocument/publishDiagnostics", {
l.info("textDocument/publishDiagnostics", {
path,
})
const exists = diagnostics.has(path)
diagnostics.set(path, params.diagnostics)
if (!exists && serverID === "typescript") return
Bus.publish(Event.Diagnostics, { path, serverID })
if (!exists && input.serverID === "typescript") return
Bus.publish(Event.Diagnostics, { path, serverID: input.serverID })
})
connection.onRequest("window/workDoneProgress/create", (params) => {
l.info("window/workDoneProgress/create", params)
return null
})
connection.onRequest("workspace/configuration", async () => {
return [{}]
})
connection.listen()
log.info("sending initialize", { id: serverID })
l.info("sending initialize")
await withTimeout(
connection.sendRequest("initialize", {
rootUri: "file://" + app.path.cwd,
processId: server.process.pid,
rootUri: "file://" + input.root,
processId: input.server.process.pid,
workspaceFolders: [
{
name: "workspace",
uri: "file://" + app.path.cwd,
uri: "file://" + input.root,
},
],
initializationOptions: {
...server.initialization,
...input.server.initialization,
},
capabilities: {
window: {
workDoneProgress: true,
},
workspace: {
configuration: true,
},
@@ -90,9 +98,9 @@ export namespace LSPClient {
}),
5_000,
).catch((err) => {
log.error("initialize error", { error: err })
l.error("initialize error", { error: err })
throw new InitializeError(
{ serverID },
{ serverID: input.serverID },
{
cause: err,
},
@@ -100,17 +108,15 @@ export namespace LSPClient {
})
await connection.sendNotification("initialized", {})
log.info("initialized", {
serverID,
})
const files: {
[path: string]: number
} = {}
const result = {
root: input.root,
get serverID() {
return serverID
return input.serverID
},
get connection() {
return connection
@@ -170,13 +176,19 @@ export namespace LSPClient {
})
},
async shutdown() {
log.info("shutting down", { serverID })
l.info("shutting down")
connection.end()
connection.dispose()
log.info("shutdown", { serverID })
input.server.process.kill()
l.info("shutdown")
},
}
if (input.server.onInitialized) {
await input.server.onInitialized(result)
}
l.info("initialized")
return result
}
}
+29 -16
View File
@@ -3,8 +3,8 @@ import { Log } from "../util/log"
import { LSPClient } from "./client"
import path from "path"
import { LSPServer } from "./server"
import { Ripgrep } from "../file/ripgrep"
import { z } from "zod"
import { Filesystem } from "../util/filesystem"
export namespace LSP {
const log = Log.create({ service: "lsp" })
@@ -36,29 +36,39 @@ export namespace LSP {
"lsp",
async (app) => {
log.info("initializing")
const clients = new Map<string, LSPClient.Info>()
const clients: LSPClient.Info[] = []
for (const server of Object.values(LSPServer)) {
for (const extension of server.extensions) {
const [file] = await Ripgrep.files({
cwd: app.path.cwd,
glob: "*" + extension,
const roots = await server.roots(app)
for (const root of roots) {
if (!Filesystem.overlaps(app.path.cwd, root)) continue
log.info("", {
root,
serverID: server.id,
})
if (!file) continue
const handle = await server.spawn(App.info())
const handle = await server.spawn(App.info(), root)
if (!handle) break
const client = await LSPClient.create(server.id, handle).catch((err) => log.error("", { error: err }))
const client = await LSPClient.create({
serverID: server.id,
server: handle,
root,
}).catch((err) => {
handle.process.kill()
log.error("", { error: err })
})
if (!client) break
clients.set(server.id, client)
break
clients.push(client)
}
}
log.info("initialized")
return {
clients,
}
},
async (state) => {
for (const client of state.clients.values()) {
for (const client of state.clients) {
await client.shutdown()
}
},
@@ -109,14 +119,17 @@ export namespace LSP {
export async function workspaceSymbol(query: string) {
return run((client) =>
client.connection.sendRequest("workspace/symbol", {
query,
}),
client.connection
.sendRequest("workspace/symbol", {
query,
})
.then((result: any) => result.slice(0, 10))
.catch(() => []),
).then((result) => result.flat() as LSP.Symbol[])
}
async function run<T>(input: (client: LSPClient.Info) => Promise<T>): Promise<T[]> {
const clients = await state().then((x) => [...x.clients.values()])
const clients = await state().then((x) => x.clients)
const tasks = clients.map((x) => input(x))
return Promise.all(tasks)
}
+67 -111
View File
@@ -6,6 +6,10 @@ import { Log } from "../util/log"
import { BunProc } from "../bun"
import { $ } from "bun"
import fs from "fs/promises"
import { unique } from "remeda"
import { Ripgrep } from "../file/ripgrep"
import type { LSPClient } from "./client"
import { withTimeout } from "../util/timeout"
export namespace LSPServer {
const log = Log.create({ service: "lsp.server" })
@@ -13,21 +17,40 @@ export namespace LSPServer {
export interface Handle {
process: ChildProcessWithoutNullStreams
initialization?: Record<string, any>
onInitialized?: (lsp: LSPClient.Info) => Promise<void>
}
type RootsFunction = (app: App.Info) => Promise<string[]>
const SimpleRoots = (patterns: string[]): RootsFunction => {
return async (app) => {
const glob = `**/*/{${patterns.join(",")}}`
const files = await Ripgrep.files({
glob: [glob],
cwd: app.path.root,
})
const dirs = files.map((file) => path.dirname(file))
return unique(dirs).map((dir) => path.join(app.path.root, dir))
}
}
export interface Info {
id: string
extensions: string[]
spawn(app: App.Info): Promise<Handle | undefined>
global?: boolean
roots: (app: App.Info) => Promise<string[]>
spawn(app: App.Info, root: string): Promise<Handle | undefined>
}
export const Typescript: Info = {
id: "typescript",
roots: SimpleRoots(["tsconfig.json", "jsconfig.json", "package.json"]),
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
async spawn(app) {
async spawn(app, root) {
const tsserver = await Bun.resolve("typescript/lib/tsserver.js", app.path.cwd).catch(() => {})
if (!tsserver) return
const proc = spawn(BunProc.which(), ["x", "typescript-language-server", "--stdio"], {
cwd: root,
env: {
...process.env,
BUN_BE_BUN: "1",
@@ -40,14 +63,32 @@ export namespace LSPServer {
path: tsserver,
},
},
// tsserver sucks and won't start processing codebase until you open a file
onInitialized: async (lsp) => {
const [hint] = await Ripgrep.files({
cwd: lsp.root,
glob: ["*.ts", "*.tsx", "*.js", "*.jsx", "*.mjs", "*.cjs", "*.mts", "*.cts"],
limit: 1,
})
const wait = new Promise<void>(async (resolve) => {
const notif = lsp.connection.onNotification("$/progress", (params) => {
if (params.value.kind !== "end") return
notif.dispose()
resolve()
})
await lsp.notify.open({ path: path.join(lsp.root, hint) })
})
await withTimeout(wait, 5_000)
},
}
},
}
export const Gopls: Info = {
id: "golang",
roots: SimpleRoots(["go.mod", "go.sum"]),
extensions: [".go"],
async spawn() {
async spawn(_, root) {
let bin = Bun.which("gopls", {
PATH: process.env["PATH"] + ":" + Global.Path.bin,
})
@@ -72,15 +113,18 @@ export namespace LSPServer {
})
}
return {
process: spawn(bin!),
process: spawn(bin!, {
cwd: root,
}),
}
},
}
export const RubyLsp: Info = {
id: "ruby-lsp",
roots: SimpleRoots(["Gemfile"]),
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async spawn() {
async spawn(_, root) {
let bin = Bun.which("ruby-lsp", {
PATH: process.env["PATH"] + ":" + Global.Path.bin,
})
@@ -109,7 +153,9 @@ export namespace LSPServer {
})
}
return {
process: spawn(bin!, ["--stdio"]),
process: spawn(bin!, ["--stdio"], {
cwd: root,
}),
}
},
}
@@ -117,8 +163,17 @@ export namespace LSPServer {
export const Pyright: Info = {
id: "pyright",
extensions: [".py", ".pyi"],
async spawn() {
roots: SimpleRoots([
"pyproject.toml",
"setup.py",
"setup.cfg",
"requirements.txt",
"Pipfile",
"pyrightconfig.json",
]),
async spawn(_, root) {
const proc = spawn(BunProc.which(), ["x", "pyright-langserver", "--stdio"], {
cwd: root,
env: {
...process.env,
BUN_BE_BUN: "1",
@@ -133,7 +188,8 @@ export namespace LSPServer {
export const ElixirLS: Info = {
id: "elixir-ls",
extensions: [".ex", ".exs"],
async spawn() {
roots: SimpleRoots(["mix.exs", "mix.lock"]),
async spawn(_, root) {
let binary = Bun.which("elixir-ls")
if (!binary) {
const elixirLsPath = path.join(Global.Path.bin, "elixir-ls")
@@ -177,109 +233,9 @@ export namespace LSPServer {
}
return {
process: spawn(binary),
}
},
}
export const Zls: Info = {
id: "zls",
extensions: [".zig", ".zon"],
async spawn() {
let bin = Bun.which("zls", {
PATH: process.env["PATH"] + ":" + Global.Path.bin,
})
if (!bin) {
const zig = Bun.which("zig")
if (!zig) {
log.error("Zig is required to use zls. Please install Zig first.")
return
}
log.info("downloading zls from GitHub releases")
const releaseResponse = await fetch("https://api.github.com/repos/zigtools/zls/releases/latest")
if (!releaseResponse.ok) {
log.error("Failed to fetch zls release info")
return
}
const release = await releaseResponse.json()
const platform = process.platform
const arch = process.arch
let assetName = ""
let zlsArch: string = arch
if (arch === "arm64") zlsArch = "aarch64"
else if (arch === "x64") zlsArch = "x86_64"
else if (arch === "ia32") zlsArch = "x86"
let zlsPlatform: string = platform
if (platform === "darwin") zlsPlatform = "macos"
else if (platform === "win32") zlsPlatform = "windows"
const ext = platform === "win32" ? "zip" : "tar.xz"
assetName = `zls-${zlsArch}-${zlsPlatform}.${ext}`
const supportedCombos = [
"zls-x86_64-linux.tar.xz",
"zls-x86_64-macos.tar.xz",
"zls-x86_64-windows.zip",
"zls-aarch64-linux.tar.xz",
"zls-aarch64-macos.tar.xz",
"zls-aarch64-windows.zip",
"zls-x86-linux.tar.xz",
"zls-x86-windows.zip",
]
if (!supportedCombos.includes(assetName)) {
log.error("Unsupported platform/architecture for zls", { platform, arch, assetName })
return
}
const asset = release.assets?.find((a: any) => a.name === assetName)
if (!asset) {
log.error("Could not find zls download for platform", { platform, arch, assetName })
return
}
const downloadUrl = asset.browser_download_url
log.info("downloading zls", { url: downloadUrl })
const response = await fetch(downloadUrl)
if (!response.ok) {
log.error("Failed to download zls")
return
}
const isZip = assetName.endsWith(".zip")
const archivePath = path.join(Global.Path.bin, isZip ? "zls.zip" : "zls.tar.xz")
await Bun.file(archivePath).write(response)
if (isZip) {
await $`unzip -o -q ${archivePath} -d ${Global.Path.bin}`.nothrow()
} else {
await $`tar -xf ${archivePath} -C ${Global.Path.bin}`.quiet()
}
await fs.rm(archivePath, { force: true })
if (platform !== "win32") {
bin = path.join(Global.Path.bin, "zls")
await $`chmod +x ${bin}`.quiet()
} else {
bin = path.join(Global.Path.bin, "zls.exe")
}
log.info("installed zls", { bin })
}
return {
process: spawn(bin!),
process: spawn(binary, {
cwd: root,
}),
}
},
}
+1 -1
View File
@@ -27,7 +27,7 @@ export const GlobTool = Tool.define({
let truncated = false
for (const file of await Ripgrep.files({
cwd: search,
glob: params.pattern,
glob: [params.pattern],
})) {
if (files.length >= limit) {
truncated = true
+11 -1
View File
@@ -1,7 +1,17 @@
import { exists } from "fs/promises"
import { dirname, join } from "path"
import { dirname, join, relative } from "path"
export namespace Filesystem {
export function overlaps(a: string, b: string) {
const relA = relative(a, b)
const relB = relative(b, a)
return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..")
}
export function contains(parent: string, child: string) {
return relative(parent, child).startsWith("..")
}
export async function findUp(target: string, start: string, stop?: string) {
let current = start
const result = []
+4 -23
View File
@@ -1,8 +1,6 @@
import {
For,
Show,
Match,
Switch,
onMount,
Suspense,
onCleanup,
@@ -12,13 +10,13 @@ import {
} from "solid-js"
import { DateTime } from "luxon"
import { createStore, reconcile } from "solid-js/store"
import { IconOpenAI, IconGemini, IconOpencode, IconAnthropic } from "./icons/custom"
import { IconSparkles, IconArrowDown } from "./icons"
import { IconArrowDown } from "./icons"
import { IconOpencode } from "./icons/custom"
import styles from "./share.module.css"
import type { MessageV2 } from "opencode/session/message-v2"
import type { Message } from "opencode/session/message"
import type { Session } from "opencode/session/index"
import { Part } from "./share/part"
import { Part, ProviderIcon } from "./share/part"
type Status = "disconnected" | "connecting" | "connected" | "error" | "reconnecting"
@@ -46,23 +44,6 @@ function getStatusText(status: [Status, string?]): string {
}
}
function ProviderIcon(props: { provider: string; size?: number }) {
const size = props.size || 16
return (
<Switch fallback={<IconSparkles width={size} height={size} />}>
<Match when={props.provider === "openai"}>
<IconOpenAI width={size} height={size} />
</Match>
<Match when={props.provider === "anthropic"}>
<IconAnthropic width={size} height={size} />
</Match>
<Match when={props.provider === "gemini"}>
<IconGemini width={size} height={size} />
</Match>
</Switch>
)
}
export default function Share(props: {
id: string
api: string
@@ -319,7 +300,7 @@ export default function Share(props: {
{([provider, model]) => (
<li data-slot="item">
<div data-slot="icon" title={provider}>
<ProviderIcon provider={provider} />
<ProviderIcon model={model} />
</div>
<span data-slot="model">{model}</span>
</li>
@@ -49,3 +49,12 @@ export function IconOpencode(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
</svg>
)
}
// https://icones.js.org/collection/ri?s=meta&icon=ri:meta-fill
export function IconMeta(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path fill="currentColor" d="M16.92 4.5c-1.851 0-3.298 1.394-4.608 3.165C10.512 5.373 9.007 4.5 7.206 4.5C3.534 4.5.72 9.28.72 14.338c0 3.165 1.531 5.162 4.096 5.162c1.846 0 3.174-.87 5.535-4.997c0 0 .984-1.737 1.66-2.934q.356.574.75 1.238l1.107 1.862c2.156 3.608 3.358 4.831 5.534 4.831c2.5 0 3.89-2.024 3.89-5.255c0-5.297-2.877-9.745-6.372-9.745m-8.37 8.886c-1.913 3-2.575 3.673-3.64 3.673c-1.097 0-1.749-.963-1.749-2.68c0-3.672 1.831-7.427 4.014-7.427c1.182 0 2.17.682 3.683 2.848c-1.437 2.204-2.307 3.586-2.307 3.586m7.224-.377L14.45 10.8a45 45 0 0 0-1.032-1.608c1.193-1.841 2.176-2.759 3.347-2.759c2.43 0 4.375 3.58 4.375 7.976c0 1.676-.549 2.649-1.686 2.649c-1.09 0-1.61-.72-3.68-4.05" />
</svg>
)
}
@@ -13,6 +13,7 @@
pre {
--shiki-dark-bg: var(--sl-color-bg-surface) !important;
background-color: var(--sl-color-bg-surface) !important;
line-height: 1.6;
font-size: 0.75rem;
white-space: pre-wrap;
@@ -1,7 +1,7 @@
import { type JSX, splitProps, createResource, Suspense } from "solid-js"
import { codeToHtml } from "shiki"
import style from "./content-code.module.css"
import { createResource, Suspense } from "solid-js"
import { transformerNotationDiff } from "@shikijs/transformers"
import style from "./content-code.module.css"
interface Props {
code: string
@@ -0,0 +1,66 @@
.root {
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);
[data-section="content"] {
pre {
margin-bottom: 0.5rem;
line-height: 1.5;
font-size: 0.75rem;
white-space: pre-wrap;
word-break: break-word;
&:last-child {
margin-bottom: 0;
}
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;
}
}
}
&[data-expanded="true"] {
[data-section="content"] {
display: block;
}
}
&[data-expanded="false"] {
[data-section="content"] {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 7;
overflow: hidden;
}
}
button {
flex: 0 0 auto;
padding: 2px 0;
font-size: 0.75rem;
}
}
@@ -0,0 +1,31 @@
import style from "./content-error.module.css"
import { type JSX, createSignal } from "solid-js"
import { createOverflow } from "./common"
interface Props extends JSX.HTMLAttributes<HTMLDivElement> {
expand?: boolean
}
export function ContentError(props: Props) {
const [expanded, setExpanded] = createSignal(false)
const overflow = createOverflow()
return (
<div
class={style.root}
data-expanded={expanded() || props.expand === true ? true : undefined}
>
<div data-section="content" ref={overflow.ref}>
{props.children}
</div>
{((!props.expand && overflow.status) || expanded()) && (
<button
type="button"
data-element-button-text
onClick={() => setExpanded((e) => !e)}
>
{expanded() ? "Show less" : "Show more"}
</button>
)}
</div>
)
}
+88 -19
View File
@@ -1,31 +1,42 @@
import { createMemo, createSignal, For, Match, Show, Switch, type JSX, type ParentProps } from "solid-js"
import map from "lang-map"
import { DateTime } from "luxon"
import {
For,
Show,
Match,
Switch,
type JSX,
createMemo,
createSignal,
type ParentProps
} from "solid-js"
import {
IconCheckCircle,
IconChevronDown,
IconChevronRight,
IconHashtag,
IconSparkles,
IconGlobeAlt,
IconDocument,
IconQueueList,
IconUserCircle,
IconCommandLine,
IconCheckCircle,
IconChevronDown,
IconChevronRight,
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 { IconMeta, IconOpenAI, IconGemini, IconAnthropic } from "../icons/custom"
import { ContentCode } from "./content-code"
import { ContentDiff } from "./content-diff"
import { ContentText } from "./content-text"
import { ContentError } from "./content-error"
import { ContentMarkdown } from "./content-markdown"
import type { MessageV2 } from "opencode/session/message-v2"
import type { Diagnostic } from "vscode-languageserver-types"
import styles from "./part.module.css"
export interface PartProps {
index: number
@@ -65,6 +76,15 @@ export function Part(props: PartProps) {
}}
>
<Switch>
<Match when={props.message.role === "user" && props.part.type === "text"}>
<IconUserCircle width={18} height={18} />
</Match>
<Match when={props.message.role === "user" && props.part.type === "file"}>
<IconDocument width={18} height={18} />
</Match>
<Match when={props.part.type === "step-start" && props.message.role === "assistant" && props.message.modelID}>
{model => <ProviderIcon model={model()} size={18} />}
</Match>
<Match when={props.part.type === "tool" && props.part.tool === "todowrite"}>
<IconQueueList width={18} height={18} />
</Match>
@@ -112,7 +132,8 @@ export function Part(props: PartProps) {
<div data-component="content">
{props.message.role === "user" && props.part.type === "text" && (
<>
<ContentText text={props.part.text} expand={props.last} /> <Spacer />
<ContentText text={props.part.text} expand={props.last} />
<Spacer />
</>
)}
{props.message.role === "assistant" && props.part.type === "text" && (
@@ -130,12 +151,28 @@ export function Part(props: PartProps) {
<Spacer />
</>
)}
{props.message.role === "user" && props.part.type === "file" && (
<div data-component="tool-title">
<span data-slot="name">Read</span>
<span data-slot="target" title={props.part.filename}>
{props.part.filename}
</span>
</div>
)}
{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 === "error" && (
<div data-component="tool">
<ContentError>
{formatErrorString(props.part.state.error)}
</ContentError>
</div>
)}
{props.part.type === "tool" &&
props.part.state.status === "completed" &&
props.message.role === "assistant" && (
@@ -420,11 +457,11 @@ export function WebFetchTool(props: ToolProps) {
<div data-component="tool-result">
<Switch>
<Match when={props.state.metadata?.error}>
<div data-component="error">{formatErrorString(props.state.output)}</div>
<ContentError>{formatErrorString(props.state.output)}</ContentError>
</Match>
<Match when={props.state.output}>
<ResultsButton>
<CodeBlock lang={props.state.input.format || "text"} code={props.state.output} />
<ContentCode lang={props.state.input.format || "text"} code={props.state.output} />
</ResultsButton>
</Match>
</Switch>
@@ -447,7 +484,7 @@ export function ReadTool(props: ToolProps) {
<div data-component="tool-result">
<Switch>
<Match when={props.state.metadata?.error}>
<div data-component="error">{formatErrorString(props.state.output)}</div>
<ContentError>{formatErrorString(props.state.output)}</ContentError>
</Match>
<Match when={typeof props.state.metadata?.preview === "string"}>
<ResultsButton showCopy="Show preview" hideCopy="Hide preview">
@@ -483,7 +520,7 @@ export function WriteTool(props: ToolProps) {
<div data-component="tool-result">
<Switch>
<Match when={props.state.metadata?.error}>
<div data-component="error">{formatErrorString(props.state.output)}</div>
<ContentError>{formatErrorString(props.state.output)}</ContentError>
</Match>
<Match when={props.state.input?.content}>
<ResultsButton showCopy="Show contents" hideCopy="Hide contents">
@@ -511,7 +548,7 @@ export function EditTool(props: ToolProps) {
<div data-component="tool-result">
<Switch>
<Match when={props.state.metadata?.error}>
<div data-component="error">{formatErrorString(props.state.metadata?.message || "")}</div>
<ContentError>{formatErrorString(props.state.metadata?.message || "")}</ContentError>
</Match>
<Match when={props.state.metadata?.diff}>
<div data-component="diff">
@@ -662,3 +699,35 @@ function flattenToolArgs(obj: any, prefix: string = ""): Array<[string, any]> {
return entries
}
function getProvider(model: string) {
const lowerModel = model.toLowerCase()
if (/claude|anthropic/.test(lowerModel)) return "anthropic"
if (/gpt|o[1-4]|codex|openai/.test(lowerModel)) return "openai"
if (/gemini|palm|bard|google/.test(lowerModel)) return "gemini"
if (/llama|meta/.test(lowerModel)) return "meta"
return "any"
}
export function ProviderIcon(props: { model: string; size?: number }) {
const provider = getProvider(props.model)
const size = props.size || 16
return (
<Switch fallback={<IconSparkles width={size} height={size} />}>
<Match when={provider === "openai"}>
<IconOpenAI width={size} height={size} />
</Match>
<Match when={provider === "anthropic"}>
<IconAnthropic width={size} height={size} />
</Match>
<Match when={provider === "gemini"}>
<IconGemini width={size} height={size} />
</Match>
<Match when={provider === "meta"}>
<IconMeta width={size} height={size} />
</Match>
</Switch>
)
}
+2 -3
View File
@@ -23,9 +23,8 @@ const models: Set<string> = new Set();
const version = data.info.version ? `v${data.info.version}` : "v0.0.1";
Object.values(data.messages).forEach((d) => {
const assistant = d.metadata?.assistant;
if (assistant) {
models.add(assistant.modelID);
if (d.role === "assistant" && d.modelID) {
models.add(d.modelID);
}
});