merge dev
format / format (push) Has been cancelled
test / test (push) Has been cancelled

This commit is contained in:
Aiden Cline
2025-11-21 01:44:45 -06:00
222 changed files with 9832 additions and 4111 deletions
+5 -4
View File
@@ -7,19 +7,20 @@
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
"build": "./script/generate-sitemap.ts && vinxi build && ../../opencode/script/schema.ts ./.output/public/config.json",
"start": "vinxi start",
"version": "1.0.61"
"version": "1.0.85"
},
"dependencies": {
"@ibm/plex": "6.4.1",
"@jsx-email/render": "1.1.1",
"@kobalte/core": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/console-core": "workspace:*",
"@opencode-ai/console-mail": "workspace:*",
"@openauthjs/openauth": "catalog:",
"@kobalte/core": "catalog:",
"@jsx-email/render": "1.1.1",
"@opencode-ai/console-resource": "workspace:*",
"@solidjs/meta": "^0.29.4",
"@solidjs/router": "^0.15.0",
"@solidjs/start": "^1.1.0",
"chart.js": "4.5.1",
"solid-js": "catalog:",
"vinxi": "^0.5.7",
"zod": "catalog:"
@@ -202,6 +202,14 @@ export function IconZai(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
)
}
export function IconGoogle(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} viewBox="0 0 50 50" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M49.04,24.001l-1.082-0.043h-0.001C36.134,23.492,26.508,13.866,26.042,2.043L25.999,0.96C25.978,0.424,25.537,0,25,0 s-0.978,0.424-0.999,0.96l-0.043,1.083C23.492,13.866,13.866,23.492,2.042,23.958L0.96,24.001C0.424,24.022,0,24.463,0,25 c0,0.537,0.424,0.978,0.961,0.999l1.082,0.042c11.823,0.467,21.449,10.093,21.915,21.916l0.043,1.083C24.022,49.576,24.463,50,25,50 s0.978-0.424,0.999-0.96l0.043-1.083c0.466-11.823,10.092-21.449,21.915-21.916l1.082-0.042C49.576,25.978,50,25.537,50,25 C50,24.463,49.576,24.022,49.04,24.001z"></path>
</svg>
)
}
export function IconStealth(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 18" fill="none">
@@ -212,3 +220,30 @@ export function IconStealth(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
</svg>
)
}
export function IconChevronLeft(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} viewBox="0 0 20 20" fill="none">
<path d="M12 15L7 10L12 5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" />
</svg>
)
}
export function IconChevronRight(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} viewBox="0 0 20 20" fill="none">
<path d="M8 5L13 10L8 15" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" />
</svg>
)
}
export function IconBreakdown(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M2 12L2 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<path d="M6 12L6 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<path d="M10 12L10 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<path d="M14 12L14 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
)
}
+2 -2
View File
@@ -22,8 +22,8 @@ export const config = {
// Static stats (used on landing page)
stats: {
contributors: "250",
commits: "3,500",
contributors: "300",
commits: "4,000",
monthlyUsers: "300,000",
},
} as const
@@ -0,0 +1,145 @@
.root {
[data-component="empty-state"] {
padding: var(--space-20) var(--space-6);
text-align: center;
border: 1px dashed var(--color-border);
border-radius: var(--border-radius-sm);
height: 400px;
display: flex;
align-items: center;
justify-content: center;
p {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
}
[data-slot="filter-container"] {
margin-bottom: 0;
display: flex;
align-items: center;
gap: var(--space-3);
[data-component="dropdown"] {
[data-slot="trigger"] {
border: 1px solid var(--color-border);
background-color: var(--color-bg);
padding: var(--space-2) var(--space-3);
border-radius: var(--border-radius-sm);
color: var(--color-text);
font-size: var(--font-size-sm);
line-height: 1.5;
&:hover {
border-color: var(--color-accent);
}
&:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-alpha);
}
}
[data-slot="chevron"] {
opacity: 0.6;
}
[data-slot="dropdown"] {
min-width: 200px;
max-height: 300px;
overflow-y: auto;
padding: var(--space-1);
}
}
}
[data-slot="month-picker"] {
display: flex;
align-items: center;
background-color: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
padding: 0;
}
[data-slot="month-button"] {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none !important;
color: var(--color-text);
cursor: pointer;
padding: var(--space-2) var(--space-3);
border-radius: var(--border-radius-xs);
transition: background-color 0.2s;
line-height: 1;
&:hover {
background-color: var(--color-bg-hover);
}
svg {
display: block;
width: 16px;
height: 16px;
stroke-width: 2;
}
}
[data-slot="month-label"] {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--color-text);
line-height: 1.5;
min-width: 140px;
text-align: center;
white-space: nowrap;
}
[data-slot="model-item"] {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
cursor: pointer;
transition: background-color 0.2s;
font-size: var(--font-size-sm);
color: var(--color-text);
border: none !important;
background: none;
width: 100%;
text-align: left;
white-space: nowrap;
&:hover {
background: var(--color-bg-hover);
}
span {
flex: 1;
user-select: none;
}
}
[data-slot="chart-container"] {
padding: var(--space-6);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
height: 400px;
}
@media (max-width: 40rem) {
[data-slot="chart-container"] {
height: 300px;
padding: var(--space-4);
}
[data-component="empty-state"] {
height: 300px;
}
}
}
@@ -0,0 +1,423 @@
import { and, Database, eq, gte, inArray, isNull, lte, or, sql, sum } from "@opencode-ai/console-core/drizzle/index.js"
import { UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
import { AuthTable } from "@opencode-ai/console-core/schema/auth.sql.js"
import { createAsync, query, useParams } from "@solidjs/router"
import { createEffect, createMemo, onCleanup, Show, For } from "solid-js"
import { createStore } from "solid-js/store"
import { withActor } from "~/context/auth.withActor"
import { Dropdown } from "~/component/dropdown"
import { IconChevronLeft, IconChevronRight } from "~/component/icon"
import styles from "./graph-section.module.css"
import {
Chart,
BarController,
BarElement,
CategoryScale,
LinearScale,
Tooltip,
Legend,
type ChartConfiguration,
} from "chart.js"
Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip, Legend)
async function getCosts(workspaceID: string, year: number, month: number) {
"use server"
return withActor(async () => {
const startDate = new Date(year, month, 1)
const endDate = new Date(year, month + 1, 0)
// First query: get usage data without joining keys
const usageData = await Database.use((tx) =>
tx
.select({
date: sql<string>`DATE(${UsageTable.timeCreated})`,
model: UsageTable.model,
totalCost: sum(UsageTable.cost),
keyId: UsageTable.keyID,
})
.from(UsageTable)
.where(
and(
eq(UsageTable.workspaceID, workspaceID),
gte(UsageTable.timeCreated, startDate),
lte(UsageTable.timeCreated, endDate),
),
)
.groupBy(sql`DATE(${UsageTable.timeCreated})`, UsageTable.model, UsageTable.keyID)
.then((x) =>
x.map((r) => ({
...r,
totalCost: r.totalCost ? parseInt(r.totalCost) : 0,
})),
),
)
// Get unique key IDs from usage
const usageKeyIds = new Set(usageData.map((r) => r.keyId).filter((id) => id !== null))
// Second query: get all existing keys plus any keys from usage
const keysData = await Database.use((tx) =>
tx
.select({
keyId: KeyTable.id,
keyName: KeyTable.name,
userEmail: AuthTable.subject,
timeDeleted: KeyTable.timeDeleted,
})
.from(KeyTable)
.innerJoin(UserTable, and(eq(KeyTable.userID, UserTable.id), eq(KeyTable.workspaceID, UserTable.workspaceID)))
.innerJoin(AuthTable, and(eq(UserTable.accountID, AuthTable.accountID), eq(AuthTable.provider, "email")))
.where(
and(
eq(KeyTable.workspaceID, workspaceID),
usageKeyIds.size > 0
? or(inArray(KeyTable.id, Array.from(usageKeyIds)), isNull(KeyTable.timeDeleted))
: isNull(KeyTable.timeDeleted),
),
)
.orderBy(AuthTable.subject, KeyTable.name),
)
return {
usage: usageData,
keys: keysData.map((key) => ({
id: key.keyId,
displayName:
key.timeDeleted !== null
? `${key.userEmail} - ${key.keyName} (deleted)`
: `${key.userEmail} - ${key.keyName}`,
})),
}
}, workspaceID)
}
const queryCosts = query(getCosts, "costs.get")
const MODEL_COLORS: Record<string, string> = {
"claude-sonnet-4-5": "#D4745C",
"claude-sonnet-4": "#E8B4A4",
"claude-opus-4": "#C8A098",
"claude-haiku-4-5": "#F0D8D0",
"claude-3-5-haiku": "#F8E8E0",
"gpt-5.1": "#4A90E2",
"gpt-5.1-codex": "#6BA8F0",
"gpt-5": "#7DB8F8",
"gpt-5-codex": "#9FCAFF",
"gpt-5-nano": "#B8D8FF",
"grok-code": "#8B5CF6",
"big-pickle": "#10B981",
"kimi-k2": "#F59E0B",
"qwen3-coder": "#EC4899",
"glm-4.6": "#14B8A6",
}
function getModelColor(model: string): string {
if (MODEL_COLORS[model]) return MODEL_COLORS[model]
const hash = model.split("").reduce((acc, char) => char.charCodeAt(0) + ((acc << 5) - acc), 0)
const hue = Math.abs(hash) % 360
return `hsl(${hue}, 50%, 65%)`
}
function formatDateLabel(dateStr: string): string {
const date = new Date()
const [y, m, d] = dateStr.split("-").map(Number)
date.setFullYear(y)
date.setMonth(m - 1)
date.setDate(d)
date.setHours(0, 0, 0, 0)
const month = date.toLocaleDateString("en-US", { month: "short" })
const day = date.getUTCDate().toString().padStart(2, "0")
return `${month} ${day}`
}
function addOpacityToColor(color: string, opacity: number): string {
if (color.startsWith("#")) {
const r = parseInt(color.slice(1, 3), 16)
const g = parseInt(color.slice(3, 5), 16)
const b = parseInt(color.slice(5, 7), 16)
return `rgba(${r}, ${g}, ${b}, ${opacity})`
}
if (color.startsWith("hsl")) return color.replace(")", `, ${opacity})`).replace("hsl", "hsla")
return color
}
export function GraphSection() {
let canvasRef: HTMLCanvasElement | undefined
let chartInstance: Chart | undefined
const params = useParams()
const now = new Date()
const [store, setStore] = createStore({
data: null as Awaited<ReturnType<typeof getCosts>> | null,
year: now.getFullYear(),
month: now.getMonth(),
key: null as string | null,
model: null as string | null,
modelDropdownOpen: false,
keyDropdownOpen: false,
})
const initialData = createAsync(() => queryCosts(params.id!, store.year, store.month))
const onPreviousMonth = async () => {
const month = store.month === 0 ? 11 : store.month - 1
const year = store.month === 0 ? store.year - 1 : store.year
const data = await getCosts(params.id!, year, month)
setStore({ month, year, data })
}
const onNextMonth = async () => {
const month = store.month === 11 ? 0 : store.month + 1
const year = store.month === 11 ? store.year + 1 : store.year
setStore({ month, year, data: await getCosts(params.id!, year, month) })
}
const onSelectModel = (model: string | null) => setStore({ model, modelDropdownOpen: false })
const onSelectKey = (keyID: string | null) => setStore({ key: keyID, keyDropdownOpen: false })
const getData = createMemo(() => store.data ?? initialData())
const getModels = createMemo(() => {
const data = getData()
if (!data?.usage) return []
return Array.from(new Set(data.usage.map((row) => row.model))).sort()
})
const getDates = createMemo(() => {
const daysInMonth = new Date(store.year, store.month + 1, 0).getDate()
return Array.from({ length: daysInMonth }, (_, i) => {
const date = new Date(store.year, store.month, i + 1)
return date.toISOString().split("T")[0]
})
})
const getKeyName = (keyID: string | null): string => {
if (!keyID || !store.data?.keys) return "All Keys"
const found = store.data.keys.find((k) => k.id === keyID)
return found?.displayName ?? "All Keys"
}
const formatMonthYear = () =>
new Date(store.year, store.month, 1).toLocaleDateString("en-US", { month: "long", year: "numeric" })
const isCurrentMonth = () => store.year === now.getFullYear() && store.month === now.getMonth()
const chartConfig = createMemo((): ChartConfiguration | null => {
const data = getData()
const dates = getDates()
if (!data?.usage?.length) return null
const dailyData = new Map<string, Map<string, number>>()
for (const dateKey of dates) dailyData.set(dateKey, new Map())
data.usage
.filter((row) => (store.key ? row.keyId === store.key : true))
.forEach((row) => {
const dayMap = dailyData.get(row.date)
if (!dayMap) return
dayMap.set(row.model, (dayMap.get(row.model) ?? 0) + row.totalCost)
})
const filteredModels = store.model === null ? getModels() : [store.model]
const datasets = filteredModels.map((model) => {
const color = getModelColor(model)
return {
label: model,
data: dates.map((date) => (dailyData.get(date)?.get(model) || 0) / 100_000_000),
backgroundColor: color,
hoverBackgroundColor: color,
borderWidth: 0,
}
})
return {
type: "bar",
data: {
labels: dates.map(formatDateLabel),
datasets,
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
stacked: true,
grid: {
display: false,
},
ticks: {
maxRotation: 0,
autoSkipPadding: 20,
color: "rgba(255, 255, 255, 0.5)",
font: {
family: "monospace",
size: 11,
},
},
},
y: {
stacked: true,
beginAtZero: true,
grid: {
color: "rgba(255, 255, 255, 0.1)",
},
ticks: {
color: "rgba(255, 255, 255, 0.5)",
font: {
family: "monospace",
size: 11,
},
callback: (value) => {
const num = Number(value)
return num >= 1000 ? `$${(num / 1000).toFixed(1)}k` : `$${num.toFixed(0)}`
},
},
},
},
plugins: {
tooltip: {
mode: "index",
intersect: false,
backgroundColor: "rgba(0, 0, 0, 0.9)",
titleColor: "rgba(255, 255, 255, 0.9)",
bodyColor: "rgba(255, 255, 255, 0.8)",
borderColor: "rgba(255, 255, 255, 0.1)",
borderWidth: 1,
padding: 12,
displayColors: true,
callbacks: {
label: (context) => {
const value = context.parsed.y
if (!value || value === 0) return
return `${context.dataset.label}: $${value.toFixed(2)}`
},
},
},
legend: {
display: true,
position: "bottom",
labels: {
color: "rgba(255, 255, 255, 0.7)",
font: {
size: 12,
},
padding: 16,
boxWidth: 16,
boxHeight: 16,
usePointStyle: false,
},
onHover: (event, legendItem, legend) => {
const chart = legend.chart
chart.data.datasets?.forEach((dataset, i) => {
const meta = chart.getDatasetMeta(i)
const baseColor = getModelColor(dataset.label || "")
const color = i === legendItem.datasetIndex ? baseColor : addOpacityToColor(baseColor, 0.3)
meta.data.forEach((bar: any) => {
bar.options.backgroundColor = color
})
})
chart.update("none")
},
onLeave: (event, legendItem, legend) => {
const chart = legend.chart
chart.data.datasets?.forEach((dataset, i) => {
const meta = chart.getDatasetMeta(i)
const baseColor = getModelColor(dataset.label || "")
meta.data.forEach((bar: any) => {
bar.options.backgroundColor = baseColor
})
})
chart.update("none")
},
},
},
},
}
})
createEffect(() => {
const config = chartConfig()
if (!config || !canvasRef) return
if (chartInstance) chartInstance.destroy()
chartInstance = new Chart(canvasRef, config)
})
onCleanup(() => chartInstance?.destroy())
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>Cost</h2>
<p>Usage costs broken down by model.</p>
</div>
<Show when={getData()}>
<div data-slot="filter-container">
<div data-slot="month-picker">
<button data-slot="month-button" onClick={onPreviousMonth}>
<IconChevronLeft />
</button>
<span data-slot="month-label">{formatMonthYear()}</span>
<button data-slot="month-button" onClick={onNextMonth} disabled={isCurrentMonth()}>
<IconChevronRight />
</button>
</div>
<Dropdown
trigger={store.model === null ? "All Models" : store.model}
open={store.modelDropdownOpen}
onOpenChange={(open) => setStore({ modelDropdownOpen: open })}
>
<>
<button data-slot="model-item" onClick={() => onSelectModel(null)}>
<span>All Models</span>
</button>
<For each={getModels()}>
{(model) => (
<button data-slot="model-item" onClick={() => onSelectModel(model)}>
<span>{model}</span>
</button>
)}
</For>
</>
</Dropdown>
<Dropdown
trigger={getKeyName(store.key)}
open={store.keyDropdownOpen}
onOpenChange={(open) => setStore({ keyDropdownOpen: open })}
>
<>
<button data-slot="model-item" onClick={() => onSelectKey(null)}>
<span>All Keys</span>
</button>
<For each={getData()?.keys || []}>
{(key) => (
<button data-slot="model-item" onClick={() => onSelectKey(key.id)}>
<span>{key.displayName}</span>
</button>
)}
</For>
</>
</Dropdown>
</div>
</Show>
<Show
when={chartConfig()}
fallback={
<div data-component="empty-state">
<p>No usage data available for the selected period.</p>
</div>
}
>
<div data-slot="chart-container">
<canvas ref={canvasRef} />
</div>
</Show>
</section>
)
}
@@ -5,6 +5,7 @@ import { NewUserSection } from "./new-user-section"
import { UsageSection } from "./usage-section"
import { ModelSection } from "./model-section"
import { ProviderSection } from "./provider-section"
import { GraphSection } from "./graph-section"
import { IconLogo } from "~/component/icon"
import { querySessionInfo, queryBillingInfo, createCheckoutUrl, formatBalance } from "../common"
@@ -66,6 +67,9 @@ export default function () {
<div data-slot="sections">
<NewUserSection />
<Show when={userInfo()?.isAdmin}>
<GraphSection />
</Show>
<ModelSection />
<Show when={userInfo()?.isAdmin}>
<ProviderSection />
@@ -5,11 +5,21 @@ import { withActor } from "~/context/auth.withActor"
import { ZenData } from "@opencode-ai/console-core/model.js"
import styles from "./model-section.module.css"
import { querySessionInfo } from "../common"
import { IconAlibaba, IconAnthropic, IconMoonshotAI, IconOpenAI, IconStealth, IconXai, IconZai } from "~/component/icon"
import {
IconAlibaba,
IconAnthropic,
IconGoogle,
IconMoonshotAI,
IconOpenAI,
IconStealth,
IconXai,
IconZai,
} from "~/component/icon"
const getModelLab = (modelId: string) => {
if (modelId.startsWith("claude")) return "Anthropic"
if (modelId.startsWith("gpt")) return "OpenAI"
if (modelId.startsWith("gemini")) return "Google"
if (modelId.startsWith("kimi")) return "Moonshot AI"
if (modelId.startsWith("glm")) return "Z.ai"
if (modelId.startsWith("qwen")) return "Alibaba"
@@ -22,9 +32,19 @@ const getModelsInfo = query(async (workspaceID: string) => {
return withActor(async () => {
return {
all: Object.entries(ZenData.list().models)
.filter(([id, _model]) => !["claude-3-5-haiku", "minimax-m2"].includes(id))
.filter(([id, _model]) => !id.startsWith("an-"))
.sort(([_idA, modelA], [_idB, modelB]) => modelA.name.localeCompare(modelB.name))
.filter(([id, _model]) => !["claude-3-5-haiku"].includes(id))
.filter(([id, _model]) => !id.startsWith("alpha-"))
.sort(([idA, modelA], [idB, modelB]) => {
const priority = ["big-pickle", "grok", "claude", "gpt", "gemini"]
const getPriority = (id: string) => {
const index = priority.findIndex((p) => id.startsWith(p))
return index === -1 ? Infinity : index
}
const pA = getPriority(idA)
const pB = getPriority(idB)
if (pA !== pB) return pA - pB
return modelA.name.localeCompare(modelB.name)
})
.map(([id, model]) => ({ id, name: model.name })),
disabled: await Model.listDisabled(),
}
@@ -96,6 +116,8 @@ export function ModelSection() {
return <IconOpenAI width={16} height={16} />
case "Anthropic":
return <IconAnthropic width={16} height={16} />
case "Google":
return <IconGoogle width={16} height={16} />
case "Moonshot AI":
return <IconMoonshotAI width={16} height={16} />
case "Z.ai":
@@ -1,24 +1,23 @@
.root {
/* Empty state */
[data-component="empty-state"] {
padding: var(--space-20) var(--space-6);
text-align: center;
border: 1px dashed var(--color-border);
border-radius: var(--border-radius-sm);
display: flex;
flex-direction: column;
gap: var(--space-2);
p {
line-height: 1.5;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
}
/* Table container */
[data-slot="usage-table"] {
overflow-x: auto;
}
/* Table element */
[data-slot="usage-table-element"] {
width: 100%;
border-collapse: collapse;
@@ -48,7 +47,6 @@
&[data-slot="usage-model"] {
font-family: var(--font-sans);
font-weight: 400;
color: var(--color-text-secondary);
max-width: 200px;
word-break: break-word;
@@ -56,33 +54,133 @@
&[data-slot="usage-cost"] {
color: var(--color-text);
font-weight: 500;
}
[data-slot="tokens-with-breakdown"] {
position: relative;
display: flex;
align-items: center;
gap: var(--space-2);
}
[data-slot="breakdown-button"] {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
background: transparent;
border: none;
color: var(--color-text-muted);
cursor: pointer;
transition: color 0.15s ease;
&:hover {
color: var(--color-text);
}
svg {
width: 16px;
height: 16px;
}
}
[data-slot="breakdown-popup"] {
position: absolute;
left: 0;
top: 100%;
margin-top: var(--space-2);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
padding: var(--space-2);
z-index: 10;
min-width: 180px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
font-size: var(--font-size-xs);
@media (prefers-color-scheme: dark) {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
}
}
tbody tr {
&:last-child td {
border-bottom: none;
tbody tr:last-child td {
border-bottom: none;
}
}
/* Pagination */
[data-slot="pagination"] {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
padding: var(--space-4) 0;
border-top: 1px solid var(--color-border-muted);
margin-top: var(--space-2);
button {
padding: var(--space-2) var(--space-4);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
color: var(--color-text);
font-size: var(--font-size-sm);
cursor: pointer;
transition: all 0.15s ease;
svg {
width: 16px;
height: 16px;
stroke-width: 2;
}
&:hover:not(:disabled) {
background: var(--color-bg-tertiary);
border-color: var(--color-border-hover);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
@media (max-width: 40rem) {
/* Mobile responsive */
@media (max-width: 40rem) {
[data-slot="usage-table-element"] {
th,
td {
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-xs);
}
th {
&:nth-child(2) /* Model */ {
display: none;
}
}
td {
&:nth-child(2) /* Model */ {
display: none;
}
/* Hide Model column on mobile */
th:nth-child(2),
td:nth-child(2) {
display: none;
}
}
}
/* Breakdown popup content */
[data-slot="breakdown-row"] {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-4);
padding: var(--space-1) 0;
}
[data-slot="breakdown-label"] {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
}
[data-slot="breakdown-value"] {
color: var(--color-text);
font-weight: 500;
font-size: var(--font-size-xs);
}
}
@@ -1,81 +1,69 @@
import { Billing } from "@opencode-ai/console-core/billing.js"
import { query, useParams, createAsync } from "@solidjs/router"
import { createMemo, For, Show } from "solid-js"
import { createAsync, query, useParams } from "@solidjs/router"
import { createMemo, For, Show, createEffect, createSignal } from "solid-js"
import { formatDateUTC, formatDateForTable } from "../common"
import { withActor } from "~/context/auth.withActor"
import { IconChevronLeft, IconChevronRight, IconBreakdown } from "~/component/icon"
import styles from "./usage-section.module.css"
import { createStore } from "solid-js/store"
const getUsageInfo = query(async (workspaceID: string) => {
const PAGE_SIZE = 50
async function getUsageInfo(workspaceID: string, page: number) {
"use server"
return withActor(async () => {
return await Billing.usages()
return await Billing.usages(page, PAGE_SIZE)
}, workspaceID)
}, "usage.list")
}
const queryUsageInfo = query(getUsageInfo, "usage.list")
export function UsageSection() {
const params = useParams()
// ORIGINAL CODE - COMMENTED OUT FOR TESTING
const usage = createAsync(() => getUsageInfo(params.id!))
const usage = createAsync(() => queryUsageInfo(params.id!, 0))
const [store, setStore] = createStore({ page: 0, usage: [] as Awaited<ReturnType<typeof getUsageInfo>> })
const [openBreakdownId, setOpenBreakdownId] = createSignal<string | null>(null)
// DUMMY DATA FOR TESTING
// const usage = () => [
// {
// timeCreated: new Date(Date.now() - 86400000 * 0).toISOString(), // Today
// model: "claude-3-5-sonnet-20241022",
// inputTokens: 1247,
// outputTokens: 423,
// cost: 125400000, // $1.254
// },
// {
// timeCreated: new Date(Date.now() - 86400000 * 0.5).toISOString(), // 12 hours ago
// model: "claude-3-haiku-20240307",
// inputTokens: 892,
// outputTokens: 156,
// cost: 23500000, // $0.235
// },
// {
// timeCreated: new Date(Date.now() - 86400000 * 1).toISOString(), // Yesterday
// model: "claude-3-5-sonnet-20241022",
// inputTokens: 2134,
// outputTokens: 687,
// cost: 234700000, // $2.347
// },
// {
// timeCreated: new Date(Date.now() - 86400000 * 1.3).toISOString(), // 1.3 days ago
// model: "gpt-4o-mini",
// inputTokens: 567,
// outputTokens: 234,
// cost: 8900000, // $0.089
// },
// {
// timeCreated: new Date(Date.now() - 86400000 * 2).toISOString(), // 2 days ago
// model: "claude-3-opus-20240229",
// inputTokens: 1893,
// outputTokens: 945,
// cost: 445600000, // $4.456
// },
// {
// timeCreated: new Date(Date.now() - 86400000 * 2.7).toISOString(), // 2.7 days ago
// model: "gpt-4o",
// inputTokens: 1456,
// outputTokens: 532,
// cost: 156800000, // $1.568
// },
// {
// timeCreated: new Date(Date.now() - 86400000 * 3).toISOString(), // 3 days ago
// model: "claude-3-haiku-20240307",
// inputTokens: 634,
// outputTokens: 89,
// cost: 12300000, // $0.123
// },
// {
// timeCreated: new Date(Date.now() - 86400000 * 4).toISOString(), // 4 days ago
// model: "claude-3-5-sonnet-20241022",
// inputTokens: 3245,
// outputTokens: 1123,
// cost: 387200000, // $3.872
// },
// ]
createEffect(() => {
setStore({ usage: usage() })
}, [usage])
createEffect(() => {
if (!openBreakdownId()) return
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (!target.closest('[data-slot="tokens-with-breakdown"]')) {
setOpenBreakdownId(null)
}
}
document.addEventListener("click", handleClickOutside)
return () => document.removeEventListener("click", handleClickOutside)
})
const hasResults = createMemo(() => store.usage && store.usage.length > 0)
const canGoPrev = createMemo(() => store.page > 0)
const canGoNext = createMemo(() => store.usage && store.usage.length === PAGE_SIZE)
const calculateTotalInputTokens = (u: Awaited<ReturnType<typeof getUsageInfo>>[0]) => {
return u.inputTokens + (u.cacheReadTokens ?? 0) + (u.cacheWrite5mTokens ?? 0) + (u.cacheWrite1hTokens ?? 0)
}
const goPrev = async () => {
const usage = await getUsageInfo(params.id!, store.page - 1)
setStore({
page: store.page - 1,
usage,
})
}
const goNext = async () => {
const usage = await getUsageInfo(params.id!, store.page + 1)
setStore({
page: store.page + 1,
usage,
})
}
return (
<section class={styles.root}>
@@ -85,7 +73,7 @@ export function UsageSection() {
</div>
<div data-slot="usage-table">
<Show
when={usage() && usage()!.length > 0}
when={hasResults()}
fallback={
<div data-component="empty-state">
<p>Make your first API call to get started.</p>
@@ -103,16 +91,51 @@ export function UsageSection() {
</tr>
</thead>
<tbody>
<For each={usage()!}>
{(usage) => {
<For each={store.usage}>
{(usage, index) => {
const date = createMemo(() => new Date(usage.timeCreated))
const totalInputTokens = createMemo(() => calculateTotalInputTokens(usage))
const breakdownId = `breakdown-${index()}`
const isOpen = createMemo(() => openBreakdownId() === breakdownId)
const isClaude = usage.model.toLowerCase().includes("claude")
return (
<tr>
<td data-slot="usage-date" title={formatDateUTC(date())}>
{formatDateForTable(date())}
</td>
<td data-slot="usage-model">{usage.model}</td>
<td data-slot="usage-tokens">{usage.inputTokens}</td>
<td data-slot="usage-tokens">
<div data-slot="tokens-with-breakdown" onClick={(e) => e.stopPropagation()}>
<button
data-slot="breakdown-button"
onClick={(e) => {
e.stopPropagation()
setOpenBreakdownId(isOpen() ? null : breakdownId)
}}
>
<IconBreakdown />
</button>
<span onClick={() => setOpenBreakdownId(null)}>{totalInputTokens()}</span>
<Show when={isOpen()}>
<div data-slot="breakdown-popup" onClick={(e) => e.stopPropagation()}>
<div data-slot="breakdown-row">
<span data-slot="breakdown-label">Input</span>
<span data-slot="breakdown-value">{usage.inputTokens}</span>
</div>
<div data-slot="breakdown-row">
<span data-slot="breakdown-label">Cache Read</span>
<span data-slot="breakdown-value">{usage.cacheReadTokens ?? 0}</span>
</div>
<Show when={isClaude}>
<div data-slot="breakdown-row">
<span data-slot="breakdown-label">Cache Write</span>
<span data-slot="breakdown-value">{usage.cacheWrite5mTokens ?? 0}</span>
</div>
</Show>
</div>
</Show>
</div>
</td>
<td data-slot="usage-tokens">{usage.outputTokens}</td>
<td data-slot="usage-cost">${((usage.cost ?? 0) / 100000000).toFixed(4)}</td>
</tr>
@@ -121,6 +144,16 @@ export function UsageSection() {
</For>
</tbody>
</table>
<Show when={canGoPrev() || canGoNext()}>
<div data-slot="pagination">
<button disabled={!canGoPrev()} onClick={goPrev}>
<IconChevronLeft />
</button>
<button disabled={!canGoNext()} onClick={goNext}>
<IconChevronRight />
</button>
</div>
</Show>
</Show>
</div>
</section>
@@ -15,6 +15,7 @@ import { logger } from "./logger"
import { AuthError, CreditsError, MonthlyLimitError, UserLimitError, ModelError, RateLimitError } from "./error"
import { createBodyConverter, createStreamPartConverter, createResponseConverter } from "./provider/provider"
import { anthropicHelper } from "./provider/anthropic"
import { googleHelper } from "./provider/google"
import { openaiHelper } from "./provider/openai"
import { oaCompatHelper } from "./provider/openai-compatible"
import { createRateLimiter } from "./rateLimiter"
@@ -30,6 +31,8 @@ export async function handler(
opts: {
format: ZenData.Format
parseApiKey: (headers: Headers) => string | undefined
parseModel: (url: string, body: any) => string
parseIsStream: (url: string, body: any) => boolean
},
) {
type AuthInfo = Awaited<ReturnType<typeof authenticate>>
@@ -43,15 +46,18 @@ export async function handler(
]
try {
const url = input.request.url
const body = await input.request.json()
const ip = input.request.headers.get("x-real-ip") ?? ""
const model = opts.parseModel(url, body)
const isStream = opts.parseIsStream(url, body)
logger.metric({
is_tream: !!body.stream,
is_tream: isStream,
session: input.request.headers.get("x-opencode-session"),
request: input.request.headers.get("x-opencode-request"),
})
const zenData = ZenData.list()
const modelInfo = validateModel(zenData, body.model)
const modelInfo = validateModel(zenData, model)
const rateLimiter = createRateLimiter(modelInfo.id, modelInfo.rateLimit, ip)
await rateLimiter?.check()
@@ -64,7 +70,7 @@ export async function handler(
logger.metric({ provider: providerInfo.id })
const startTimestamp = Date.now()
const reqUrl = providerInfo.modifyUrl(providerInfo.api)
const reqUrl = providerInfo.modifyUrl(providerInfo.api, providerInfo.model, isStream)
const reqBody = JSON.stringify(
providerInfo.modifyBody({
...createBodyConverter(opts.format, providerInfo.format)(body),
@@ -114,7 +120,7 @@ export async function handler(
logger.debug("STATUS: " + res.status + " " + res.statusText)
// Handle non-streaming response
if (!body.stream) {
if (!isStream) {
const responseConverter = createResponseConverter(providerInfo.format, opts.format)
const json = await res.json()
const body = JSON.stringify(responseConverter(json))
@@ -169,7 +175,7 @@ export async function handler(
responseLength += value.length
buffer += decoder.decode(value, { stream: true })
const parts = buffer.split("\n\n")
const parts = buffer.split(providerInfo.streamSeparator)
buffer = parts.pop() ?? ""
for (let part of parts) {
@@ -283,6 +289,7 @@ export async function handler(
...(() => {
const format = zenData.providers[provider.id].format
if (format === "anthropic") return anthropicHelper
if (format === "google") return googleHelper
if (format === "openai") return openaiHelper
return oaCompatHelper
})(),
@@ -291,7 +298,7 @@ export async function handler(
async function authenticate(modelInfo: ModelInfo, providerInfo: ProviderInfo) {
const apiKey = opts.parseApiKey(input.request.headers)
if (!apiKey) {
if (!apiKey || apiKey === "public") {
if (modelInfo.allowAnonymous) return
throw new AuthError("Missing API key.")
}
@@ -30,6 +30,7 @@ export const anthropicHelper = {
service_tier: "standard_only",
}
},
streamSeparator: "\n\n",
createUsageParser: () => {
let usage: Usage
@@ -0,0 +1,74 @@
import { ProviderHelper } from "./provider"
/*
{
promptTokenCount: 11453,
candidatesTokenCount: 71,
totalTokenCount: 11625,
cachedContentTokenCount: 8100,
promptTokensDetails: [
{modality: "TEXT",tokenCount: 11453}
],
cacheTokensDetails: [
{modality: "TEXT",tokenCount: 8100}
],
thoughtsTokenCount: 101
}
*/
type Usage = {
promptTokenCount?: number
candidatesTokenCount?: number
totalTokenCount?: number
cachedContentTokenCount?: number
promptTokensDetails?: { modality: string; tokenCount: number }[]
cacheTokensDetails?: { modality: string; tokenCount: number }[]
thoughtsTokenCount?: number
}
export const googleHelper = {
format: "google",
modifyUrl: (providerApi: string, model?: string, isStream?: boolean) =>
`${providerApi}/models/${model}:${isStream ? "streamGenerateContent?alt=sse" : "generateContent"}`,
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
headers.set("x-goog-api-key", apiKey)
},
modifyBody: (body: Record<string, any>) => {
return body
},
streamSeparator: "\r\n\r\n",
createUsageParser: () => {
let usage: Usage
return {
parse: (chunk: string) => {
if (!chunk.startsWith("data: ")) return
let json
try {
json = JSON.parse(chunk.slice(6)) as { usageMetadata?: Usage }
} catch (e) {
return
}
if (!json.usageMetadata) return
usage = json.usageMetadata
},
retrieve: () => usage,
}
},
normalizeUsage: (usage: Usage) => {
const inputTokens = usage.promptTokenCount ?? 0
const outputTokens = usage.candidatesTokenCount ?? 0
const reasoningTokens = usage.thoughtsTokenCount ?? 0
const cacheReadTokens = usage.cachedContentTokenCount ?? 0
return {
inputTokens: inputTokens - cacheReadTokens,
outputTokens,
reasoningTokens,
cacheReadTokens,
cacheWrite5mTokens: undefined,
cacheWrite1hTokens: undefined,
}
},
} satisfies ProviderHelper
@@ -33,6 +33,7 @@ export const oaCompatHelper = {
...(body.stream ? { stream_options: { include_usage: true } } : {}),
}
},
streamSeparator: "\n\n",
createUsageParser: () => {
let usage: Usage
@@ -21,6 +21,7 @@ export const openaiHelper = {
modifyBody: (body: Record<string, any>) => {
return body
},
streamSeparator: "\n\n",
createUsageParser: () => {
let usage: Usage
@@ -26,9 +26,10 @@ import {
export type ProviderHelper = {
format: ZenData.Format
modifyUrl: (providerApi: string) => string
modifyUrl: (providerApi: string, model?: string, isStream?: boolean) => string
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => void
modifyBody: (body: Record<string, any>) => Record<string, any>
streamSeparator: string
createUsageParser: () => {
parse: (chunk: string) => void
retrieve: () => any
@@ -5,5 +5,7 @@ export function POST(input: APIEvent) {
return handler(input, {
format: "oa-compat",
parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1],
parseModel: (url: string, body: any) => body.model,
parseIsStream: (url: string, body: any) => !!body.stream,
})
}
@@ -5,5 +5,7 @@ export function POST(input: APIEvent) {
return handler(input, {
format: "anthropic",
parseApiKey: (headers: Headers) => headers.get("x-api-key") ?? undefined,
parseModel: (url: string, body: any) => body.model,
parseIsStream: (url: string, body: any) => !!body.stream,
})
}
@@ -0,0 +1,13 @@
import type { APIEvent } from "@solidjs/start/server"
import { handler } from "~/routes/zen/util/handler"
export function POST(input: APIEvent) {
return handler(input, {
format: "google",
parseApiKey: (headers: Headers) => headers.get("x-goog-api-key") ?? undefined,
parseModel: (url: string, body: any) => url.split("/").pop()?.split(":")?.[0] ?? "",
parseIsStream: (url: string, body: any) =>
// ie. url: https://opencode.ai/zen/v1/models/gemini-3-pro:streamGenerateContent?alt=sse'
url.split("/").pop()?.split(":")?.[1]?.startsWith("streamGenerateContent") ?? false,
})
}
@@ -5,5 +5,7 @@ export function POST(input: APIEvent) {
return handler(input, {
format: "openai",
parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1],
parseModel: (url: string, body: any) => body.model,
parseIsStream: (url: string, body: any) => !!body.stream,
})
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.0.61",
"version": "1.0.85",
"private": true,
"type": "module",
"dependencies": {
+3 -2
View File
@@ -57,14 +57,15 @@ export namespace Billing {
)
}
export const usages = async () => {
export const usages = async (page = 0, pageSize = 50) => {
return await Database.use((tx) =>
tx
.select()
.from(UsageTable)
.where(eq(UsageTable.workspaceID, Actor.workspace()))
.orderBy(sql`${UsageTable.timeCreated} DESC`)
.limit(100),
.limit(pageSize)
.offset(page * pageSize),
)
}
+1 -1
View File
@@ -8,7 +8,7 @@ import { Actor } from "./actor"
import { Resource } from "@opencode-ai/console-resource"
export namespace ZenData {
const FormatSchema = z.enum(["anthropic", "openai", "oa-compat"])
const FormatSchema = z.enum(["anthropic", "google", "openai", "oa-compat"])
export type Format = z.infer<typeof FormatSchema>
const ModelCostSchema = z.object({
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.0.61",
"version": "1.0.85",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
@@ -12,7 +12,8 @@ export default {
if (
url.pathname !== "/zen/v1/chat/completions" &&
url.pathname !== "/zen/v1/messages" &&
url.pathname !== "/zen/v1/responses"
url.pathname !== "/zen/v1/responses" &&
!url.pathname.startsWith("/zen/v1/models/")
)
return
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.0.61",
"version": "1.0.85",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+6 -8
View File
@@ -8,14 +8,12 @@
<title>OpenCode</title>
</head>
<body class="antialiased overscroll-none select-none text-12-regular">
<!-- <script> -->
<!-- ;(function () { -->
<!-- const savedTheme = localStorage.getItem("theme") || "opencode" -->
<!-- const savedDarkMode = localStorage.getItem("darkMode") !== "false" -->
<!-- document.documentElement.setAttribute("data-theme", savedTheme) -->
<!-- document.documentElement.setAttribute("data-dark", savedDarkMode.toString()) -->
<!-- })() -->
<!-- </script> -->
<script>
;(function () {
const savedTheme = localStorage.getItem("theme") || "oc-1"
document.documentElement.setAttribute("data-theme", savedTheme)
})()
</script>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script src="/src/index.tsx" type="module"></script>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/desktop",
"version": "1.0.61",
"version": "1.0.85",
"description": "",
"type": "module",
"scripts": {
@@ -266,7 +266,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!existing) {
const created = await sdk.client.session.create()
existing = created.data ?? undefined
if (existing) navigate(`/session/${existing.id}`)
if (existing) navigate(existing.id)
}
if (!existing) return
@@ -347,7 +347,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Show when={store.popoverIsOpen}>
<div
class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-[252px] min-h-10
overflow-auto no-scrollbar flex flex-col p-2 pb-0 rounded-2xl
overflow-auto no-scrollbar flex flex-col p-2 pb-0 rounded-md
border border-border-base bg-surface-raised-stronger-non-alpha shadow-md"
>
<Show when={flat().length > 0} fallback={<div class="text-text-weak px-2">No matching files</div>}>
@@ -366,7 +366,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
{getDirectory(i)}
</span>
<span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
<Show when={!i.endsWith("/")}>
<span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
</Show>
</div>
</div>
<div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
@@ -380,7 +382,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSubmit={handleSubmit}
classList={{
"bg-surface-raised-stronger-non-alpha border border-border-strong-base": true,
"rounded-2xl overflow-clip focus-within:border-transparent focus-within:shadow-xs-border-select": true,
"rounded-md overflow-clip focus-within:border-transparent focus-within:shadow-xs-border-select": true,
[props.class ?? ""]: !!props.class,
}}
>
@@ -394,17 +396,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onInput={handleInput}
onKeyDown={handleKeyDown}
classList={{
"w-full p-3 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
"w-full px-5 py-3 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
"[&>[data-type=file]]:text-icon-info-active": true,
}}
/>
<Show when={!session.prompt.dirty()}>
<div class="absolute top-0 left-0 p-3 text-14-regular text-text-weak pointer-events-none">
<div class="absolute top-0 left-0 px-5 py-3 text-14-regular text-text-weak pointer-events-none">
Plan and build anything
</div>
</Show>
</div>
<div class="p-3 flex items-center justify-between">
<div class="relative p-3 flex items-center justify-between">
<div class="flex items-center justify-start gap-1">
<Select
options={local.agent.list().map((agent) => agent.name)}
@@ -487,7 +489,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
disabled={!session.prompt.dirty() && !session.working()}
icon={session.working() ? "stop" : "arrow-up"}
variant="primary"
class="rounded-full"
class="h-10 w-8 absolute right-2 bottom-2"
/>
</Tooltip>
</div>
@@ -0,0 +1,104 @@
import { useSession } from "@/context/session"
import { FileIcon } from "@/ui"
import { getDirectory, getFilename } from "@/utils"
import { Accordion, Button, Diff, DiffChanges, Icon, IconButton, Tooltip } from "@opencode-ai/ui"
import { For, Match, Show, Switch } from "solid-js"
import { StickyAccordionHeader } from "./sticky-accordion-header"
import { createStore } from "solid-js/store"
import { useLayout } from "@/context/layout"
export const SessionReview = (props: { split?: boolean; class?: string; hideExpand?: boolean }) => {
const layout = useLayout()
const session = useSession()
const [store, setStore] = createStore({
open: session.diffs().map((d) => d.file),
})
const handleChange = (open: string[]) => {
setStore("open", open)
}
const handleExpandOrCollapseAll = () => {
if (store.open.length > 0) {
setStore("open", [])
} else {
setStore(
"open",
session.diffs().map((d) => d.file),
)
}
}
return (
<div
classList={{
"flex flex-col gap-3 h-full overflow-y-auto no-scrollbar": true,
[props.class ?? ""]: !!props.class,
}}
>
<div class="sticky top-0 z-20 bg-background-stronger h-8 shrink-0 flex justify-between items-center self-stretch">
<div class="text-14-medium text-text-strong">Session changes</div>
<div class="flex items-center gap-x-4 pr-px">
<Button size="normal" icon="chevron-grabber-vertical" onClick={handleExpandOrCollapseAll}>
<Switch>
<Match when={store.open.length > 0}>Collapse all</Match>
<Match when={true}>Expand all</Match>
</Switch>
</Button>
<Show when={!props.hideExpand}>
<Tooltip value="Open in tab">
<IconButton
icon="expand"
variant="ghost"
onClick={() => {
layout.review.tab()
session.layout.setActiveTab("review")
}}
/>
</Tooltip>
</Show>
</div>
</div>
<Accordion multiple value={store.open} onChange={handleChange}>
<For each={session.diffs()}>
{(diff) => (
<Accordion.Item value={diff.file}>
<StickyAccordionHeader class="top-11 data-expanded:before:-top-11">
<Accordion.Trigger class="bg-background-stronger">
<div class="flex items-center justify-between w-full gap-5">
<div class="grow flex items-center gap-5 min-w-0">
<FileIcon node={{ path: diff.file, type: "file" }} class="shrink-0 size-4" />
<div class="flex grow min-w-0">
<Show when={diff.file.includes("/")}>
<span class="text-text-base truncate-start">{getDirectory(diff.file)}&lrm;</span>
</Show>
<span class="text-text-strong shrink-0">{getFilename(diff.file)}</span>
</div>
</div>
<div class="shrink-0 flex gap-4 items-center justify-end">
<DiffChanges changes={diff} />
<Icon name="chevron-grabber-vertical" size="small" />
</div>
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content>
<Diff
diffStyle={props.split ? "split" : "unified"}
before={{
name: diff.file!,
contents: diff.before!,
}}
after={{
name: diff.file!,
contents: diff.after!,
}}
/>
</Accordion.Content>
</Accordion.Item>
)}
</For>
</Accordion>
</div>
)
}
@@ -0,0 +1,17 @@
import { Accordion } from "@opencode-ai/ui"
import { ParentProps } from "solid-js"
export function StickyAccordionHeader(props: ParentProps<{ class?: string }>) {
return (
<Accordion.Header
classList={{
"sticky top-0 data-expanded:z-10": true,
"data-expanded:before:content-[''] data-expanded:before:z-[-10]": true,
"data-expanded:before:absolute data-expanded:before:inset-0 data-expanded:before:bg-background-stronger": true,
[props.class ?? ""]: !!props.class,
}}
>
{props.children}
</Accordion.Header>
)
}
@@ -0,0 +1,32 @@
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/client"
import { createSimpleContext } from "./helper"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup } from "solid-js"
export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
name: "GlobalSDK",
init: (props: { url: string }) => {
const abort = new AbortController()
const sdk = createOpencodeClient({
baseUrl: props.url,
signal: abort.signal,
})
const emitter = createGlobalEmitter<{
[key: string]: Event
}>()
sdk.global.event().then(async (events) => {
for await (const event of events.stream) {
// console.log("event", event)
emitter.emit(event.directory, event.payload)
}
})
onCleanup(() => {
abort.abort()
})
return { url: props.url, client: sdk, event: emitter }
},
})
@@ -0,0 +1,183 @@
import type {
Message,
Agent,
Provider,
Session,
Part,
Config,
Path,
File,
FileNode,
Project,
FileDiff,
Todo,
SessionStatus,
} from "@opencode-ai/sdk"
import { createStore, produce, reconcile } from "solid-js/store"
import { Binary } from "@/utils/binary"
import { createSimpleContext } from "./helper"
import { useGlobalSDK } from "./global-sdk"
type State = {
ready: boolean
provider: Provider[]
agent: Agent[]
project: Project
config: Config
path: Path
session: Session[]
session_status: {
[sessionID: string]: SessionStatus
}
session_diff: {
[sessionID: string]: FileDiff[]
}
todo: {
[sessionID: string]: Todo[]
}
limit: number
message: {
[sessionID: string]: Message[]
}
part: {
[messageID: string]: Part[]
}
node: FileNode[]
changes: File[]
}
export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimpleContext({
name: "GlobalSync",
init: () => {
const [globalStore, setGlobalStore] = createStore<{
ready: boolean
defaultProject?: Project // TODO: remove this when we can select projects
projects: Project[]
children: Record<string, State>
}>({
ready: false,
projects: [],
children: {},
})
const children: Record<string, ReturnType<typeof createStore<State>>> = {}
function child(directory: string) {
if (!children[directory]) {
setGlobalStore("children", directory, {
project: { id: "", worktree: "", time: { created: 0, initialized: 0 } },
config: {},
path: { state: "", config: "", worktree: "", directory: "" },
ready: false,
agent: [],
provider: [],
session: [],
session_status: {},
session_diff: {},
todo: {},
limit: 10,
message: {},
part: {},
node: [],
changes: [],
})
children[directory] = createStore(globalStore.children[directory])
}
return children[directory]
}
const sdk = useGlobalSDK()
sdk.event.listen((e) => {
const directory = e.name
const [store, setStore] = child(directory)
const event = e.details
switch (event.type) {
case "session.updated": {
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
if (result.found) {
setStore("session", result.index, reconcile(event.properties.info))
break
}
setStore(
"session",
produce((draft) => {
draft.splice(result.index, 0, event.properties.info)
}),
)
break
}
case "session.diff":
setStore("session_diff", event.properties.sessionID, event.properties.diff)
break
case "todo.updated":
setStore("todo", event.properties.sessionID, event.properties.todos)
break
case "session.status": {
setStore("session_status", event.properties.sessionID, event.properties.status)
break
}
case "message.updated": {
const messages = store.message[event.properties.info.sessionID]
if (!messages) {
setStore("message", event.properties.info.sessionID, [event.properties.info])
break
}
const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
if (result.found) {
setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
break
}
setStore(
"message",
event.properties.info.sessionID,
produce((draft) => {
draft.splice(result.index, 0, event.properties.info)
}),
)
break
}
case "message.part.updated": {
const part = event.properties.part
const parts = store.part[part.messageID]
if (!parts) {
setStore("part", part.messageID, [part])
break
}
const result = Binary.search(parts, part.id, (p) => p.id)
if (result.found) {
setStore("part", part.messageID, result.index, reconcile(part))
break
}
setStore(
"part",
part.messageID,
produce((draft) => {
draft.splice(result.index, 0, part)
}),
)
break
}
}
})
Promise.all([
sdk.client.project.list().then((x) =>
setGlobalStore(
"projects",
x.data!.filter((x) => !x.worktree.includes("opencode-test")),
),
),
// TODO: remove this when we can select projects
sdk.client.project.current().then((x) => setGlobalStore("defaultProject", x.data)),
]).then(() => setGlobalStore("ready", true))
return {
data: globalStore,
get ready() {
return globalStore.ready
},
child,
}
},
})
+75
View File
@@ -0,0 +1,75 @@
import { createStore } from "solid-js/store"
import { createMemo } from "solid-js"
import { createSimpleContext } from "./helper"
import { makePersisted } from "@solid-primitives/storage"
import { useGlobalSync } from "./global-sync"
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
name: "Layout",
init: () => {
const globalSync = useGlobalSync()
const [store, setStore] = makePersisted(
createStore({
projects: [] as { directory: string; expanded: boolean }[],
sidebar: {
opened: true,
width: 280,
},
review: {
state: "pane" as "pane" | "tab",
},
}),
{
name: "___default-layout",
},
)
return {
projects: {
list: createMemo(() =>
globalSync.data.defaultProject
? [{ directory: globalSync.data.defaultProject!.worktree, expanded: true }, ...store.projects]
: store.projects,
),
open(directory: string) {
if (store.projects.find((x) => x.directory === directory)) return
setStore("projects", (x) => [...x, { directory, expanded: true }])
},
close(directory: string) {
setStore("projects", (x) => x.filter((x) => x.directory !== directory))
},
expand(directory: string) {
setStore("projects", (x) => x.map((x) => (x.directory === directory ? { ...x, expanded: true } : x)))
},
collapse(directory: string) {
setStore("projects", (x) => x.map((x) => (x.directory === directory ? { ...x, expanded: false } : x)))
},
},
sidebar: {
opened: createMemo(() => store.sidebar.opened),
open() {
setStore("sidebar", "opened", true)
},
close() {
setStore("sidebar", "opened", false)
},
toggle() {
setStore("sidebar", "opened", (x) => !x)
},
width: createMemo(() => store.sidebar.width),
resize(width: number) {
setStore("sidebar", "width", width)
},
},
review: {
state: createMemo(() => store.review?.state ?? "closed"),
pane() {
setStore("review", "state", "pane")
},
tab() {
setStore("review", "state", "tab")
},
},
}
},
})
+2 -50
View File
@@ -5,7 +5,7 @@ import type { FileContent, FileNode, Model, Provider, File as FileStatus } from
import { createSimpleContext } from "./helper"
import { useSDK } from "./sdk"
import { useSync } from "./sync"
import { makePersisted } from "@solid-primitives/storage"
import { base64Encode } from "@/utils"
export type LocalFile = FileNode &
Partial<{
@@ -457,60 +457,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
})()
const layout = (() => {
const [store, setStore] = makePersisted(
createStore({
sidebar: {
opened: true,
width: 240,
},
review: {
state: "closed" as "open" | "closed" | "tab",
},
}),
{
name: "default-layout",
},
)
return {
sidebar: {
opened: createMemo(() => store.sidebar.opened),
open() {
setStore("sidebar", "opened", true)
},
close() {
setStore("sidebar", "opened", false)
},
toggle() {
setStore("sidebar", "opened", (x) => !x)
},
width: createMemo(() => store.sidebar.width),
resize(width: number) {
setStore("sidebar", "width", width)
},
},
review: {
state: createMemo(() => store.review?.state ?? "closed"),
open() {
setStore("review", "state", "open")
},
close() {
setStore("review", "state", "closed")
},
tab() {
setStore("review", "state", "tab")
},
},
}
})()
const result = {
slug: createMemo(() => base64Encode(sdk.directory)),
model,
agent,
file,
context,
layout,
}
return result
},
+8 -13
View File
@@ -2,36 +2,31 @@ import { createOpencodeClient, type Event } from "@opencode-ai/sdk/client"
import { createSimpleContext } from "./helper"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup } from "solid-js"
import { useGlobalSDK } from "./global-sdk"
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK",
init: (props: { url: string }) => {
init: (props: { directory: string }) => {
const globalSDK = useGlobalSDK()
const abort = new AbortController()
const sdk = createOpencodeClient({
baseUrl: props.url,
baseUrl: globalSDK.url,
signal: abort.signal,
fetch: (req) => {
// @ts-ignore
req.timeout = false
return fetch(req)
},
directory: props.directory,
})
const emitter = createGlobalEmitter<{
[key in Event["type"]]: Extract<Event, { type: key }>
}>()
sdk.event.subscribe().then(async (events) => {
for await (const event of events.stream) {
console.log("event", event.type)
emitter.emit(event.type, event)
}
globalSDK.event.on(props.directory, async (event) => {
emitter.emit(event.type, event)
})
onCleanup(() => {
abort.abort()
})
return { client: sdk, event: emitter }
return { directory: props.directory, client: sdk, event: emitter }
},
})
+25 -23
View File
@@ -3,15 +3,20 @@ import { createSimpleContext } from "./helper"
import { batch, createEffect, createMemo } from "solid-js"
import { useSync } from "./sync"
import { makePersisted } from "@solid-primitives/storage"
import { TextSelection, useLocal } from "./local"
import { TextSelection } from "./local"
import { pipe, sumBy } from "remeda"
import { AssistantMessage } from "@opencode-ai/sdk"
import { useParams } from "@solidjs/router"
import { base64Encode } from "@/utils"
export const { use: useSession, provider: SessionProvider } = createSimpleContext({
name: "Session",
init: (props: { sessionId?: string }) => {
init: () => {
const params = useParams()
const sync = useSync()
const local = useLocal()
const name = createMemo(
() => `___${base64Encode(sync.data.project.worktree)}/session${params.id ? "/" + params.id : ""}`,
)
const [store, setStore] = makePersisted(
createStore<{
@@ -30,17 +35,17 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
cursor: undefined,
}),
{
name: props.sessionId ?? "new-session",
name: name(),
},
)
createEffect(() => {
if (!props.sessionId) return
sync.session.sync(props.sessionId)
if (!params.id) return
sync.session.sync(params.id)
})
const info = createMemo(() => (props.sessionId ? sync.session.get(props.sessionId) : undefined))
const messages = createMemo(() => (props.sessionId ? (sync.data.message[props.sessionId] ?? []) : []))
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
const userMessages = createMemo(() =>
messages()
.filter((m) => m.role === "user")
@@ -53,16 +58,13 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
if (!store.messageId) return lastUserMessage()
return userMessages()?.find((m) => m.id === store.messageId)
})
const working = createMemo(() => {
if (!props.sessionId) return false
const last = lastUserMessage()
if (!last) return false
const assistantMessages = sync.data.message[props.sessionId]?.filter(
(m) => m.role === "assistant" && m.parentID == last?.id,
) as AssistantMessage[]
const error = assistantMessages?.find((m) => m?.error)?.error
return !last?.summary?.body && !error
})
const status = createMemo(
() =>
sync.data.session_status[params.id] ?? {
type: "idle",
},
)
const working = createMemo(() => status()?.type !== "idle")
const cost = createMemo(() => {
const total = pipe(
@@ -81,7 +83,7 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
const model = createMemo(() =>
last() ? sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined,
)
const diffs = createMemo(() => (props.sessionId ? (sync.data.session_diff[props.sessionId] ?? []) : []))
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
const tokens = createMemo(() => {
if (!last()) return
@@ -97,8 +99,11 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
})
return {
id: props.sessionId,
get id() {
return params.id
},
info,
status,
working,
diffs,
prompt: {
@@ -140,9 +145,6 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
setStore("tabs", "active", undefined)
return
}
if (tab.startsWith("file://")) {
await local.file.open(tab.replace("file://", ""))
}
if (tab !== "review") {
if (!store.tabs.opened.includes(tab)) {
setStore("tabs", "opened", [...store.tabs.opened, tab])
+6 -121
View File
@@ -1,133 +1,17 @@
import type {
Message,
Agent,
Provider,
Session,
Part,
Config,
Path,
File,
FileNode,
Project,
FileDiff,
Todo,
} from "@opencode-ai/sdk"
import { createStore, produce, reconcile } from "solid-js/store"
import type { Part } from "@opencode-ai/sdk"
import { produce } from "solid-js/store"
import { createMemo } from "solid-js"
import { Binary } from "@/utils/binary"
import { createSimpleContext } from "./helper"
import { useGlobalSync } from "./global-sync"
import { useSDK } from "./sdk"
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
init: () => {
const [store, setStore] = createStore<{
ready: boolean
provider: Provider[]
agent: Agent[]
project: Project
config: Config
path: Path
session: Session[]
session_diff: {
[sessionID: string]: FileDiff[]
}
todo: {
[sessionID: string]: Todo[]
}
limit: number
message: {
[sessionID: string]: Message[]
}
part: {
[messageID: string]: Part[]
}
node: FileNode[]
changes: File[]
}>({
project: { id: "", worktree: "", time: { created: 0, initialized: 0 } },
config: {},
path: { state: "", config: "", worktree: "", directory: "" },
ready: false,
agent: [],
provider: [],
session: [],
session_diff: {},
todo: {},
limit: 10,
message: {},
part: {},
node: [],
changes: [],
})
const globalSync = useGlobalSync()
const sdk = useSDK()
sdk.event.listen((e) => {
const event = e.details
switch (event.type) {
case "session.updated": {
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
if (result.found) {
setStore("session", result.index, reconcile(event.properties.info))
break
}
setStore(
"session",
produce((draft) => {
draft.splice(result.index, 0, event.properties.info)
}),
)
break
}
case "session.diff":
setStore("session_diff", event.properties.sessionID, event.properties.diff)
break
case "todo.updated":
setStore("todo", event.properties.sessionID, event.properties.todos)
break
case "message.updated": {
const messages = store.message[event.properties.info.sessionID]
if (!messages) {
setStore("message", event.properties.info.sessionID, [event.properties.info])
break
}
const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
if (result.found) {
setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
break
}
setStore(
"message",
event.properties.info.sessionID,
produce((draft) => {
draft.splice(result.index, 0, event.properties.info)
}),
)
break
}
case "message.part.updated": {
const part = sanitizePart(event.properties.part)
const parts = store.part[part.messageID]
if (!parts) {
setStore("part", part.messageID, [part])
break
}
const result = Binary.search(parts, part.id, (p) => p.id)
if (result.found) {
setStore("part", part.messageID, result.index, reconcile(part))
break
}
setStore(
"part",
part.messageID,
produce((draft) => {
draft.splice(result.index, 0, part)
}),
)
break
}
}
})
const [store, setStore] = globalSync.child(sdk.directory)
const load = {
project: () => sdk.client.project.current().then((x) => setStore("project", x.data!)),
@@ -142,6 +26,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
.slice(0, store.limit)
setStore("session", sessions)
}),
status: () => sdk.client.session.status().then((x) => setStore("session_status", x.data!)),
config: () => sdk.client.config.get().then((x) => setStore("config", x.data!)),
changes: () => sdk.client.file.status().then((x) => setStore("changes", x.data!)),
node: () => sdk.client.file.list({ query: { path: "/" } }).then((x) => setStore("node", x.data!)),
@@ -0,0 +1,20 @@
import { createSignal, onCleanup, onMount } from "solid-js"
import { isServer } from "solid-js/web"
export function createSessionSeen(key: string, delay = 1000) {
// 1. Initialize state based on storage (default to true on server to avoid flash)
const storageKey = `app:seen:${key}`
const [hasSeen] = createSignal(!isServer && sessionStorage.getItem(storageKey) === "true")
onMount(() => {
// 2. If we haven't seen it, mark it as seen for NEXT time
if (!hasSeen()) {
const timer = setTimeout(() => {
sessionStorage.setItem(storageKey, "true")
}, delay)
onCleanup(() => clearTimeout(timer))
}
})
return hasSeen
}
+6
View File
@@ -1 +1,7 @@
@import "@opencode-ai/ui/styles/tailwind";
:root {
a {
cursor: default;
}
}
+34 -13
View File
@@ -1,15 +1,18 @@
/* @refresh reload */
import "@/index.css"
import { render } from "solid-js/web"
import { Router, Route } from "@solidjs/router"
import { Router, Route, Navigate } from "@solidjs/router"
import { MetaProvider } from "@solidjs/meta"
import { Fonts, MarkedProvider } from "@opencode-ai/ui"
import { SDKProvider } from "./context/sdk"
import { SyncProvider } from "./context/sync"
import { LocalProvider } from "./context/local"
import { GlobalSyncProvider, useGlobalSync } from "./context/global-sync"
import Layout from "@/pages/layout"
import SessionLayout from "@/pages/session-layout"
import DirectoryLayout from "@/pages/directory-layout"
import Session from "@/pages/session"
import { LayoutProvider } from "./context/layout"
import { GlobalSDKProvider } from "./context/global-sdk"
import { SessionProvider } from "./context/session"
import { base64Encode } from "./utils"
import { createMemo, Show } from "solid-js"
const host = import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "127.0.0.1"
const port = import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"
@@ -30,20 +33,38 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
render(
() => (
<MarkedProvider>
<SDKProvider url={url}>
<SyncProvider>
<LocalProvider>
<GlobalSDKProvider url={url}>
<GlobalSyncProvider>
<LayoutProvider>
<MetaProvider>
<Fonts />
<Router root={Layout}>
<Route path={["/", "/session"]} component={SessionLayout}>
<Route path="/:id?" component={Session} />
<Route
path="/"
component={() => {
const globalSync = useGlobalSync()
const slug = createMemo(() => base64Encode(globalSync.data.defaultProject!.worktree))
return <Navigate href={`${slug()}/session`} />
}}
/>
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route
path="/session/:id?"
component={(p) => (
<Show when={p.params.id || true} keyed>
<SessionProvider>
<Session />
</SessionProvider>
</Show>
)}
/>
</Route>
</Router>
</MetaProvider>
</LocalProvider>
</SyncProvider>
</SDKProvider>
</LayoutProvider>
</GlobalSyncProvider>
</GlobalSDKProvider>
</MarkedProvider>
),
root!,
@@ -0,0 +1,23 @@
import { createMemo, type ParentProps } from "solid-js"
import { useParams } from "@solidjs/router"
import { SDKProvider } from "@/context/sdk"
import { SyncProvider } from "@/context/sync"
import { LocalProvider } from "@/context/local"
import { useGlobalSync } from "@/context/global-sync"
import { base64Decode } from "@/utils"
export default function Layout(props: ParentProps) {
const params = useParams()
const sync = useGlobalSync()
const directory = createMemo(() => {
const decoded = base64Decode(params.dir)
return sync.data.projects.find((x) => x.worktree === decoded)?.worktree ?? "/"
})
return (
<SDKProvider directory={directory()}>
<SyncProvider>
<LocalProvider>{props.children}</LocalProvider>
</SyncProvider>
</SDKProvider>
)
}
+20
View File
@@ -0,0 +1,20 @@
import { useGlobalSync } from "@/context/global-sync"
import { base64Encode, getFilename } from "@/utils"
import { For } from "solid-js"
import { A } from "@solidjs/router"
import { Button } from "@opencode-ai/ui"
export default function Home() {
const sync = useGlobalSync()
return (
<div class="flex flex-col gap-3">
<For each={sync.data.projects}>
{(project) => (
<Button as={A} href={base64Encode(project.worktree)}>
{getFilename(project.worktree)}
</Button>
)}
</For>
</div>
)
}
+163 -87
View File
@@ -1,116 +1,192 @@
import { Button, Tooltip, DiffChanges, IconButton } from "@opencode-ai/ui"
import { Button, Tooltip, DiffChanges, IconButton, Mark, Icon, Collapsible } from "@opencode-ai/ui"
import { createMemo, For, ParentProps, Show } from "solid-js"
import { DateTime } from "luxon"
import { useSync } from "@/context/sync"
import { A, useParams } from "@solidjs/router"
import { useLocal } from "@/context/local"
import { useLayout } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync"
import { base64Encode, getFilename } from "@/utils"
export default function Layout(props: ParentProps) {
const params = useParams()
const sync = useSync()
const local = useLocal()
const globalSync = useGlobalSync()
const layout = useLayout()
const handleOpenProject = async () => {
// layout.projects.open(dir.)
}
return (
<div class="relative h-screen flex flex-col">
<header class="hidden h-12 shrink-0 bg-background-strong border-b border-border-weak-base"></header>
<div class="h-[calc(100vh-0rem)] flex">
<header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base">
<A
href="/"
classList={{
"w-12 shrink-0 px-4 py-3.5": true,
"flex items-center justify-start self-stretch": true,
"border-r border-border-weak-base": true,
}}
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
>
<Mark class="shrink-0" />
</A>
</header>
<div class="h-[calc(100vh-3rem)] flex">
<div
classList={{
"@container w-14 pb-4 shrink-0 bg-background-weak": true,
"flex flex-col items-start self-stretch justify-between": true,
"@container w-12 pb-5 shrink-0 bg-background-base": true,
"flex flex-col gap-5.5 items-start self-stretch justify-between": true,
"border-r border-border-weak-base": true,
"w-70": local.layout.sidebar.opened(),
}}
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
>
<div class="flex flex-col justify-center items-start gap-4 self-stretch py-2 overflow-hidden mx-auto @[4rem]:mx-0">
<div class="h-8 shrink-0 flex items-center self-stretch px-3">
<Tooltip placement="right" value="Collapse sidebar">
<IconButton icon="layout-left" variant="ghost" size="large" onClick={local.layout.sidebar.toggle} />
</Tooltip>
</div>
<div class="w-full px-3">
<Button as={A} href="/session" class="hidden @[4rem]:flex w-full" size="large" icon="edit-small-2">
New Session
<div class="grow flex flex-col items-start self-stretch gap-4 p-2 min-h-0">
<Tooltip class="shrink-0" placement="right" value="Toggle sidebar" inactive={layout.sidebar.opened()}>
<Button
variant="ghost"
size="large"
class="group/sidebar-toggle shrink-0 w-full text-left justify-start"
onClick={layout.sidebar.toggle}
>
<div class="relative -ml-px flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
name={layout.sidebar.opened() ? "layout-left" : "layout-right"}
size="small"
class="group-hover/sidebar-toggle:hidden"
/>
<Icon
name={layout.sidebar.opened() ? "layout-left-partial" : "layout-right-partial"}
size="small"
class="hidden group-hover/sidebar-toggle:inline-block"
/>
<Icon
name={layout.sidebar.opened() ? "layout-left-full" : "layout-right-full"}
size="small"
class="hidden group-active/sidebar-toggle:inline-block"
/>
</div>
<Show when={layout.sidebar.opened()}>
<div class="hidden group-hover/sidebar-toggle:block group-active/sidebar-toggle:block text-text-base">
Toggle sidebar
</div>
</Show>
</Button>
<Tooltip placement="right" value="New session">
<IconButton as={A} href="/session" icon="edit-small-2" size="large" class="@[4rem]:hidden" />
</Tooltip>
</div>
<div class="hidden @[4rem]:flex size-full overflow-y-auto no-scrollbar flex-col flex-1 px-3">
<nav class="w-full">
<For each={sync.data.session}>
{(session) => {
const updated = createMemo(() => DateTime.fromMillis(session.time.updated))
</Tooltip>
<div class="flex flex-col justify-center items-start gap-4 self-stretch min-h-0">
<div class="hidden @[4rem]:flex size-full flex-col grow overflow-y-auto no-scrollbar">
<For each={layout.projects.list()}>
{(project) => {
const [store] = globalSync.child(project.directory)
const slug = createMemo(() => base64Encode(project.directory))
return (
<A
data-active={session.id === params.id}
href={`/session/${session.id}`}
class="group/session focus:outline-none cursor-default"
>
<Tooltip placement="right" value={session.title}>
<div
class="w-full mb-1.5 px-3 py-1 rounded-md
group-data-[active=true]/session:bg-surface-raised-base-hover
group-hover/session:bg-surface-raised-base-hover
group-focus/session:bg-surface-raised-base-hover"
>
<div class="flex items-center self-stretch gap-6 justify-between">
<span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
{session.title}
</span>
<span class="text-12-regular text-text-weak text-right whitespace-nowrap">
{Math.abs(updated().diffNow().as("seconds")) < 60
? "Now"
: updated()
.toRelative({ style: "short", unit: ["days", "hours", "minutes"] })
?.replace(" ago", "")
?.replace(/ days?/, "d")
?.replace(" min.", "m")
?.replace(" hr.", "h")}
</span>
</div>
<div class="flex justify-between items-center self-stretch">
<span class="text-12-regular text-text-weak">{`${session.summary?.files || "No"} file${session.summary?.files !== 1 ? "s" : ""} changed`}</span>
<Show when={session.summary}>{(summary) => <DiffChanges changes={summary()} />}</Show>
</div>
</div>
</Tooltip>
</A>
<Collapsible variant="ghost" defaultOpen class="gap-2">
<Button
as={"div"}
variant="ghost"
class="flex items-center justify-between gap-3 w-full h-8 pl-2 pr-2.25 self-stretch"
>
<Collapsible.Trigger class="p-0 text-left text-14-medium text-text-strong grow min-w-0 truncate">
{getFilename(project.directory)}
</Collapsible.Trigger>
<IconButton as={A} href={`${slug()}/session`} icon="plus-small" size="normal" />
</Button>
<Collapsible.Content>
<nav class="w-full flex flex-col gap-1.5">
<For each={store.session}>
{(session) => {
const updated = createMemo(() => DateTime.fromMillis(session.time.updated))
return (
<A
data-active={session.id === params.id}
href={`${slug()}/session/${session.id}`}
class="group/session focus:outline-none cursor-default"
>
<Tooltip placement="right" value={session.title}>
<div
class="w-full px-2 py-1 rounded-md
group-data-[active=true]/session:bg-surface-raised-base-hover
group-hover/session:bg-surface-raised-base-hover
group-focus/session:bg-surface-raised-base-hover"
>
<div class="flex items-center self-stretch gap-6 justify-between">
<span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
{session.title}
</span>
<span class="text-12-regular text-text-weak text-right whitespace-nowrap">
{Math.abs(updated().diffNow().as("seconds")) < 60
? "Now"
: updated()
.toRelative({ style: "short", unit: ["days", "hours", "minutes"] })
?.replace(" ago", "")
?.replace(/ days?/, "d")
?.replace(" min.", "m")
?.replace(" hr.", "h")}
</span>
</div>
<div class="hidden _flex justify-between items-center self-stretch">
<span class="text-12-regular text-text-weak">{`${session.summary?.files || "No"} file${session.summary?.files !== 1 ? "s" : ""} changed`}</span>
<Show when={session.summary}>
{(summary) => <DiffChanges changes={summary()} />}
</Show>
</div>
</div>
</Tooltip>
</A>
)
}}
</For>
</nav>
{/* <Show when={sync.session.more()}> */}
{/* <button */}
{/* class="shrink-0 self-start p-3 text-12-medium text-text-weak hover:text-text-strong" */}
{/* onClick={() => sync.session.fetch()} */}
{/* > */}
{/* Show more */}
{/* </button> */}
{/* </Show> */}
</Collapsible.Content>
</Collapsible>
)
}}
</For>
</nav>
<Show when={sync.session.more()}>
<button
class="shrink-0 self-start p-3 text-12-medium text-text-weak hover:text-text-strong"
onClick={() => sync.session.fetch()}
>
Show more
</button>
</Show>
</div>
</div>
</div>
<div class="flex flex-col items-start shrink-0 px-3 py-1 mx-auto @[4rem]:mx-0">
<Button
as={"a"}
href="https://opencode.ai/desktop-feedback"
target="_blank"
class="hidden @[4rem]:flex w-full text-12-medium text-text-base stroke-[1.5px]"
variant="ghost"
icon="speech-bubble"
>
Share feedback
</Button>
<Tooltip placement="right" value="Share feedback">
<IconButton
<div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
<Tooltip placement="right" value="Open project" inactive={layout.sidebar.opened()}>
<Button
disabled
class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px]"
variant="ghost"
size="large"
icon="folder-add-left"
onClick={handleOpenProject}
>
<Show when={layout.sidebar.opened()}>Open project</Show>
</Button>
</Tooltip>
<Tooltip placement="right" value="Settings" inactive={layout.sidebar.opened()}>
<Button
disabled
class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px]"
variant="ghost"
size="large"
icon="settings-gear"
>
<Show when={layout.sidebar.opened()}>Settings</Show>
</Button>
</Tooltip>
<Tooltip placement="right" value="Share feedback" inactive={layout.sidebar.opened()}>
<Button
as={"a"}
href="https://opencode.ai/desktop-feedback"
target="_blank"
icon="speech-bubble"
class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px]"
variant="ghost"
size="large"
class="@[4rem]:hidden stroke-[1.5px]"
/>
icon="bubble-5"
>
<Show when={layout.sidebar.opened()}>Share feedback</Show>
</Button>
</Tooltip>
</div>
</div>
@@ -1,12 +0,0 @@
import { Show, type ParentProps } from "solid-js"
import { SessionProvider } from "@/context/session"
import { useParams } from "@solidjs/router"
export default function Layout(props: ParentProps) {
const params = useParams()
return (
<Show when={params.id || true} keyed>
<SessionProvider sessionId={params.id}>{props.children}</SessionProvider>
</Show>
)
}
+175 -272
View File
@@ -13,7 +13,6 @@ import {
Code,
Tooltip,
ProgressCircle,
Button,
} from "@opencode-ai/ui"
import { FileIcon } from "@/ui"
import { MessageProgress } from "@/components/message-progress"
@@ -44,14 +43,19 @@ import {
useDragDropContext,
} from "@thisbeyond/solid-dnd"
import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
import type { JSX, ParentProps } from "solid-js"
import type { JSX } from "solid-js"
import { useSync } from "@/context/sync"
import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk"
import { Markdown } from "@opencode-ai/ui"
import { Spinner } from "@/components/spinner"
import { useSession } from "@/context/session"
import { StickyAccordionHeader } from "@/components/sticky-accordion-header"
import { SessionReview } from "@/components/session-review"
import { useLayout } from "@/context/layout"
import { createSessionSeen } from "@/hooks/create-session-seen"
export default function Page() {
const layout = useLayout()
const local = useLocal()
const sync = useSync()
const session = useSession()
@@ -83,6 +87,15 @@ export default function Page() {
setStore("fileSelectOpen", true)
return
}
if (event.ctrlKey && event.key.toLowerCase() === "t") {
event.preventDefault()
const currentTheme = localStorage.getItem("theme") ?? "oc-1"
const themes = ["oc-1", "oc-2-paper"]
const nextTheme = themes[(themes.indexOf(currentTheme) + 1) % themes.length]
localStorage.setItem("theme", nextTheme)
document.documentElement.setAttribute("data-theme", nextTheme)
return
}
const focused = document.activeElement === inputRef
if (focused) {
@@ -165,10 +178,16 @@ export default function Page() {
setStore("activeDraggable", undefined)
}
const FileVisual = (props: { file: LocalFile }): JSX.Element => {
const FileVisual = (props: { file: LocalFile; active?: boolean }): JSX.Element => {
return (
<div class="flex items-center gap-x-1.5">
<FileIcon node={props.file} class="grayscale-100 group-data-[selected]/tab:grayscale-0" />
<FileIcon
node={props.file}
classList={{
"grayscale-100 group-data-[selected]/tab:grayscale-0": !props.active,
"grayscale-0": props.active,
}}
/>
<span
classList={{
"text-14-medium": true,
@@ -216,18 +235,15 @@ export default function Page() {
// @ts-ignore
<div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
<div class="relative h-full">
<Tabs.Trigger value={props.tab} class="group/tab pl-3 pr-1" onClick={() => props.onTabClick(props.tab)}>
<Tabs.Trigger
value={props.tab}
closeButton={<IconButton icon="close" variant="ghost" onClick={() => props.onTabClose(props.tab)} />}
hideCloseButton
onClick={() => props.onTabClick(props.tab)}
>
<Switch>
<Match when={file()}>{(f) => <FileVisual file={f()} />}</Match>
</Switch>
<IconButton
icon="close"
class="mt-0.5 opacity-0 group-data-[selected]/tab:opacity-100
hover:bg-transparent
hover:opacity-100 group-hover/tab:opacity-100"
variant="ghost"
onClick={() => props.onTabClose(props.tab)}
/>
</Tabs.Trigger>
</div>
</div>
@@ -277,38 +293,40 @@ export default function Page() {
<Tabs value={session.layout.tabs.active ?? "chat"} onChange={session.layout.openTab}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List>
<Tabs.Trigger value="chat" class="flex gap-x-4 items-center">
<div>Chat</div>
<Tooltip
value={`${new Intl.NumberFormat("en-US", {
notation: "compact",
compactDisplay: "short",
}).format(session.usage.tokens() ?? 0)} Tokens`}
class="flex items-center gap-1.5"
>
<ProgressCircle percentage={session.usage.context() ?? 0} />
<div class="text-14-regular text-text-weak text-left w-7">{session.usage.context() ?? 0}%</div>
</Tooltip>
<Tabs.Trigger value="chat">
<div class="flex gap-x-[17px] items-center">
<div>Session</div>
<Tooltip
value={`${new Intl.NumberFormat("en-US", {
notation: "compact",
compactDisplay: "short",
}).format(session.usage.tokens() ?? 0)} Tokens`}
class="flex items-center gap-1.5"
>
<ProgressCircle percentage={session.usage.context() ?? 0} />
<div class="text-14-regular text-text-weak text-left w-7">{session.usage.context() ?? 0}%</div>
</Tooltip>
</div>
</Tabs.Trigger>
<Show when={local.layout.review.state() === "tab" && session.diffs().length}>
<Tabs.Trigger value="review" class="flex gap-3 items-center group/tab pr-1">
<Show when={session.diffs()}>
<DiffChanges changes={session.diffs()} variant="bars" />
</Show>
<div class="flex items-center gap-1.5">
<div>Review</div>
<Show when={session.info()?.summary?.files}>
<div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
{session.info()?.summary?.files ?? 0}
</div>
<Show when={layout.review.state() === "tab" && session.diffs().length}>
<Tabs.Trigger
value="review"
closeButton={
<IconButton icon="collapse" size="normal" variant="ghost" onClick={layout.review.pane} />
}
>
<div class="flex items-center gap-3">
<Show when={session.diffs()}>
<DiffChanges changes={session.diffs()} variant="bars" />
</Show>
<IconButton
icon="close"
class="mt-0.5 -ml-1 opacity-0 group-data-[selected]/tab:opacity-100
hover:bg-transparent hover:opacity-100 group-hover/tab:opacity-100"
variant="ghost"
onClick={local.layout.review.close}
/>
<div class="flex items-center gap-1.5">
<div>Review</div>
<Show when={session.info()?.summary?.files}>
<div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
{session.info()?.summary?.files ?? 0}
</div>
</Show>
</div>
</div>
</Tabs.Trigger>
</Show>
@@ -333,115 +351,110 @@ export default function Page() {
<div
classList={{
"w-full flex-1 min-h-0": true,
grid: local.layout.review.state() !== "open",
flex: local.layout.review.state() === "open",
grid: layout.review.state() === "tab",
flex: layout.review.state() === "pane",
}}
>
<div class="relative shrink-0 px-6 py-2 flex flex-col gap-6 flex-1 min-h-0 w-full max-w-xl mx-auto">
<div class="relative shrink-0 px-6 py-3 flex flex-col gap-6 flex-1 min-h-0 w-full max-w-xl mx-auto">
<Switch>
<Match when={session.id}>
<div class="h-8 flex shrink-0 self-stretch items-center justify-end">
<Show when={local.layout.review.state() === "closed" && session.diffs().length}>
<Button icon="layout-right" onClick={local.layout.review.open}>
Review
</Button>
</Show>
</div>
<div
classList={{
"flex-1 min-h-0 pb-20": true,
"flex items-start justify-start": local.layout.review.state() === "open",
"flex items-start justify-start": layout.review.state() === "pane",
}}
>
<Show when={session.messages.user().length > 1}>
<ul
role="list"
classList={{
"mr-8 shrink-0 flex flex-col items-start": true,
"absolute right-full w-60 @7xl:gap-2": local.layout.review.state() !== "open",
"mt-1": local.layout.review.state() === "open",
}}
>
<For each={session.messages.user()}>
{(message) => {
const assistantMessages = createMemo(() => {
if (!session.id) return []
return sync.data.message[session.id]?.filter(
(m) => m.role === "assistant" && m.parentID == message.id,
) as AssistantMessageType[]
})
const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
const working = createMemo(() => !message.summary?.body && !error())
{(_) => {
const expanded = createMemo(() => layout.review.state() === "tab" || !session.diffs().length)
const handleClick = () => session.messages.setActive(message.id)
return (
<ul
role="list"
classList={{
"mr-8 shrink-0 flex flex-col items-start": true,
"absolute right-full w-60 mt-3 @7xl:gap-2 @7xl:mt-1": expanded(),
"mt-3": !expanded(),
}}
>
<For each={session.messages.user()}>
{(message) => {
const working = createMemo(
() => message.id === session.messages.last()?.id && session.working(),
)
const handleClick = () => session.messages.setActive(message.id)
return (
<li
classList={{
"group/li flex items-center self-stretch justify-end": true,
"@7xl:justify-start": local.layout.review.state() !== "open",
}}
>
<Tooltip
placement="right"
gutter={8}
value={
<div class="flex items-center gap-2">
<DiffChanges changes={message.summary?.diffs ?? []} variant="bars" />
{message.summary?.title}
</div>
}
>
<button
data-active={session.messages.active()?.id === message.id}
onClick={handleClick}
return (
<li
classList={{
"group/tick flex items-center justify-start h-2 w-8 -mr-3": true,
"data-[active=true]:[&>div]:bg-icon-strong-base data-[active=true]:[&>div]:w-full": true,
"@7xl:hidden": local.layout.review.state() !== "open",
"group/li flex items-center self-stretch justify-end": true,
"@7xl:justify-start": expanded(),
}}
>
<div class="h-px w-5 bg-icon-base group-hover/tick:w-full group-hover/tick:bg-icon-strong-base" />
</button>
</Tooltip>
<button
classList={{
"hidden items-center self-stretch w-full gap-x-2 cursor-default": true,
"@7xl:flex": local.layout.review.state() !== "open",
}}
onClick={handleClick}
>
<Switch>
<Match when={working()}>
<Spinner class="text-text-base shrink-0 w-[18px] aspect-square" />
</Match>
<Match when={true}>
<DiffChanges changes={message.summary?.diffs ?? []} variant="bars" />
</Match>
</Switch>
<div
data-active={session.messages.active()?.id === message.id}
classList={{
"text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true,
"text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
}}
>
<Show when={message.summary?.title} fallback="New message">
{message.summary?.title}
</Show>
</div>
</button>
</li>
)
}}
</For>
</ul>
<Tooltip
placement="right"
gutter={8}
value={
<div class="flex items-center gap-2">
<DiffChanges changes={message.summary?.diffs ?? []} variant="bars" />
{message.summary?.title}
</div>
}
>
<button
data-active={session.messages.active()?.id === message.id}
onClick={handleClick}
classList={{
"group/tick flex items-center justify-start h-2 w-8 -mr-3": true,
"data-[active=true]:[&>div]:bg-icon-strong-base data-[active=true]:[&>div]:w-full": true,
"@7xl:hidden": expanded(),
}}
>
<div class="h-px w-5 bg-icon-base group-hover/tick:w-full group-hover/tick:bg-icon-strong-base" />
</button>
</Tooltip>
<button
classList={{
"hidden items-center self-stretch w-full gap-x-2 cursor-default": true,
"@7xl:flex": expanded(),
}}
onClick={handleClick}
>
<Switch>
<Match when={working()}>
<Spinner class="text-text-base shrink-0 w-[18px] aspect-square" />
</Match>
<Match when={true}>
<DiffChanges changes={message.summary?.diffs ?? []} variant="bars" />
</Match>
</Switch>
<div
data-active={session.messages.active()?.id === message.id}
classList={{
"text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true,
"text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
}}
>
<Show when={message.summary?.title} fallback="New message">
{message.summary?.title}
</Show>
</div>
</button>
</li>
)
}}
</For>
</ul>
)
}}
</Show>
<div ref={messageScrollElement} class="grow size-full min-w-0 overflow-y-auto no-scrollbar">
<For each={session.messages.user()}>
{(message) => {
const isActive = createMemo(() => session.messages.active()?.id === message.id)
const [titled, setTitled] = createSignal(!!message.summary?.title)
const titleSeen = createSessionSeen(`message-title-${message.id}`)
const contentSeen = createSessionSeen(`message-content-${message.id}`)
const [titled, setTitled] = createSignal(titleSeen())
const assistantMessages = createMemo(() => {
if (!session.id) return []
return sync.data.message[session.id]?.filter(
@@ -449,7 +462,6 @@ export default function Page() {
) as AssistantMessageType[]
})
const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
const [completed, setCompleted] = createSignal(!!message.summary?.body || !!error())
const [detailsExpanded, setDetailsExpanded] = createSignal(false)
const parts = createMemo(() => sync.data.part[message.id])
const hasToolPart = createMemo(() =>
@@ -457,17 +469,21 @@ export default function Page() {
?.flatMap((m) => sync.data.part[m.id])
.some((p) => p?.type === "tool"),
)
const working = createMemo(() => !message.summary?.body && !error())
const working = createMemo(
() => message.id === session.messages.last()?.id && session.working(),
)
const initialCompleted = !(message.id === session.messages.last()?.id && session.working())
const [completed, setCompleted] = createSignal(initialCompleted)
// allowing time for the animations to finish
createEffect(() => {
if (titleSeen()) return
const title = message.summary?.title
setTimeout(() => setTitled(!!title), 10_000)
if (title) setTimeout(() => setTitled(true), 10_000)
})
createEffect(() => {
const summary = message.summary?.body
const complete = !!summary || !!error()
setTimeout(() => setCompleted(complete), 1200)
const completed = !working()
setTimeout(() => setCompleted(completed), 1200)
})
return (
@@ -477,7 +493,7 @@ export default function Page() {
class="flex flex-col items-start self-stretch gap-8 pb-20"
>
{/* Title */}
<div class="flex flex-col items-start gap-2 self-stretch sticky top-0 bg-background-stronger z-20 pb-1">
<div class="flex items-center gap-2 self-stretch sticky top-0 bg-background-stronger z-20 h-8">
<div class="w-full text-14-medium text-text-strong">
<Show
when={titled()}
@@ -495,9 +511,7 @@ export default function Page() {
</Show>
</div>
</div>
<div class="-mt-9">
<Message message={message} parts={parts()} />
</div>
<Message message={message} parts={parts()} />
{/* Summary */}
<Show when={completed()}>
<div class="w-full flex flex-col gap-6 items-start self-stretch">
@@ -513,7 +527,7 @@ export default function Page() {
<Markdown
classList={{
"text-14-regular": !!message.summary?.diffs?.length,
"[&>*]:fade-up-text": !message.summary?.diffs?.length,
"[&>*]:fade-up-text": !message.summary?.diffs?.length && !contentSeen(),
}}
text={summary()}
/>
@@ -524,7 +538,7 @@ export default function Page() {
<For each={message.summary?.diffs ?? []}>
{(diff) => (
<Accordion.Item value={diff.file}>
<StickyAccordionHeader class="top-10 data-expanded:before:-top-10 ">
<StickyAccordionHeader class="top-10 data-expanded:before:-top-10">
<Accordion.Trigger>
<div class="flex items-center justify-between w-full gap-5">
<div class="grow flex items-center gap-5 min-w-0">
@@ -653,127 +667,25 @@ export default function Page() {
/>
</div>
</div>
<Show when={local.layout.review.state() === "open"}>
<Show when={layout.review.state() === "pane" && session.diffs().length}>
<div
classList={{
"relative grow px-6 py-2 w-full flex flex-col gap-6 flex-1 min-h-0 border-l border-border-weak-base": true,
"relative grow px-6 py-3 flex-1 min-h-0 border-l border-border-weak-base": true,
}}
>
<div class="h-8 w-full flex items-center justify-between shrink-0 self-stretch">
<div class="flex items-center gap-x-3">
<Tooltip value="Close">
<IconButton icon="align-right" variant="ghost" onClick={local.layout.review.close} />
</Tooltip>
<Tooltip value="Open in tab">
<IconButton
icon="expand"
variant="ghost"
onClick={() => {
local.layout.review.tab()
session.layout.setActiveTab("review")
}}
/>
</Tooltip>
</div>
</div>
<div class="text-14-medium text-text-strong">All changes</div>
<div class="h-full pb-40 overflow-y-auto no-scrollbar">
<Accordion class="w-full" multiple>
<For each={session.diffs()}>
{(diff) => (
<Accordion.Item value={diff.file} defaultOpen>
<StickyAccordionHeader>
<Accordion.Trigger>
<div class="flex items-center justify-between w-full gap-5">
<div class="grow flex items-center gap-5 min-w-0">
<FileIcon node={{ path: diff.file, type: "file" }} class="shrink-0 size-4" />
<div class="flex grow min-w-0">
<Show when={diff.file.includes("/")}>
<span class="text-text-base truncate-start">
{getDirectory(diff.file)}&lrm;
</span>
</Show>
<span class="text-text-strong shrink-0">{getFilename(diff.file)}</span>
</div>
</div>
<div class="shrink-0 flex gap-4 items-center justify-end">
<DiffChanges changes={diff} />
<Icon name="chevron-grabber-vertical" size="small" />
</div>
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content>
<Diff
before={{
name: diff.file!,
contents: diff.before!,
}}
after={{
name: diff.file!,
contents: diff.after!,
}}
/>
</Accordion.Content>
</Accordion.Item>
)}
</For>
</Accordion>
</div>
<SessionReview />
</div>
</Show>
</div>
</Tabs.Content>
<Show when={local.layout.review.state() === "tab" && session.diffs().length}>
<Tabs.Content value="review" class="select-text flex flex-col h-full overflow-hidden mt-8">
<Show when={layout.review.state() === "tab" && session.diffs().length}>
<Tabs.Content value="review" class="select-text flex flex-col h-full overflow-hidden">
<div
classList={{
"relative px-6 py-2 w-full flex flex-col gap-6 flex-1 min-h-0 overflow-hidden": true,
"relative px-6 py-3 flex-1 min-h-0 overflow-hidden": true,
}}
>
<div class="text-14-medium text-text-strong shrink-0">All changes</div>
<div class="flex-1 min-h-0 pb-40 overflow-y-auto no-scrollbar">
<Accordion class="w-full" multiple>
<For each={session.diffs()}>
{(diff) => (
<Accordion.Item value={diff.file} defaultOpen>
<StickyAccordionHeader>
<Accordion.Trigger>
<div class="flex items-center justify-between w-full gap-5">
<div class="grow flex items-center gap-5 min-w-0">
<FileIcon node={{ path: diff.file, type: "file" }} class="shrink-0 size-4" />
<div class="flex grow min-w-0">
<Show when={diff.file.includes("/")}>
<span class="text-text-base truncate-start">{getDirectory(diff.file)}&lrm;</span>
</Show>
<span class="text-text-strong shrink-0">{getFilename(diff.file)}</span>
</div>
</div>
<div class="shrink-0 flex gap-4 items-center justify-end">
<DiffChanges changes={diff} />
<Icon name="chevron-grabber-vertical" size="small" />
</div>
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content>
<Diff
diffStyle="split"
before={{
name: diff.file!,
contents: diff.before!,
}}
after={{
name: diff.file!,
contents: diff.after!,
}}
/>
</Accordion.Content>
</Accordion.Item>
)}
</For>
</Accordion>
</div>
<SessionReview split hideExpand class="pb-40" />
</div>
</Tabs.Content>
</Show>
@@ -819,8 +731,8 @@ export default function Page() {
},
)
return (
<div class="relative px-3 h-10 flex items-center bg-background-base border-x border-border-weak-base border-b border-b-transparent">
<Show when={file()}>{(f) => <FileVisual file={f()} />}</Show>
<div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
<Show when={file()}>{(f) => <FileVisual active file={f()} />}</Show>
</div>
)
}}
@@ -828,7 +740,7 @@ export default function Page() {
</DragOverlay>
</DragDropProvider>
<Show when={session.layout.tabs.active}>
<div class="absolute inset-x-0 px-6 max-w-2xl flex flex-col justify-center items-center z-50 mx-auto bottom-8">
<div class="absolute inset-x-0 px-6 max-w-2xl flex flex-col justify-center items-center z-50 mx-auto bottom-6">
<PromptInput
ref={(el) => {
inputRef = el
@@ -870,7 +782,13 @@ export default function Page() {
items={local.file.searchFiles}
key={(x) => x}
onOpenChange={(open) => setStore("fileSelectOpen", open)}
onSelect={(x) => (x ? session.layout.openTab("file://" + x) : undefined)}
onSelect={(x) => {
if (x) {
local.file.open(x)
return session.layout.openTab("file://" + x)
}
return undefined
}}
>
{(i) => (
<div
@@ -895,18 +813,3 @@ export default function Page() {
</div>
)
}
function StickyAccordionHeader(props: ParentProps<{ class?: string }>) {
return (
<Accordion.Header
classList={{
"sticky top-0 data-expanded:z-10": true,
"data-expanded:before:content-[''] data-expanded:before:z-[-10]": true,
"data-expanded:before:absolute data-expanded:before:inset-0 data-expanded:before:bg-background-stronger": true,
[props.class ?? ""]: !!props.class,
}}
>
{props.children}
</Accordion.Header>
)
}
+2 -1
View File
@@ -9,12 +9,13 @@ export type FileIconProps = JSX.GSVGAttributes<SVGSVGElement> & {
}
export const FileIcon: Component<FileIconProps> = (props) => {
const [local, rest] = splitProps(props, ["node", "class", "expanded"])
const [local, rest] = splitProps(props, ["node", "class", "classList", "expanded"])
const name = createMemo(() => chooseIconName(local.node.path, local.node.type, local.expanded || false))
return (
<svg
{...rest}
classList={{
...(local.classList ?? {}),
"shrink-0 size-4": true,
[local.class ?? ""]: !!local.class,
}}
+7
View File
@@ -0,0 +1,7 @@
export function base64Encode(value: string) {
return btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
}
export function base64Decode(value: string) {
return atob(value.replace(/-/g, "+").replace(/_/g, "/"))
}
+1
View File
@@ -1,2 +1,3 @@
export * from "./path"
export * from "./dom"
export * from "./encode"
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The AI coding agent built for the terminal"
version = "1.0.61"
version = "1.0.85"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/sst/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.61/opencode-darwin-arm64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.85/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.61/opencode-darwin-x64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.85/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.61/opencode-linux-arm64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.85/opencode-linux-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.61/opencode-linux-x64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.85/opencode-linux-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.61/opencode-windows-x64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.85/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.0.61",
"version": "1.0.85",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+81 -58
View File
@@ -1,61 +1,84 @@
#!/bin/sh
set -e
#!/usr/bin/env node
if [ -n "$OPENCODE_BIN_PATH" ]; then
resolved="$OPENCODE_BIN_PATH"
else
# Get the real path of this script, resolving any symlinks
script_path="$0"
while [ -L "$script_path" ]; do
link_target="$(readlink "$script_path")"
case "$link_target" in
/*) script_path="$link_target" ;;
*) script_path="$(dirname "$script_path")/$link_target" ;;
esac
done
script_dir="$(dirname "$script_path")"
script_dir="$(cd "$script_dir" && pwd)"
# Map platform names
case "$(uname -s)" in
Darwin) platform="darwin" ;;
Linux) platform="linux" ;;
MINGW*|CYGWIN*|MSYS*) platform="win32" ;;
*) platform="$(uname -s | tr '[:upper:]' '[:lower:]')" ;;
esac
# Map architecture names
case "$(uname -m)" in
x86_64|amd64) arch="x64" ;;
aarch64) arch="arm64" ;;
armv7l) arch="arm" ;;
*) arch="$(uname -m)" ;;
esac
name="opencode-${platform}-${arch}"
binary="opencode"
[ "$platform" = "win32" ] && binary="opencode.exe"
# Search for the binary starting from real script location
resolved=""
current_dir="$script_dir"
while [ "$current_dir" != "/" ]; do
candidate="$current_dir/node_modules/$name/bin/$binary"
if [ -f "$candidate" ]; then
resolved="$candidate"
break
fi
current_dir="$(dirname "$current_dir")"
done
if [ -z "$resolved" ]; then
printf "It seems that your package manager failed to install the right version of the opencode CLI for your platform. You can try manually installing the \"%s\" package\n" "$name" >&2
exit 1
fi
fi
const childProcess = require("child_process")
const fs = require("fs")
const path = require("path")
const os = require("os")
# Handle SIGINT gracefully
trap '' INT
function run(target) {
const result = childProcess.spawnSync(target, process.argv.slice(2), {
stdio: "inherit",
})
if (result.error) {
console.error(result.error.message)
process.exit(1)
}
const code = typeof result.status === "number" ? result.status : 0
process.exit(code)
}
# Execute the binary with all arguments
exec "$resolved" "$@"
const envPath = process.env.OPENCODE_BIN_PATH
if (envPath) {
run(envPath)
}
const scriptPath = fs.realpathSync(__filename)
const scriptDir = path.dirname(scriptPath)
const platformMap = {
darwin: "darwin",
linux: "linux",
win32: "windows",
}
const archMap = {
x64: "x64",
arm64: "arm64",
arm: "arm",
}
let platform = platformMap[os.platform()]
if (!platform) {
platform = os.platform()
}
let arch = archMap[os.arch()]
if (!arch) {
arch = os.arch()
}
const base = "opencode-" + platform + "-" + arch
const binary = platform === "windows" ? "opencode.exe" : "opencode"
function findBinary(startDir) {
let current = startDir
for (;;) {
const modules = path.join(current, "node_modules")
if (fs.existsSync(modules)) {
const entries = fs.readdirSync(modules)
for (const entry of entries) {
if (!entry.startsWith(base)) {
continue
}
const candidate = path.join(modules, entry, "bin", binary)
if (fs.existsSync(candidate)) {
return candidate
}
}
}
const parent = path.dirname(current)
if (parent === current) {
return
}
current = parent
}
}
const resolved = findBinary(scriptDir)
if (!resolved) {
console.error(
'It seems that your package manager failed to install the right version of the opencode CLI for your platform. You can try manually installing the "' +
base +
'" package',
)
process.exit(1)
}
run(resolved)
-58
View File
@@ -1,58 +0,0 @@
@echo off
setlocal enabledelayedexpansion
if defined OPENCODE_BIN_PATH (
set "resolved=%OPENCODE_BIN_PATH%"
goto :execute
)
rem Get the directory of this script
set "script_dir=%~dp0"
set "script_dir=%script_dir:~0,-1%"
rem Detect platform and architecture
set "platform=windows"
rem Detect architecture
if "%PROCESSOR_ARCHITECTURE%"=="AMD64" (
set "arch=x64"
) else if "%PROCESSOR_ARCHITECTURE%"=="ARM64" (
set "arch=arm64"
) else if "%PROCESSOR_ARCHITECTURE%"=="x86" (
set "arch=x86"
) else (
set "arch=x64"
)
set "name=opencode-!platform!-!arch!"
set "binary=opencode.exe"
rem Search for the binary starting from script location
set "resolved="
set "current_dir=%script_dir%"
:search_loop
set "candidate=%current_dir%\node_modules\%name%\bin\%binary%"
if exist "%candidate%" (
set "resolved=%candidate%"
goto :execute
)
rem Move up one directory
for %%i in ("%current_dir%") do set "parent_dir=%%~dpi"
set "parent_dir=%parent_dir:~0,-1%"
rem Check if we've reached the root
if "%current_dir%"=="%parent_dir%" goto :not_found
set "current_dir=%parent_dir%"
goto :search_loop
:not_found
echo It seems that your package manager failed to install the right version of the opencode CLI for your platform. You can try manually installing the "%name%" package >&2
exit /b 1
:execute
rem Execute the binary with all arguments in the same console window
rem Use start /b /wait to ensure it runs in the current shell context for all shells
start /b /wait "" "%resolved%" %*
exit /b %ERRORLEVEL%
+4 -3
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.0.61",
"version": "1.0.85",
"name": "opencode",
"type": "module",
"private": true,
@@ -44,6 +44,7 @@
"@actions/core": "1.11.1",
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.5.1",
"@ai-sdk/mcp": "0.0.8",
"@clack/prompts": "1.0.0-alpha.1",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
@@ -54,8 +55,8 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opentui/core": "0.1.42",
"@opentui/solid": "0.1.42",
"@opentui/core": "0.1.47",
"@opentui/solid": "0.1.47",
"@parcel/watcher": "2.5.1",
"@pierre/precision-diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
+23
View File
@@ -167,6 +167,15 @@ export default {
],
},
},
{
filetype: "yaml",
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-yaml/releases/download/v0.7.2/tree-sitter-yaml.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/yaml/highlights.scm",
],
},
},
{
filetype: "haskell",
wasm: "https://github.com/tree-sitter/tree-sitter-haskell/releases/download/v0.23.1/tree-sitter-haskell.wasm",
@@ -212,5 +221,19 @@ export default {
],
},
},
{
filetype: "swift",
wasm: "https://github.com/alex-pinkus/tree-sitter-swift/releases/download/0.7.1/tree-sitter-swift.wasm",
queries: {
highlights: [
// NOTE: Using parser repo queries instead of nvim-treesitter due to incompatible #lua-match? predicates
// "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/highlights.scm
"https://raw.githubusercontent.com/alex-pinkus/tree-sitter-swift/main/queries/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/swift/locals.scm",
],
},
},
],
}
+6 -5
View File
@@ -66,11 +66,11 @@ const allTargets: {
avx2: false,
},
{
os: "windows",
os: "win32",
arch: "x64",
},
{
os: "windows",
os: "win32",
arch: "x64",
avx2: false,
},
@@ -88,7 +88,8 @@ await $`bun install --os="*" --cpu="*" @parcel/watcher@${pkg.dependencies["@parc
for (const item of targets) {
const name = [
pkg.name,
item.os,
// changing to win32 flags npm for some reason
item.os === "win32" ? "windows" : item.os,
item.arch,
item.avx2 === false ? "baseline" : undefined,
item.abi === undefined ? undefined : item.abi,
@@ -115,7 +116,7 @@ for (const item of targets) {
entrypoints: ["./src/index.ts", parserWorker, workerPath],
define: {
OPENCODE_VERSION: `'${Script.version}'`,
OTUI_TREE_SITTER_WORKER_PATH: "/$bunfs/root/" + path.relative(dir, parserWorker),
OTUI_TREE_SITTER_WORKER_PATH: "/$bunfs/root/" + path.relative(dir, parserWorker).replaceAll("\\", "/"),
OPENCODE_WORKER_PATH: workerPath,
OPENCODE_CHANNEL: `'${Script.channel}'`,
},
@@ -127,7 +128,7 @@ for (const item of targets) {
{
name,
version: Script.version,
os: [item.os === "windows" ? "win32" : item.os],
os: [item.os],
cpu: [item.arch],
},
null,
+31 -44
View File
@@ -50,79 +50,66 @@ function detectPlatformAndArch() {
function findBinary() {
const { platform, arch } = detectPlatformAndArch()
const packageName = `opencode-${platform}-${arch}`
const binary = platform === "windows" ? "opencode.exe" : "opencode"
const binaryName = platform === "windows" ? "opencode.exe" : "opencode"
try {
// Use require.resolve to find the package
const packageJsonPath = require.resolve(`${packageName}/package.json`)
const packageDir = path.dirname(packageJsonPath)
const binaryPath = path.join(packageDir, "bin", binary)
const binaryPath = path.join(packageDir, "bin", binaryName)
if (!fs.existsSync(binaryPath)) {
throw new Error(`Binary not found at ${binaryPath}`)
}
return binaryPath
return { binaryPath, binaryName }
} catch (error) {
throw new Error(`Could not find package ${packageName}: ${error.message}`)
}
}
async function regenerateWindowsCmdWrappers() {
console.log("Windows + npm detected: Forcing npm to rebuild bin links")
function prepareBinDirectory(binaryName) {
const binDir = path.join(__dirname, "bin")
const targetPath = path.join(binDir, binaryName)
try {
const { execSync } = require("child_process")
const pkgPath = path.join(__dirname, "..")
// Ensure bin directory exists
if (!fs.existsSync(binDir)) {
fs.mkdirSync(binDir, { recursive: true })
}
// npm_config_global is string | undefined
// if it exists, the value is true
const isGlobal = process.env.npm_config_global === "true" || pkgPath.includes(path.join("npm", "node_modules"))
// Remove existing binary/symlink if it exists
if (fs.existsSync(targetPath)) {
fs.unlinkSync(targetPath)
}
// The npm rebuild command does 2 things - Execute lifecycle scripts and rebuild bin links
// We want to skip lifecycle scripts to avoid infinite loops, so we use --ignore-scripts
const cmd = `npm rebuild opencode-ai --ignore-scripts${isGlobal ? " -g" : ""}`
const opts = {
stdio: "inherit",
shell: true,
...(isGlobal ? {} : { cwd: path.join(pkgPath, "..", "..") }), // For local, run from project root
}
return { binDir, targetPath }
}
console.log(`Running: ${cmd}`)
execSync(cmd, opts)
console.log("Successfully rebuilt npm bin links")
} catch (error) {
console.error("Error rebuilding npm links:", error.message)
console.error("npm rebuild failed. You may need to manually run: npm rebuild opencode-ai --ignore-scripts")
function symlinkBinary(sourcePath, binaryName) {
const { targetPath } = prepareBinDirectory(binaryName)
fs.symlinkSync(sourcePath, targetPath)
console.log(`opencode binary symlinked: ${targetPath} -> ${sourcePath}`)
// Verify the file exists after operation
if (!fs.existsSync(targetPath)) {
throw new Error(`Failed to symlink binary to ${targetPath}`)
}
}
async function main() {
try {
if (os.platform() === "win32") {
// NPM eg format - npm/11.4.2 node/v24.4.1 win32 x64
// Bun eg format - bun/1.2.19 npm/? node/v24.3.0 win32 x64
if (process.env.npm_config_user_agent.startsWith("npm")) {
await regenerateWindowsCmdWrappers()
} else {
console.log("Windows detected but not npm, skipping postinstall")
}
// On Windows, the .exe is already included in the package and bin field points to it
// No postinstall setup needed
console.log("Windows detected: binary setup not needed (using packaged .exe)")
return
}
const binaryPath = findBinary()
const binScript = path.join(__dirname, "bin", "opencode")
// Remove existing bin script if it exists
if (fs.existsSync(binScript)) {
fs.unlinkSync(binScript)
}
// Create symlink to the actual binary
fs.symlinkSync(binaryPath, binScript)
console.log(`opencode binary symlinked: ${binScript} -> ${binaryPath}`)
const { binaryPath, binaryName } = findBinary()
symlinkBinary(binaryPath, binaryName)
} catch (error) {
console.error("Failed to create opencode binary symlink:", error.message)
console.error("Failed to setup opencode binary:", error.message)
process.exit(1)
}
}
-44
View File
@@ -1,44 +0,0 @@
#!/usr/bin/env node
import fs from "fs"
import path from "path"
import os from "os"
import { fileURLToPath } from "url"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
function main() {
if (os.platform() !== "win32") {
console.log("Non-Windows platform detected, skipping preinstall")
return
}
console.log("Windows detected: Modifying package.json bin entry")
// Read package.json
const packageJsonPath = path.join(__dirname, "package.json")
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"))
// Modify bin to point to .cmd file on Windows
packageJson.bin = {
opencode: "./bin/opencode.cmd",
}
// Write it back
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2))
console.log("Updated package.json bin to use opencode.cmd")
// Now you can also remove the Unix script if you want
const unixScript = path.join(__dirname, "bin", "opencode")
if (fs.existsSync(unixScript)) {
console.log("Removing Unix shell script")
fs.unlinkSync(unixScript)
}
}
try {
main()
} catch (error) {
console.error("Preinstall script error:", error.message)
process.exit(0)
}
+40 -5
View File
@@ -2,8 +2,9 @@
import { $ } from "bun"
import pkg from "../package.json"
import { Script } from "@opencode-ai/script"
import { fileURLToPath } from "url"
const dir = new URL("..", import.meta.url).pathname
const dir = fileURLToPath(new URL("..", import.meta.url))
process.chdir(dir)
const { binaries } = await import("./build.ts")
@@ -15,8 +16,8 @@ const { binaries } = await import("./build.ts")
await $`mkdir -p ./dist/${pkg.name}`
await $`cp -r ./bin ./dist/${pkg.name}/bin`
await $`cp ./script/preinstall.mjs ./dist/${pkg.name}/preinstall.mjs`
await $`cp ./script/postinstall.mjs ./dist/${pkg.name}/postinstall.mjs`
await Bun.file(`./dist/${pkg.name}/package.json`).write(
JSON.stringify(
{
@@ -25,7 +26,6 @@ await Bun.file(`./dist/${pkg.name}/package.json`).write(
[pkg.name]: `./bin/${pkg.name}`,
},
scripts: {
preinstall: "bun ./preinstall.mjs || node ./preinstall.mjs",
postinstall: "bun ./postinstall.mjs || node ./postinstall.mjs",
},
version: Script.version,
@@ -36,7 +36,15 @@ await Bun.file(`./dist/${pkg.name}/package.json`).write(
),
)
for (const [name] of Object.entries(binaries)) {
await $`cd dist/${name} && chmod 777 -R . && bun publish --access public --tag ${Script.channel}`
try {
process.chdir(`./dist/${name}`)
if (process.platform !== "win32") {
await $`chmod 755 -R .`
}
await $`bun publish --access public --tag ${Script.channel}`
} finally {
process.chdir(dir)
}
}
await $`cd ./dist/${pkg.name} && bun publish --access public --tag ${Script.channel}`
@@ -123,7 +131,34 @@ if (!Script.preview) {
"",
"package() {",
` cd "opencode-\${pkgver}/packages/opencode"`,
' install -Dm755 $(find dist/*/bin/opencode) "${pkgdir}/usr/bin/opencode"',
' mkdir -p "${pkgdir}/usr/bin"',
' target_arch="x64"',
' case "$CARCH" in',
' x86_64) target_arch="x64" ;;',
' aarch64) target_arch="arm64" ;;',
' *) printf "unsupported architecture: %s\\n" "$CARCH" >&2 ; return 1 ;;',
" esac",
' libc=""',
" if command -v ldd >/dev/null 2>&1; then",
" if ldd --version 2>&1 | grep -qi musl; then",
' libc="-musl"',
" fi",
" fi",
' if [ -z "$libc" ] && ls /lib/ld-musl-* >/dev/null 2>&1; then',
' libc="-musl"',
" fi",
' base=""',
' if [ "$target_arch" = "x64" ]; then',
" if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then",
' base="-baseline"',
" fi",
" fi",
' bin="dist/opencode-linux-${target_arch}${base}${libc}/bin/opencode"',
' if [ ! -f "$bin" ]; then',
' printf "unable to find binary for %s%s%s\\n" "$target_arch" "$base" "$libc" >&2',
" return 1",
" fi",
' install -Dm755 "$bin" "${pkgdir}/usr/bin/opencode"',
"}",
"",
].join("\n")
+1 -1
View File
@@ -12,7 +12,7 @@ export namespace Agent {
.object({
name: z.string(),
description: z.string().optional(),
mode: z.union([z.literal("subagent"), z.literal("primary"), z.literal("all")]),
mode: z.enum(["subagent", "primary", "all"]),
builtIn: z.boolean(),
topP: z.number().optional(),
temperature: z.number().optional(),
+42 -10
View File
@@ -79,16 +79,48 @@ export namespace BunProc {
version,
})
await BunProc.run(args, {
cwd: Global.Path.cache,
}).catch((e) => {
throw new InstallFailedError(
{ pkg, version },
{
cause: e,
},
)
})
const total = 3
const wait = 500
const runInstall = async (count: number = 1): Promise<void> => {
log.info("bun install attempt", {
pkg,
version,
attempt: count,
total,
})
await BunProc.run(args, {
cwd: Global.Path.cache,
}).catch(async (error) => {
log.warn("bun install failed", {
pkg,
version,
attempt: count,
total,
error,
})
if (count >= total) {
throw new InstallFailedError(
{ pkg, version },
{
cause: error,
},
)
}
const delay = wait * count
log.info("bun install retrying", {
pkg,
version,
next: count + 1,
delay,
})
await Bun.sleep(delay)
return runInstall(count + 1)
})
}
await runInstall()
parsed.dependencies[pkg] = version
await Bun.write(pkgjson.name!, JSON.stringify(parsed, null, 2))
return mod
+10
View File
@@ -0,0 +1,10 @@
import { EventEmitter } from "events"
export const GlobalBus = new EventEmitter<{
event: [
{
directory: string
payload: any
},
]
}>()
+25 -16
View File
@@ -2,6 +2,7 @@ import z from "zod"
import type { ZodType } from "zod"
import { Log } from "../util/log"
import { Instance } from "../project/instance"
import { GlobalBus } from "./global"
export namespace Bus {
const log = Log.create({ service: "bus" })
@@ -29,22 +30,26 @@ export namespace Bus {
}
export function payloads() {
return z.discriminatedUnion(
"type",
registry
.entries()
.map(([type, def]) => {
return z
.object({
type: z.literal(type),
properties: def.properties,
})
.meta({
ref: "Event" + "." + def.type,
})
})
.toArray() as any,
)
return z
.discriminatedUnion(
"type",
registry
.entries()
.map(([type, def]) => {
return z
.object({
type: z.literal(type),
properties: def.properties,
})
.meta({
ref: "Event" + "." + def.type,
})
})
.toArray() as any,
)
.meta({
ref: "Event",
})
}
export async function publish<Definition extends EventDefinition>(
@@ -65,6 +70,10 @@ export namespace Bus {
pending.push(sub(payload))
}
}
GlobalBus.emit("event", {
directory: Instance.directory,
payload,
})
return Promise.all(pending)
}
+25 -1
View File
@@ -6,6 +6,7 @@ import { Agent } from "../../agent/agent"
import path from "path"
import matter from "gray-matter"
import { Instance } from "../../project/instance"
import { EOL } from "os"
const AgentCreateCommand = cmd({
command: "create",
@@ -133,9 +134,32 @@ const AgentCreateCommand = cmd({
},
})
const AgentListCommand = cmd({
command: "list",
describe: "list all available agents",
async handler() {
await Instance.provide({
directory: process.cwd(),
async fn() {
const agents = await Agent.list()
const sortedAgents = agents.sort((a, b) => {
if (a.builtIn !== b.builtIn) {
return a.builtIn ? -1 : 1
}
return a.name.localeCompare(b.name)
})
for (const agent of sortedAgents) {
process.stdout.write(`${agent.name} (${agent.mode})${EOL}`)
}
},
})
},
})
export const AgentCommand = cmd({
command: "agent",
describe: "manage agents",
builder: (yargs) => yargs.command(AgentCreateCommand).demandCommand(),
builder: (yargs) => yargs.command(AgentCreateCommand).command(AgentListCommand).demandCommand(),
async handler() {},
})
@@ -2,6 +2,7 @@ import { EOL } from "os"
import { File } from "../../../file"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
import { Ripgrep } from "@/file/ripgrep"
const FileSearchCommand = cmd({
command: "search <query>",
@@ -62,6 +63,20 @@ const FileListCommand = cmd({
},
})
const FileTreeCommand = cmd({
command: "tree [dir]",
builder: (yargs) =>
yargs.positional("dir", {
type: "string",
description: "Directory to tree",
default: process.cwd(),
}),
async handler(args) {
const files = await Ripgrep.tree({ cwd: args.dir, limit: 200 })
console.log(files)
},
})
export const FileCommand = cmd({
command: "file",
builder: (yargs) =>
@@ -70,6 +85,7 @@ export const FileCommand = cmd({
.command(FileStatusCommand)
.command(FileListCommand)
.command(FileSearchCommand)
.command(FileTreeCommand)
.demandCommand(),
async handler() {},
})
+40 -17
View File
@@ -439,11 +439,13 @@ export const GithubRunCommand = cmd({
// Local PR
if (prData.headRepository.nameWithOwner === prData.baseRepository.nameWithOwner) {
await checkoutLocalBranch(prData)
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
const dataPrompt = buildPromptDataForPR(prData)
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
if (await branchIsDirty()) {
const { dirty, uncommittedChanges } = await branchIsDirty(head)
if (dirty) {
const summary = await summarize(response)
await pushToLocalBranch(summary)
await pushToLocalBranch(summary, uncommittedChanges)
}
const hasShared = prData.comments.nodes.some((c) => c.body.includes(`${shareBaseUrl}/s/${shareId}`))
await updateComment(`${response}${footer({ image: !hasShared })}`)
@@ -451,11 +453,13 @@ export const GithubRunCommand = cmd({
// Fork PR
else {
await checkoutForkBranch(prData)
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
const dataPrompt = buildPromptDataForPR(prData)
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
if (await branchIsDirty()) {
const { dirty, uncommittedChanges } = await branchIsDirty(head)
if (dirty) {
const summary = await summarize(response)
await pushToForkBranch(summary, prData)
await pushToForkBranch(summary, prData, uncommittedChanges)
}
const hasShared = prData.comments.nodes.some((c) => c.body.includes(`${shareBaseUrl}/s/${shareId}`))
await updateComment(`${response}${footer({ image: !hasShared })}`)
@@ -464,12 +468,14 @@ export const GithubRunCommand = cmd({
// Issue
else {
const branch = await checkoutNewBranch()
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
const issueData = await fetchIssue()
const dataPrompt = buildPromptDataForIssue(issueData)
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
if (await branchIsDirty()) {
const { dirty, uncommittedChanges } = await branchIsDirty(head)
if (dirty) {
const summary = await summarize(response)
await pushToNewBranch(summary, branch)
await pushToNewBranch(summary, branch, uncommittedChanges)
const pr = await createPR(
repoData.data.default_branch,
branch,
@@ -802,40 +808,57 @@ export const GithubRunCommand = cmd({
return `opencode/${type}${issueId}-${timestamp}`
}
async function pushToNewBranch(summary: string, branch: string) {
async function pushToNewBranch(summary: string, branch: string, commit: boolean) {
console.log("Pushing to new branch...")
await $`git add .`
await $`git commit -m "${summary}
if (commit) {
await $`git add .`
await $`git commit -m "${summary}
Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
}
await $`git push -u origin ${branch}`
}
async function pushToLocalBranch(summary: string) {
async function pushToLocalBranch(summary: string, commit: boolean) {
console.log("Pushing to local branch...")
await $`git add .`
await $`git commit -m "${summary}
if (commit) {
await $`git add .`
await $`git commit -m "${summary}
Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
}
await $`git push`
}
async function pushToForkBranch(summary: string, pr: GitHubPullRequest) {
async function pushToForkBranch(summary: string, pr: GitHubPullRequest, commit: boolean) {
console.log("Pushing to fork branch...")
const remoteBranch = pr.headRefName
await $`git add .`
await $`git commit -m "${summary}
if (commit) {
await $`git add .`
await $`git commit -m "${summary}
Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
}
await $`git push fork HEAD:${remoteBranch}`
}
async function branchIsDirty() {
async function branchIsDirty(originalHead: string) {
console.log("Checking if branch is dirty...")
const ret = await $`git status --porcelain`
return ret.stdout.toString().trim().length > 0
const status = ret.stdout.toString().trim()
if (status.length > 0) {
return {
dirty: true,
uncommittedChanges: true,
}
}
const head = await $`git rev-parse HEAD`
return {
dirty: head.stdout.toString().trim() !== originalHead,
uncommittedChanges: false,
}
}
async function assertPermissions() {
+25 -12
View File
@@ -2,10 +2,11 @@ import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentu
import { Clipboard } from "@tui/util/clipboard"
import { TextAttributes } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch } from "solid-js"
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show } from "solid-js"
import { Installation } from "@/installation"
import { Global } from "@/global"
import { DialogProvider, useDialog } from "@tui/ui/dialog"
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
import { SDKProvider, useSDK } from "@tui/context/sdk"
import { SyncProvider, useSync } from "@tui/context/sync"
import { LocalProvider, useLocal } from "@tui/context/local"
@@ -293,6 +294,14 @@ function App() {
},
category: "System",
},
{
title: "Connect provider",
value: "provider.connect",
onSelect: () => {
dialog.replace(() => <DialogProviderList />)
},
category: "System",
},
{
title: `Switch to ${mode() === "dark" ? "light" : "dark"} mode`,
value: "theme.switch_mode",
@@ -312,7 +321,7 @@ function App() {
{
title: "Exit the app",
value: "app.exit",
onSelect: exit,
onSelect: () => exit(),
category: "System",
},
{
@@ -451,16 +460,18 @@ function App() {
<text fg={theme.textMuted}>{process.cwd().replace(Global.Path.home, "~")}</text>
</box>
</box>
<box flexDirection="row" flexShrink={0}>
<text fg={theme.textMuted} paddingRight={1}>
tab
</text>
<text fg={local.agent.color(local.agent.current().name)}>{""}</text>
<text bg={local.agent.color(local.agent.current().name)} fg={theme.background} wrapMode={undefined}>
<span style={{ bold: true }}> {local.agent.current().name.toUpperCase()}</span>
<span> AGENT </span>
</text>
</box>
<Show when={false}>
<box flexDirection="row" flexShrink={0}>
<text fg={theme.textMuted} paddingRight={1}>
tab
</text>
<text fg={local.agent.color(local.agent.current().name)}>{""}</text>
<text bg={local.agent.color(local.agent.current().name)} fg={theme.background} wrapMode={undefined}>
<span style={{ bold: true }}> {local.agent.current().name.toUpperCase()}</span>
<span> AGENT </span>
</text>
</box>
</Show>
</box>
</box>
)
@@ -488,6 +499,8 @@ function ErrorComponent(props: { error: Error; reset: () => void; onExit: () =>
)
}
issueURL.searchParams.set("opencode-version", Installation.VERSION)
const copyIssueURL = () => {
Clipboard.copy(issueURL.toString()).then(() => {
setCopied(true)
@@ -1,16 +1,21 @@
export const EmptyBorder = {
topLeft: "",
bottomLeft: "",
vertical: "",
topRight: "",
bottomRight: "",
horizontal: " ",
bottomT: "",
topT: "",
cross: "",
leftT: "",
rightT: "",
}
export const SplitBorder = {
border: ["left" as const, "right" as const],
customBorderChars: {
topLeft: "",
bottomLeft: "",
...EmptyBorder,
vertical: "┃",
topRight: "",
bottomRight: "",
horizontal: "",
bottomT: "",
topT: "",
cross: "",
leftT: "",
rightT: "",
},
}
@@ -1,26 +1,27 @@
import { createMemo, createSignal } from "solid-js"
import { useLocal } from "@tui/context/local"
import { useSync } from "@tui/context/sync"
import { map, pipe, flatMap, entries, filter, isDeepEqual, sortBy } from "remeda"
import { map, pipe, flatMap, entries, filter, isDeepEqual, sortBy, take } from "remeda"
import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
import { useTheme } from "../context/theme"
function Free() {
const { theme } = useTheme()
return <span style={{ fg: theme.secondary }}>Free</span>
}
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
export function DialogModel() {
const local = useLocal()
const sync = useSync()
const dialog = useDialog()
const [ref, setRef] = createSignal<DialogSelectRef<unknown>>()
const { theme } = useTheme()
const connected = createMemo(() =>
sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)),
)
const showRecent = createMemo(() => !ref()?.filter && local.model.recent().length > 0 && connected())
const providers = createDialogProviderOptions()
const options = createMemo(() => {
return [
...(!ref()?.filter
...(showRecent()
? local.model.recent().flatMap((item) => {
const provider = sync.data.provider.find((x) => x.id === item.providerID)!
if (!provider) return []
@@ -36,7 +37,17 @@ export function DialogModel() {
title: model.name ?? item.modelID,
description: provider.name,
category: "Recent",
footer: model.cost?.input === 0 && provider.id === "opencode" ? <Free /> : undefined,
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect: () => {
dialog.clear()
local.model.set(
{
providerID: provider.id,
modelID: model.id,
},
{ recent: true },
)
},
},
]
})
@@ -57,27 +68,56 @@ export function DialogModel() {
modelID: model,
},
title: info.name ?? model,
description: provider.name,
category: provider.name,
footer: info.cost?.input === 0 && provider.id === "opencode" ? <Free /> : undefined,
description: connected() ? provider.name : undefined,
category: connected() ? provider.name : undefined,
disabled: provider.id === "opencode" && model.includes("-nano"),
footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect() {
dialog.clear()
local.model.set(
{
providerID: provider.id,
modelID: model,
},
{ recent: true },
)
},
})),
filter((x) => Boolean(ref()?.filter) || !local.model.recent().find((y) => isDeepEqual(y, x.value))),
filter((x) => !showRecent() || !local.model.recent().find((y) => isDeepEqual(y, x.value))),
sortBy((x) => x.title),
),
),
),
...(!connected()
? pipe(
providers(),
map((option) => {
return {
...option,
category: "Popular providers",
}
}),
take(6),
)
: []),
]
})
return (
<DialogSelect
keybind={[
{
keybind: { ctrl: true, name: "a", meta: false, shift: false, leader: false },
title: connected() ? "Connect provider" : "More providers",
onTrigger() {
dialog.replace(() => <DialogProvider />)
},
},
]}
ref={setRef}
title="Select model"
current={local.model.current()}
options={options()}
onSelect={(option) => {
dialog.clear()
local.model.set(option.value, { recent: true })
}}
/>
)
}
@@ -0,0 +1,222 @@
import { createMemo, createSignal, onMount, Show } from "solid-js"
import { useSync } from "@tui/context/sync"
import { map, pipe, sortBy } from "remeda"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
import { useSDK } from "../context/sdk"
import { DialogPrompt } from "../ui/dialog-prompt"
import { useTheme } from "../context/theme"
import { TextAttributes } from "@opentui/core"
import type { ProviderAuthAuthorization } from "@opencode-ai/sdk"
import { DialogModel } from "./dialog-model"
const PROVIDER_PRIORITY: Record<string, number> = {
opencode: 0,
anthropic: 1,
"github-copilot": 2,
openai: 3,
google: 4,
openrouter: 5,
}
export function createDialogProviderOptions() {
const sync = useSync()
const dialog = useDialog()
const sdk = useSDK()
const options = createMemo(() => {
return pipe(
sync.data.provider_next.all,
map((provider) => ({
title: provider.name,
value: provider.id,
footer: {
opencode: "Recommended",
anthropic: "Claude Max or API key",
}[provider.id],
async onSelect() {
const methods = sync.data.provider_auth[provider.id] ?? [
{
type: "api",
label: "API key",
},
]
let index: number | null = 0
if (methods.length > 1) {
index = await new Promise<number | null>((resolve) => {
dialog.replace(
() => (
<DialogSelect
title="Select auth method"
options={methods.map((x, index) => ({
title: x.label,
value: index,
}))}
onSelect={(option) => resolve(option.value)}
/>
),
() => resolve(null),
)
})
}
if (index == null) return
const method = methods[index]
if (method.type === "oauth") {
const result = await sdk.client.provider.oauth.authorize({
path: {
id: provider.id,
},
body: {
method: index,
},
})
if (result.data?.method === "code") {
dialog.replace(() => (
<CodeMethod providerID={provider.id} title={method.label} index={index} authorization={result.data!} />
))
}
if (result.data?.method === "auto") {
dialog.replace(() => (
<AutoMethod providerID={provider.id} title={method.label} index={index} authorization={result.data!} />
))
}
}
if (method.type === "api") {
return dialog.replace(() => <ApiMethod providerID={provider.id} title={method.label} />)
}
},
})),
sortBy((x) => PROVIDER_PRIORITY[x.value] ?? 99),
)
})
return options
}
export function DialogProvider() {
const options = createDialogProviderOptions()
return <DialogSelect title="Connect a provider" options={options()} />
}
interface AutoMethodProps {
index: number
providerID: string
title: string
authorization: ProviderAuthAuthorization
}
function AutoMethod(props: AutoMethodProps) {
const { theme } = useTheme()
const sdk = useSDK()
const dialog = useDialog()
const sync = useSync()
onMount(async () => {
const result = await sdk.client.provider.oauth.callback({
path: {
id: props.providerID,
},
body: {
method: props.index,
},
})
if (result.error) {
dialog.clear()
return
}
await sdk.client.instance.dispose()
await sync.bootstrap()
dialog.replace(() => <DialogModel />)
})
return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD}>{props.title}</text>
<text fg={theme.textMuted}>esc</text>
</box>
<box gap={1}>
<text fg={theme.primary}>{props.authorization.url}</text>
<text fg={theme.textMuted}>{props.authorization.instructions}</text>
</box>
<text fg={theme.textMuted}>Waiting for authorization...</text>
</box>
)
}
interface CodeMethodProps {
index: number
title: string
providerID: string
authorization: ProviderAuthAuthorization
}
function CodeMethod(props: CodeMethodProps) {
const { theme } = useTheme()
const sdk = useSDK()
const sync = useSync()
const dialog = useDialog()
const [error, setError] = createSignal(false)
return (
<DialogPrompt
title={props.title}
placeholder="Authorization code"
onConfirm={async (value) => {
const { error } = await sdk.client.provider.oauth.callback({
path: {
id: props.providerID,
},
body: {
method: props.index,
code: value,
},
})
if (!error) {
await sdk.client.instance.dispose()
await sync.bootstrap()
dialog.replace(() => <DialogModel />)
return
}
setError(true)
}}
description={() => (
<box gap={1}>
<text fg={theme.textMuted}>{props.authorization.instructions}</text>
<text fg={theme.primary}>{props.authorization.url}</text>
<Show when={error()}>
<text fg={theme.error}>Invalid code</text>
</Show>
</box>
)}
/>
)
}
interface ApiMethodProps {
providerID: string
title: string
}
function ApiMethod(props: ApiMethodProps) {
const dialog = useDialog()
const sdk = useSDK()
const sync = useSync()
return (
<DialogPrompt
title={props.title}
placeholder="API key"
onConfirm={async (value) => {
if (!value) return
sdk.client.auth.set({
path: {
id: props.providerID,
},
body: {
type: "api",
key: value,
},
})
await sdk.client.instance.dispose()
await sync.bootstrap()
dialog.replace(() => <DialogModel />)
}}
/>
)
}
@@ -53,15 +53,7 @@ export function Autocomplete(props: {
// Track props.value to make memo reactive to text changes
props.value // <- there surely is a better way to do this, like making .input() reactive
const val = props.input().getTextRange(store.index + 1, props.input().cursorOffset + 1)
// If the filter contains a space, hide the autocomplete
if (val.includes(" ")) {
hide()
return undefined
}
return val
return props.input().getTextRange(store.index + 1, props.input().cursorOffset)
})
function insertPart(text: string, part: PromptInfo["parts"][number]) {
@@ -245,6 +237,11 @@ export function Autocomplete(props: {
description: "jump to message",
onSelect: () => command.trigger("session.timeline"),
},
{
display: "/thinking",
description: "toggle thinking blocks",
onSelect: () => command.trigger("session.toggle.thinking"),
},
)
if (sync.data.config.share !== "disabled") {
results.push({
@@ -382,17 +379,19 @@ export function Autocomplete(props: {
get visible() {
return store.visible
},
onInput() {
onInput(value) {
if (store.visible) {
if (props.input().cursorOffset <= store.index) {
if (
// Typed text before the trigger
props.input().cursorOffset <= store.index ||
// There is a space between the trigger and the cursor
props.input().getTextRange(store.index, props.input().cursorOffset).match(/\s/) ||
// "/<command>" is not the sole content
(store.visible === "/" && value.match(/^\S+\s+\S+\s*$/))
) {
hide()
return
}
// Check if a space was typed after the trigger character
const currentText = props.input().getTextRange(store.index + 1, props.input().cursorOffset + 1)
if (currentText.includes(" ")) {
hide()
}
}
},
onKeyDown(e: KeyEvent) {
@@ -4,7 +4,7 @@ import { onMount } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { clone } from "remeda"
import { createSimpleContext } from "../../context/helper"
import { appendFile } from "fs/promises"
import { appendFile, writeFile } from "fs/promises"
import type { AgentPart, FilePart, TextPart } from "@opencode-ai/sdk"
export type PromptInfo = {
@@ -24,6 +24,8 @@ export type PromptInfo = {
)[]
}
const MAX_HISTORY_ENTRIES = 50
export const { use: usePromptHistory, provider: PromptHistoryProvider } = createSimpleContext({
name: "PromptHistory",
init: () => {
@@ -33,8 +35,23 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
const lines = text
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line))
setStore("history", lines as PromptInfo[])
.map((line) => {
try {
return JSON.parse(line)
} catch {
return null
}
})
.filter((line): line is PromptInfo => line !== null)
.slice(-MAX_HISTORY_ENTRIES)
setStore("history", lines)
// Rewrite file with only valid entries to self-heal corruption
if (lines.length > 0) {
const content = lines.map((line) => JSON.stringify(line)).join("\n") + "\n"
writeFile(historyFile.name!, content).catch(() => {})
}
})
const [store, setStore] = createStore({
@@ -64,14 +81,26 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
return store.history.at(store.index)
},
append(item: PromptInfo) {
item = clone(item)
appendFile(historyFile.name!, JSON.stringify(item) + "\n")
const entry = clone(item)
let trimmed = false
setStore(
produce((draft) => {
draft.history.push(item)
draft.history.push(entry)
if (draft.history.length > MAX_HISTORY_ENTRIES) {
draft.history = draft.history.slice(-MAX_HISTORY_ENTRIES)
trimmed = true
}
draft.index = 0
}),
)
if (trimmed) {
const content = store.history.map((line) => JSON.stringify(line)).join("\n") + "\n"
writeFile(historyFile.name!, content).catch(() => {})
return
}
appendFile(historyFile.name!, JSON.stringify(entry) + "\n").catch(() => {})
},
}
},
@@ -1,18 +1,8 @@
import {
TextAttributes,
BoxRenderable,
TextareaRenderable,
MouseEvent,
PasteEvent,
t,
dim,
fg,
type KeyBinding,
} from "@opentui/core"
import { createEffect, createMemo, Match, Switch, type JSX, onMount, batch } from "solid-js"
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, t, dim, fg, type KeyBinding } from "@opentui/core"
import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, Show, Switch, Match } from "solid-js"
import { useLocal } from "@tui/context/local"
import { useTheme } from "@tui/context/theme"
import { SplitBorder } from "@tui/component/border"
import { EmptyBorder } from "@tui/component/border"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
@@ -29,6 +19,8 @@ import { Clipboard } from "../../util/clipboard"
import type { FilePart } from "@opencode-ai/sdk"
import { TuiEvent } from "../../event"
import { iife } from "@/util/iife"
import { Locale } from "@/util/locale"
import { Shimmer } from "../../ui/shimmer"
export type PromptProps = {
sessionID?: string
@@ -57,7 +49,7 @@ export function Prompt(props: PromptProps) {
const sdk = useSDK()
const route = useRoute()
const sync = useSync()
const status = createMemo(() => (props.sessionID ? sync.session.status(props.sessionID) : "idle"))
const status = createMemo(() => sync.data.session_status[props.sessionID ?? ""] ?? { type: "idle" })
const history = usePromptHistory()
const command = useCommandDialog()
const renderer = useRenderer()
@@ -222,12 +214,17 @@ export function Prompt(props: PromptProps) {
title: "Interrupt session",
value: "session.interrupt",
keybind: "session_interrupt",
disabled: status() !== "working",
disabled: status().type === "idle",
category: "Session",
onSelect: (dialog) => {
if (!props.sessionID) return
if (autocomplete.visible) return
if (!input.focused) return
// TODO: this should be its own command
if (store.mode === "shell") {
setStore("mode", "normal")
return
}
if (!props.sessionID) return
setStore("interrupt", store.interrupt + 1)
@@ -425,6 +422,10 @@ export function Prompt(props: PromptProps) {
},
body: {
agent: local.agent.current().name,
model: {
providerID: local.model.current().providerID,
modelID: local.model.current().modelID,
},
command: inputText,
},
})
@@ -538,6 +539,16 @@ export function Prompt(props: PromptProps) {
return
}
const highlight = createMemo(() => {
if (keybind.leader) return theme.border
if (store.mode === "shell") return theme.primary
return local.agent.color(local.agent.current().name)
})
createEffect(() => {
renderer.setCursorColor(highlight())
})
return (
<>
<Autocomplete
@@ -562,17 +573,22 @@ export function Prompt(props: PromptProps) {
/>
<box ref={(r) => (anchor = r)}>
<box
flexDirection="row"
{...SplitBorder}
borderColor={keybind.leader ? theme.accent : store.mode === "shell" ? theme.secondary : theme.border}
justifyContent="space-evenly"
border={["left"]}
borderColor={highlight()}
customBorderChars={{
...EmptyBorder,
vertical: "┃",
bottomLeft: "╹",
}}
>
<box backgroundColor={theme.backgroundElement} width={3} height="100%" alignItems="center" paddingTop={1}>
<text attributes={TextAttributes.BOLD} fg={theme.primary}>
{store.mode === "normal" ? ">" : "!"}
</text>
</box>
<box paddingTop={1} paddingBottom={1} backgroundColor={theme.backgroundElement} flexGrow={1}>
<box
paddingLeft={2}
paddingRight={1}
paddingTop={1}
flexShrink={0}
backgroundColor={theme.backgroundElement}
flexGrow={1}
>
<textarea
placeholder={
props.showPlaceholder
@@ -590,8 +606,7 @@ export function Prompt(props: PromptProps) {
syncExtmarksWithPromptParts()
}}
keyBindings={textareaKeybindings()}
// TODO: fix this any
onKeyDown={async (e: any) => {
onKeyDown={async (e) => {
if (props.disabled) {
e.preventDefault()
return
@@ -665,7 +680,11 @@ export function Prompt(props: PromptProps) {
return
}
const pastedContent = event.text.trim()
// Normalize line endings at the boundary
// Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste
// Replace CRLF first, then any remaining CR
const normalizedText = event.text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const pastedContent = normalizedText.trim()
if (!pastedContent) {
command.trigger("prompt.paste")
return
@@ -744,37 +763,151 @@ export function Prompt(props: PromptProps) {
cursorColor={theme.primary}
syntaxStyle={syntax()}
/>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1}>
<text fg={highlight()}>
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
</text>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
<text flexShrink={0} fg={theme.text}>
{local.model.parsed().model}
</text>
</box>
</Show>
</box>
</box>
<box backgroundColor={theme.backgroundElement} width={1} justifyContent="center" alignItems="center"></box>
</box>
<box
height={1}
border={["left"]}
borderColor={highlight()}
customBorderChars={{
...EmptyBorder,
vertical: "╹",
}}
>
<box
height={1}
border={["bottom"]}
borderColor={theme.backgroundElement}
customBorderChars={{
...EmptyBorder,
horizontal: "▀",
}}
/>
</box>
<box flexDirection="row" justifyContent="space-between">
<text flexShrink={0} wrapMode="none" fg={theme.text}>
<span style={{ fg: theme.textMuted }}>{local.model.parsed().provider}</span>{" "}
<span style={{ bold: true }}>{local.model.parsed().model}</span>
</text>
<Switch>
<Match when={status() === "compacting"}>
<text fg={theme.textMuted}>compacting...</text>
</Match>
<Match when={status() === "working"}>
<box flexDirection="row" gap={1}>
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
esc{" "}
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
</span>
</text>
<Show when={status().type !== "idle"} fallback={<text />}>
<box
flexDirection="row"
gap={1}
flexGrow={1}
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
>
<box flexShrink={0} flexDirection="row" gap={1}>
<Loader />
<box flexDirection="row" gap={1} flexShrink={0}>
{(() => {
const retry = createMemo(() => {
const s = status()
if (s.type !== "retry") return
return s
})
const message = createMemo(() => {
const r = retry()
if (!r) return
if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
return "gemini 3 way too hot right now"
if (r.message.length > 50) return r.message.slice(0, 50) + "..."
return r.message
})
const [seconds, setSeconds] = createSignal(0)
onMount(() => {
const timer = setInterval(() => {
const next = retry()?.next
if (next) setSeconds(Math.round((next - Date.now()) / 1000))
}, 1000)
onCleanup(() => {
clearInterval(timer)
})
})
return (
<Show when={retry()}>
<text fg={theme.error}>
{message()} [retrying {seconds() > 0 ? `in ${seconds()}s ` : ""}
attempt #{retry()!.attempt}]
</text>
</Show>
)
})()}
</box>
</box>
</Match>
<Match when={props.hint}>{props.hint!}</Match>
<Match when={true}>
<text fg={theme.text}>
{keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span>
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
esc{" "}
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
</span>
</text>
</Match>
</Switch>
</box>
</Show>
<Show when={status().type !== "retry"}>
<box gap={2} flexDirection="row">
<Switch>
<Match when={store.mode === "normal"}>
<text fg={theme.text}>
{keybind.print("agent_cycle")} <span style={{ fg: theme.textMuted }}>switch agent</span>
</text>
<text fg={theme.text}>
{keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span>
</text>
</Match>
<Match when={store.mode === "shell"}>
<text fg={theme.text}>
esc <span style={{ fg: theme.textMuted }}>exit shell mode</span>
</text>
</Match>
</Switch>
</box>
</Show>
</box>
</box>
</>
)
}
function Loader() {
const FRAMES = [
"▱▱▱▱▱▱▱",
"▱▱▱▱▱▱▱",
"▱▱▱▱▱▱▱",
"▱▱▱▱▱▱▱",
"▰▱▱▱▱▱▱",
"▰▰▱▱▱▱▱",
"▰▰▰▱▱▱▱",
"▱▰▰▰▱▱▱",
"▱▱▰▰▰▱▱",
"▱▱▱▰▰▰▱",
"▱▱▱▱▰▰▰",
"▱▱▱▱▱▰▰",
"▱▱▱▱▱▱▰",
"▱▱▱▱▱▱▱",
"▱▱▱▱▱▱▱",
"▱▱▱▱▱▱▱",
"▱▱▱▱▱▱▱",
]
const [frame, setFrame] = createSignal(0)
onMount(() => {
const timer = setInterval(() => {
setFrame((frame() + 1) % FRAMES.length)
}, 100)
onCleanup(() => {
clearInterval(timer)
})
})
const { theme } = useTheme()
return <text fg={theme.diffAdded}>{FRAMES[frame()]}</text>
}
@@ -1,13 +1,18 @@
import { useRenderer } from "@opentui/solid"
import { createSimpleContext } from "./helper"
import { FormatError } from "@/cli/error"
export const { use: useExit, provider: ExitProvider } = createSimpleContext({
name: "Exit",
init: (input: { onExit?: () => Promise<void> }) => {
const renderer = useRenderer()
return async () => {
return async (reason?: any) => {
renderer.destroy()
await input.onExit?.()
if (reason) {
const formatted = FormatError(reason) ?? JSON.stringify(reason)
process.stderr.write(formatted + "\n")
}
process.exit(0)
}
},
@@ -10,6 +10,7 @@ import { createSimpleContext } from "./helper"
import { useToast } from "../ui/toast"
import { Provider } from "@/provider/provider"
import { useArgs } from "./args"
import { RGBA } from "@opentui/core"
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
name: "Local",
@@ -91,7 +92,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
},
color(name: string) {
const agent = agents().find((x) => x.name === name)
if (agent?.color) return agent.color
if (agent?.color) return RGBA.fromHex(agent.color)
const index = agents().findIndex((x) => x.name === name)
return colors()[index % colors().length]
},
@@ -158,10 +159,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
}
const provider = sync.data.provider[0]
const model = Object.values(provider.models)[0]
const model = sync.data.provider_default[provider.id] ?? Object.values(provider.models)[0].id
return {
providerID: provider.id,
modelID: model.id,
modelID: model,
}
})
@@ -10,11 +10,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
const sdk = createOpencodeClient({
baseUrl: props.url,
signal: abort.signal,
fetch: (req) => {
// @ts-ignore
req.timeout = false
return fetch(req)
},
})
const emitter = createGlobalEmitter<{
@@ -23,7 +18,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
sdk.event.subscribe().then(async (events) => {
for await (const event of events.stream) {
console.log("event", event.type)
emitter.emit(event.type, event)
}
})
@@ -11,12 +11,17 @@ import type {
LspStatus,
McpStatus,
FormatterStatus,
SessionStatus,
ProviderListResponse,
ProviderAuthMethod,
} from "@opencode-ai/sdk"
import { createStore, produce, reconcile } from "solid-js/store"
import { useSDK } from "@tui/context/sdk"
import { Binary } from "@/util/binary"
import { createSimpleContext } from "./helper"
import type { Snapshot } from "@/snapshot"
import { useExit } from "./exit"
import { batch, onMount } from "solid-js"
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
@@ -24,6 +29,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const [store, setStore] = createStore<{
status: "loading" | "partial" | "complete"
provider: Provider[]
provider_default: Record<string, string>
provider_next: ProviderListResponse
provider_auth: Record<string, ProviderAuthMethod[]>
agent: Agent[]
command: Command[]
permission: {
@@ -31,6 +39,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}
config: Config
session: Session[]
session_status: {
[sessionID: string]: SessionStatus
}
session_diff: {
[sessionID: string]: Snapshot.FileDiff[]
}
@@ -49,13 +60,21 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}
formatter: FormatterStatus[]
}>({
provider_next: {
all: [],
default: {},
connected: [],
},
provider_auth: {},
config: {},
status: "loading",
agent: [],
permission: {},
command: [],
provider: [],
provider_default: {},
session: [],
session_status: {},
session_diff: {},
todo: {},
message: {},
@@ -138,6 +157,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}),
)
break
case "session.status": {
setStore("session_status", event.properties.sessionID, event.properties.status)
break
}
case "message.updated": {
const messages = store.message[event.properties.info.sessionID]
if (!messages) {
@@ -215,28 +240,52 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}
})
// blocking
Promise.all([
sdk.client.config.providers({ throwOnError: true }).then((x) => setStore("provider", x.data!.providers)),
sdk.client.app.agents({ throwOnError: true }).then((x) => setStore("agent", x.data ?? [])),
sdk.client.config.get({ throwOnError: true }).then((x) => setStore("config", x.data!)),
]).then(() => {
setStore("status", "partial")
// non-blocking
Promise.all([
sdk.client.session.list().then((x) =>
setStore(
"session",
(x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)),
),
),
sdk.client.command.list().then((x) => setStore("command", x.data ?? [])),
sdk.client.lsp.status().then((x) => setStore("lsp", x.data!)),
sdk.client.mcp.status().then((x) => setStore("mcp", x.data!)),
sdk.client.formatter.status().then((x) => setStore("formatter", x.data!)),
]).then(() => {
setStore("status", "complete")
})
const exit = useExit()
async function bootstrap() {
// blocking
await Promise.all([
sdk.client.config.providers({ throwOnError: true }).then((x) => {
batch(() => {
setStore("provider", x.data!.providers)
setStore("provider_default", x.data!.default)
})
}),
sdk.client.provider.list({ throwOnError: true }).then((x) => {
batch(() => {
setStore("provider_next", x.data!)
})
}),
sdk.client.app.agents({ throwOnError: true }).then((x) => setStore("agent", x.data ?? [])),
sdk.client.config.get({ throwOnError: true }).then((x) => setStore("config", x.data!)),
])
.then(() => {
if (store.status !== "complete") setStore("status", "partial")
// non-blocking
Promise.all([
sdk.client.session.list().then((x) =>
setStore(
"session",
(x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)),
),
),
sdk.client.command.list().then((x) => setStore("command", x.data ?? [])),
sdk.client.lsp.status().then((x) => setStore("lsp", x.data!)),
sdk.client.mcp.status().then((x) => setStore("mcp", x.data!)),
sdk.client.formatter.status().then((x) => setStore("formatter", x.data!)),
sdk.client.session.status().then((x) => setStore("session_status", x.data!)),
sdk.client.provider.auth().then((x) => setStore("provider_auth", x.data ?? {})),
]).then(() => {
setStore("status", "complete")
})
})
.catch(async (e) => {
await exit(e)
})
}
onMount(() => {
bootstrap()
})
const result = {
@@ -291,6 +340,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
console.log("synced in " + (Date.now() - now), sessionID)
},
},
bootstrap,
}
return result
},
@@ -9,6 +9,7 @@ import catppuccin from "./theme/catppuccin.json" with { type: "json" }
import cobalt2 from "./theme/cobalt2.json" with { type: "json" }
import dracula from "./theme/dracula.json" with { type: "json" }
import everforest from "./theme/everforest.json" with { type: "json" }
import flexoki from "./theme/flexoki.json" with { type: "json" }
import github from "./theme/github.json" with { type: "json" }
import gruvbox from "./theme/gruvbox.json" with { type: "json" }
import kanagawa from "./theme/kanagawa.json" with { type: "json" }
@@ -105,6 +106,7 @@ export const DEFAULT_THEMES: Record<string, ThemeJson> = {
cobalt2,
dracula,
everforest,
flexoki,
github,
gruvbox,
kanagawa,
@@ -128,7 +130,19 @@ function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
const defs = theme.defs ?? {}
function resolveColor(c: ColorValue): RGBA {
if (c instanceof RGBA) return c
if (typeof c === "string") return c.startsWith("#") ? RGBA.fromHex(c) : resolveColor(defs[c])
if (typeof c === "string") {
if (c === "transparent" || c === "none") return RGBA.fromInts(0, 0, 0, 0)
if (c.startsWith("#")) return RGBA.fromHex(c)
if (defs[c]) {
return resolveColor(defs[c])
} else if (theme.theme[c as keyof Theme]) {
return resolveColor(theme.theme[c as keyof Theme])
} else {
throw new Error(`Color reference "${c}" not found in defs or theme`)
}
}
return resolveColor(c[mode])
}
return Object.fromEntries(
@@ -864,18 +878,21 @@ function generateSyntax(theme: Theme) {
scope: ["diff.plus"],
style: {
foreground: theme.diffAdded,
background: theme.diffAddedBg,
},
},
{
scope: ["diff.minus"],
style: {
foreground: theme.diffRemoved,
background: theme.diffRemovedBg,
},
},
{
scope: ["diff.delta"],
style: {
foreground: theme.diffContext,
background: theme.diffContextBg,
},
},
{
@@ -0,0 +1,237 @@
{
"$schema": "https://opencode.ai/theme.json",
"defs": {
"black": "#100F0F",
"base950": "#1C1B1A",
"base900": "#282726",
"base850": "#343331",
"base800": "#403E3C",
"base700": "#575653",
"base600": "#6F6E69",
"base500": "#878580",
"base300": "#B7B5AC",
"base200": "#CECDC3",
"base150": "#DAD8CE",
"base100": "#E6E4D9",
"base50": "#F2F0E5",
"paper": "#FFFCF0",
"red400": "#D14D41",
"red600": "#AF3029",
"orange400": "#DA702C",
"orange600": "#BC5215",
"yellow400": "#D0A215",
"yellow600": "#AD8301",
"green400": "#879A39",
"green600": "#66800B",
"cyan400": "#3AA99F",
"cyan600": "#24837B",
"blue400": "#4385BE",
"blue600": "#205EA6",
"purple400": "#8B7EC8",
"purple600": "#5E409D",
"magenta400": "#CE5D97",
"magenta600": "#A02F6F"
},
"theme": {
"primary": {
"dark": "orange400",
"light": "blue600"
},
"secondary": {
"dark": "blue400",
"light": "purple600"
},
"accent": {
"dark": "purple400",
"light": "orange600"
},
"error": {
"dark": "red400",
"light": "red600"
},
"warning": {
"dark": "orange400",
"light": "orange600"
},
"success": {
"dark": "green400",
"light": "green600"
},
"info": {
"dark": "cyan400",
"light": "cyan600"
},
"text": {
"dark": "base200",
"light": "black"
},
"textMuted": {
"dark": "base600",
"light": "base600"
},
"background": {
"dark": "black",
"light": "paper"
},
"backgroundPanel": {
"dark": "base950",
"light": "base50"
},
"backgroundElement": {
"dark": "base900",
"light": "base100"
},
"border": {
"dark": "base700",
"light": "base300"
},
"borderActive": {
"dark": "base600",
"light": "base500"
},
"borderSubtle": {
"dark": "base800",
"light": "base200"
},
"diffAdded": {
"dark": "green400",
"light": "green600"
},
"diffRemoved": {
"dark": "red400",
"light": "red600"
},
"diffContext": {
"dark": "base600",
"light": "base600"
},
"diffHunkHeader": {
"dark": "blue400",
"light": "blue600"
},
"diffHighlightAdded": {
"dark": "green400",
"light": "green600"
},
"diffHighlightRemoved": {
"dark": "red400",
"light": "red600"
},
"diffAddedBg": {
"dark": "#1A2D1A",
"light": "#D5E5D5"
},
"diffRemovedBg": {
"dark": "#2D1A1A",
"light": "#F7D8DB"
},
"diffContextBg": {
"dark": "base950",
"light": "base50"
},
"diffLineNumber": {
"dark": "base600",
"light": "base600"
},
"diffAddedLineNumberBg": {
"dark": "#152515",
"light": "#C5D5C5"
},
"diffRemovedLineNumberBg": {
"dark": "#251515",
"light": "#E7C8CB"
},
"markdownText": {
"dark": "base200",
"light": "black"
},
"markdownHeading": {
"dark": "purple400",
"light": "purple600"
},
"markdownLink": {
"dark": "blue400",
"light": "blue600"
},
"markdownLinkText": {
"dark": "cyan400",
"light": "cyan600"
},
"markdownCode": {
"dark": "cyan400",
"light": "cyan600"
},
"markdownBlockQuote": {
"dark": "yellow400",
"light": "yellow600"
},
"markdownEmph": {
"dark": "yellow400",
"light": "yellow600"
},
"markdownStrong": {
"dark": "orange400",
"light": "orange600"
},
"markdownHorizontalRule": {
"dark": "base600",
"light": "base600"
},
"markdownListItem": {
"dark": "orange400",
"light": "orange600"
},
"markdownListEnumeration": {
"dark": "cyan400",
"light": "cyan600"
},
"markdownImage": {
"dark": "magenta400",
"light": "magenta600"
},
"markdownImageText": {
"dark": "cyan400",
"light": "cyan600"
},
"markdownCodeBlock": {
"dark": "base200",
"light": "black"
},
"syntaxComment": {
"dark": "base600",
"light": "base600"
},
"syntaxKeyword": {
"dark": "green400",
"light": "green600"
},
"syntaxFunction": {
"dark": "orange400",
"light": "orange600"
},
"syntaxVariable": {
"dark": "blue400",
"light": "blue600"
},
"syntaxString": {
"dark": "cyan400",
"light": "cyan600"
},
"syntaxNumber": {
"dark": "purple400",
"light": "purple600"
},
"syntaxType": {
"dark": "yellow400",
"light": "yellow600"
},
"syntaxOperator": {
"dark": "base300",
"light": "base600"
},
"syntaxPunctuation": {
"dark": "base300",
"light": "base600"
}
}
}
@@ -3,6 +3,7 @@ import { useSync } from "@tui/context/sync"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
import { Clipboard } from "@tui/util/clipboard"
import type { PromptInfo } from "@tui/component/prompt/history"
export function DialogMessage(props: {
@@ -54,6 +55,26 @@ export function DialogMessage(props: {
dialog.clear()
},
},
{
title: "Copy",
value: "message.copy",
description: "copy message text to clipboard",
onSelect: async (dialog) => {
const msg = message()
if (!msg) return
const parts = sync.data.part[msg.id]
const text = parts.reduce((agg, part) => {
if (part.type === "text" && !part.synthetic) {
agg += part.text
}
return agg
}, "")
await Clipboard.copy(text)
dialog.clear()
},
},
{
title: "Fork",
value: "session.fork",
@@ -6,6 +6,8 @@ import {
For,
Match,
on,
onCleanup,
onMount,
Show,
Switch,
useContext,
@@ -17,7 +19,13 @@ import { useRoute, useRouteData } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { SplitBorder } from "@tui/component/border"
import { useTheme } from "@tui/context/theme"
import { BoxRenderable, ScrollBoxRenderable, TextAttributes, addDefaultParsers } from "@opentui/core"
import {
BoxRenderable,
ScrollBoxRenderable,
addDefaultParsers,
MacOSScrollAccel,
type ScrollAcceleration,
} from "@opentui/core"
import { Prompt, type PromptRef } from "@tui/component/prompt"
import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk"
import { useLocal } from "@tui/context/local"
@@ -46,6 +54,7 @@ import { DialogMessage } from "./dialog-message"
import type { PromptInfo } from "../../component/prompt/history"
import { iife } from "@/util/iife"
import { DialogConfirm } from "@tui/ui/dialog-confirm"
import { DialogPrompt } from "@tui/ui/dialog-prompt"
import { DialogTimeline } from "./dialog-timeline"
import { DialogSessionRename } from "../../component/dialog-session-rename"
import { Sidebar } from "./sidebar"
@@ -58,13 +67,23 @@ import { Editor } from "../../util/editor"
import { Global } from "@/global"
import fs from "fs/promises"
import stripAnsi from "strip-ansi"
import { LSP } from "@/lsp/index.ts"
addDefaultParsers(parsers.parsers)
class CustomSpeedScroll implements ScrollAcceleration {
constructor(private speed: number) {}
tick(_now?: number): number {
return this.speed
}
reset(): void {}
}
const context = createContext<{
width: number
conceal: () => boolean
showThinking: () => boolean
}>()
function use() {
@@ -84,17 +103,29 @@ export function Session() {
const permissions = createMemo(() => sync.data.permission[route.sessionID] ?? [])
const pending = createMemo(() => {
return messages().findLast((x) => x.role === "assistant" && !x.time?.completed)?.id
return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id
})
const dimensions = useTerminalDimensions()
const [sidebar, setSidebar] = createSignal<"show" | "hide" | "auto">(kv.get("sidebar", "auto"))
const [conceal, setConceal] = createSignal(true)
const [showThinking, setShowThinking] = createSignal(true)
const wide = createMemo(() => dimensions().width > 120)
const sidebarVisible = createMemo(() => sidebar() === "show" || (sidebar() === "auto" && wide()))
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
const scrollAcceleration = createMemo(() => {
const tui = sync.data.config.tui
if (tui?.scroll_acceleration?.enabled) {
return new MacOSScrollAccel()
}
if (tui?.scroll_speed) {
return new CustomSpeedScroll(tui.scroll_speed)
}
return undefined
})
createEffect(async () => {
await sync.session
.sync(route.sessionID)
@@ -111,9 +142,30 @@ export function Session() {
})
const toast = useToast()
const sdk = useSDK()
// Auto-navigate to whichever session currently needs permission input
createEffect(() => {
const currentSession = session()
if (!currentSession) return
const currentPermissions = permissions()
let targetID = currentPermissions.length > 0 ? currentSession.id : undefined
if (!targetID) {
const child = sync.data.session.find(
(x) => x.parentID === currentSession.id && (sync.data.permission[x.id]?.length ?? 0) > 0,
)
if (child) targetID = child.id
}
if (targetID && targetID !== currentSession.id) {
navigate({
type: "session",
sessionID: targetID,
})
}
})
let scroll: ScrollBoxRenderable
let prompt: PromptRef
const keybind = useKeybind()
@@ -352,6 +404,15 @@ export function Session() {
dialog.clear()
},
},
{
title: "Toggle thinking blocks",
value: "session.toggle.thinking",
category: "Session",
onSelect: (dialog) => {
setShowThinking((prev) => !prev)
dialog.clear()
},
},
{
title: "Page up",
value: "session.page.up",
@@ -539,12 +600,17 @@ export function Session() {
transcript += `---\n\n`
}
// Save to file in data directory
const exportDir = path.join(Global.Path.data, "exports")
await fs.mkdir(exportDir, { recursive: true })
// Prompt for optional filename
const customFilename = await DialogPrompt.show(dialog, "Export filename", {
value: `session-${sessionData.id.slice(0, 8)}.md`,
})
const timestamp = new Date().toISOString().replace(/[:.]/g, "-")
const filename = `session-${sessionData.id.slice(0, 8)}-${timestamp}.md`
// Cancel if user pressed escape
if (customFilename === null) return
// Save to file in current working directory
const exportDir = process.cwd()
const filename = customFilename.trim()
const filepath = path.join(exportDir, filename)
await Bun.write(filepath, transcript)
@@ -641,6 +707,7 @@ export function Session() {
return contentWidth()
},
conceal,
showThinking,
}}
>
<box flexDirection="row" paddingBottom={1} paddingTop={1} paddingLeft={2} paddingRight={2} gap={2}>
@@ -684,6 +751,7 @@ export function Session() {
stickyScroll={true}
stickyStart="bottom"
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
>
<For each={messages()}>
{(message, index) => (
@@ -772,7 +840,7 @@ export function Session() {
</Match>
<Match when={message.role === "assistant"}>
<AssistantMessage
last={index() === messages().length - 1}
last={pending() === message.id}
message={message as AssistantMessage}
parts={sync.data.part[message.id] ?? []}
/>
@@ -827,64 +895,87 @@ function UserMessage(props: {
const queued = createMemo(() => props.pending && props.message.id > props.pending)
const color = createMemo(() => (queued() ? theme.accent : theme.secondary))
const compaction = createMemo(() => props.parts.find((x) => x.type === "compaction"))
return (
<Show when={text()}>
<box
id={props.message.id}
onMouseOver={() => {
setHover(true)
}}
onMouseOut={() => {
setHover(false)
}}
onMouseUp={props.onMouseUp}
border={["left"]}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
marginTop={props.index === 0 ? 0 : 1}
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
customBorderChars={SplitBorder.customBorderChars}
borderColor={color()}
flexShrink={0}
>
<text fg={theme.text}>{text()?.text}</text>
<Show when={files().length}>
<box flexDirection="row" paddingBottom={1} paddingTop={1} gap={1} flexWrap="wrap">
<For each={files()}>
{(file) => {
const bg = createMemo(() => {
if (file.mime.startsWith("image/")) return theme.accent
if (file.mime === "application/pdf") return theme.primary
return theme.secondary
})
return (
<text fg={theme.text}>
<span style={{ bg: bg(), fg: theme.background }}> {MIME_BADGE[file.mime] ?? file.mime} </span>
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.filename} </span>
</text>
)
}}
</For>
</box>
</Show>
<text fg={theme.text}>
{sync.data.config.username ?? "You"}{" "}
<Show
when={queued()}
fallback={<span style={{ fg: theme.textMuted }}>({Locale.time(props.message.time.created)})</span>}
<>
<Show when={text()}>
<box
id={props.message.id}
border={["left"]}
borderColor={color()}
customBorderChars={SplitBorder.customBorderChars}
marginTop={props.index === 0 ? 0 : 1}
>
<box
onMouseOver={() => {
setHover(true)
}}
onMouseOut={() => {
setHover(false)
}}
onMouseUp={props.onMouseUp}
paddingTop={1}
paddingBottom={1}
paddingLeft={1}
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
flexShrink={0}
>
<span style={{ bg: theme.accent, fg: theme.backgroundPanel, bold: true }}> QUEUED </span>
</Show>
</text>
</box>
</Show>
<text fg={theme.text}>{text()?.text}</text>
<Show when={files().length}>
<box flexDirection="row" paddingBottom={1} paddingTop={1} gap={1} flexWrap="wrap">
<For each={files()}>
{(file) => {
const bg = createMemo(() => {
if (file.mime.startsWith("image/")) return theme.accent
if (file.mime === "application/pdf") return theme.primary
return theme.secondary
})
return (
<text fg={theme.text}>
<span style={{ bg: bg(), fg: theme.background }}> {MIME_BADGE[file.mime] ?? file.mime} </span>
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.filename} </span>
</text>
)
}}
</For>
</box>
</Show>
<text fg={theme.textMuted}>
{sync.data.config.username ?? "You"}{" "}
<Show
when={queued()}
fallback={<span style={{ fg: theme.textMuted }}>{Locale.time(props.message.time.created)}</span>}
>
<span style={{ bg: theme.accent, fg: theme.backgroundPanel, bold: true }}> QUEUED </span>
</Show>
</text>
</box>
</box>
</Show>
<Show when={compaction()}>
<box
marginTop={1}
border={["top"]}
title=" Compaction "
titleAlignment="center"
borderColor={theme.borderActive}
/>
</Show>
</>
)
}
function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; last: boolean }) {
const local = useLocal()
const { theme } = useTheme()
const sync = useSync()
const status = createMemo(
() =>
sync.data.session_status[props.message.sessionID] ?? {
type: "idle",
},
)
return (
<>
<For each={props.parts}>
@@ -916,38 +1007,62 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
<text fg={theme.textMuted}>{props.message.error?.data.message}</text>
</box>
</Show>
<Show
when={
!props.message.time.completed ||
(props.last && props.parts.some((item) => item.type === "step-finish" && item.reason === "tool-calls"))
}
>
<box
paddingLeft={2}
marginTop={1}
flexDirection="row"
gap={1}
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.backgroundElement}
<Switch>
<Match when={props.last && status().type !== "idle" && false}>
<box paddingLeft={3} flexDirection="row" gap={1} marginTop={1}>
<text fg={local.agent.color(props.message.mode)}>{Locale.titlecase(props.message.mode)}</text>
<Shimmer text={props.message.modelID} color={theme.text} />
{(() => {
const retry = createMemo(() => {
const s = status()
if (s.type !== "retry") return
return s
})
const message = createMemo(() => {
const r = retry()
if (!r) return
if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
return "gemini 3 way too hot right now"
if (r.message.length > 50) return r.message.slice(0, 50) + "..."
return r.message
})
const [seconds, setSeconds] = createSignal(0)
onMount(() => {
const timer = setInterval(() => {
const next = retry()?.next
if (next) setSeconds(Math.round((next - Date.now()) / 1000))
}, 1000)
onCleanup(() => {
clearInterval(timer)
})
})
return (
<Show when={retry()}>
<text fg={theme.error}>
{message()} [retrying {seconds() > 0 ? `in ${seconds()}s ` : ""}
attempt #{retry()!.attempt}]
</text>
</Show>
)
})()}
</box>
</Match>
<Match
when={
(props.message.time.completed &&
props.parts.some((item) => item.type === "step-finish" && item.reason !== "tool-calls")) ||
props.last
}
>
<text fg={local.agent.color(props.message.mode)}>{Locale.titlecase(props.message.mode)}</text>
<Shimmer text={`${props.message.modelID}`} color={theme.text} />
</box>
</Show>
<Show
when={
props.message.time.completed &&
props.parts.some((item) => item.type === "step-finish" && item.reason !== "tool-calls")
}
>
<box paddingLeft={3}>
<text marginTop={1}>
<span style={{ fg: local.agent.color(props.message.mode) }}>{Locale.titlecase(props.message.mode)}</span>{" "}
<span style={{ fg: theme.textMuted }}>{props.message.modelID}</span>
</text>
</box>
</Show>
<box paddingLeft={3}>
<text marginTop={1}>
<span style={{ fg: local.agent.color(props.message.mode) }}>{Locale.titlecase(props.message.mode)}</span>{" "}
<span style={{ fg: theme.textMuted }}>{props.message.modelID}</span>
</text>
</box>
</Match>
</Switch>
</>
)
}
@@ -963,7 +1078,7 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
const ctx = use()
const content = createMemo(() => props.part.text.trim())
return (
<Show when={content()}>
<Show when={content() && ctx.showThinking()}>
<box
id={"text-" + props.part.id}
paddingLeft={2}
@@ -1196,9 +1311,10 @@ ToolRegistry.register<typeof WriteTool>({
container: "block",
render(props) {
const { theme, syntax } = useTheme()
const lines = createMemo(() => {
return props.input.content?.split("\n") ?? []
})
const lines = createMemo(
() => (typeof props.input.content === "string" ? props.input.content.split("\n") : []),
[] as string[],
)
const code = createMemo(() => {
if (!props.input.content) return ""
const text = props.input.content
@@ -1224,7 +1340,7 @@ ToolRegistry.register<typeof WriteTool>({
<For each={numbers()}>{(value) => <text style={{ fg: theme.textMuted }}>{value}</text>}</For>
</box>
<box paddingLeft={1} flexGrow={1}>
<code filetype={filetype(props.input.filePath!)} syntaxStyle={syntax()} content={code()} />
<code fg={theme.text} filetype={filetype(props.input.filePath!)} syntaxStyle={syntax()} content={code()} />
</box>
</box>
<Show when={diagnostics().length}>
@@ -1434,16 +1550,16 @@ ToolRegistry.register<typeof EditTool>({
<Match when={diff() && style() === "split"}>
<box paddingLeft={1} flexDirection="row" gap={2}>
<box flexGrow={1} flexBasis={0}>
<code filetype={ft()} syntaxStyle={syntax()} content={diff()!.oldContent} />
<code fg={theme.text} filetype={ft()} syntaxStyle={syntax()} content={diff()!.oldContent} />
</box>
<box flexGrow={1} flexBasis={0}>
<code filetype={ft()} syntaxStyle={syntax()} content={diff()!.newContent} />
<code fg={theme.text} filetype={ft()} syntaxStyle={syntax()} content={diff()!.newContent} />
</box>
</box>
</Match>
<Match when={code()}>
<box paddingLeft={1}>
<code filetype={ft()} syntaxStyle={syntax()} content={code()} />
<code fg={theme.text} filetype={ft()} syntaxStyle={syntax()} content={code()} />
</box>
</Match>
</Switch>
@@ -1,5 +1,5 @@
import { useSync } from "@tui/context/sync"
import { createMemo, For, Show, Switch, Match } from "solid-js"
import { createMemo, For, Show, Switch, Match, createSignal } from "solid-js"
import { useTheme } from "../../context/theme"
import { Locale } from "@/util/locale"
import path from "path"
@@ -13,6 +13,11 @@ export function Sidebar(props: { sessionID: string }) {
const todo = createMemo(() => sync.data.todo[props.sessionID] ?? [])
const messages = createMemo(() => sync.data.message[props.sessionID] ?? [])
const [mcpExpanded, setMcpExpanded] = createSignal(true)
const [diffExpanded, setDiffExpanded] = createSignal(true)
const [todoExpanded, setTodoExpanded] = createSignal(true)
const [lspExpanded, setLspExpanded] = createSignal(true)
const cost = createMemo(() => {
const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
return new Intl.NumberFormat("en-US", {
@@ -55,110 +60,154 @@ export function Sidebar(props: { sessionID: string }) {
</box>
<Show when={Object.keys(sync.data.mcp).length > 0}>
<box>
<text fg={theme.text}>
<b>MCP</b>
</text>
<For each={Object.entries(sync.data.mcp)}>
{([key, item]) => (
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
style={{
fg: {
connected: theme.success,
failed: theme.error,
disabled: theme.textMuted,
}[item.status],
}}
>
</text>
<text fg={theme.text} wrapMode="word">
{key}{" "}
<span style={{ fg: theme.textMuted }}>
<Switch>
<Match when={item.status === "connected"}>Connected</Match>
<Match when={item.status === "failed" && item}>{(val) => <i>{val().error}</i>}</Match>
<Match when={item.status === "disabled"}>Disabled in configuration</Match>
</Switch>
</span>
</text>
</box>
)}
</For>
<box
flexDirection="row"
gap={1}
onMouseDown={() => Object.keys(sync.data.mcp).length > 2 && setMcpExpanded(!mcpExpanded())}
>
<Show when={Object.keys(sync.data.mcp).length > 2}>
<text fg={theme.text}>{mcpExpanded() ? "▼" : "▶"}</text>
</Show>
<text fg={theme.text}>
<b>MCP</b>
</text>
</box>
<Show when={Object.keys(sync.data.mcp).length <= 2 || mcpExpanded()}>
<For each={Object.entries(sync.data.mcp)}>
{([key, item]) => (
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
style={{
fg: {
connected: theme.success,
failed: theme.error,
disabled: theme.textMuted,
}[item.status],
}}
>
</text>
<text fg={theme.text} wrapMode="word">
{key}{" "}
<span style={{ fg: theme.textMuted }}>
<Switch>
<Match when={item.status === "connected"}>Connected</Match>
<Match when={item.status === "failed" && item}>{(val) => <i>{val().error}</i>}</Match>
<Match when={item.status === "disabled"}>Disabled in configuration</Match>
</Switch>
</span>
</text>
</box>
)}
</For>
</Show>
</box>
</Show>
<Show when={sync.data.lsp.length > 0}>
<box>
<text fg={theme.text}>
<b>LSP</b>
</text>
<For each={sync.data.lsp}>
{(item) => (
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
style={{
fg: {
connected: theme.success,
error: theme.error,
}[item.status],
}}
>
</text>
<text fg={theme.textMuted}>
{item.id} {item.root}
</text>
</box>
)}
</For>
<box
flexDirection="row"
gap={1}
onMouseDown={() => sync.data.lsp.length > 2 && setLspExpanded(!lspExpanded())}
>
<Show when={sync.data.lsp.length > 2}>
<text fg={theme.text}>{lspExpanded() ? "▼" : "▶"}</text>
</Show>
<text fg={theme.text}>
<b>LSP</b>
</text>
</box>
<Show when={sync.data.lsp.length <= 2 || lspExpanded()}>
<For each={sync.data.lsp}>
{(item) => (
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
style={{
fg: {
connected: theme.success,
error: theme.error,
}[item.status],
}}
>
</text>
<text fg={theme.textMuted}>
{item.id} {item.root}
</text>
</box>
)}
</For>
</Show>
</box>
</Show>
<Show when={todo().length > 0}>
<box>
<text fg={theme.text}>
<b>Todo</b>
</text>
<For each={todo()}>
{(todo) => (
<text style={{ fg: todo.status === "in_progress" ? theme.success : theme.textMuted }}>
[{todo.status === "completed" ? "" : " "}] {todo.content}
</text>
)}
</For>
<box
flexDirection="row"
gap={1}
onMouseDown={() => todo().length > 2 && setTodoExpanded(!todoExpanded())}
>
<Show when={todo().length > 2}>
<text fg={theme.text}>{todoExpanded() ? "" : ""}</text>
</Show>
<text fg={theme.text}>
<b>Todo</b>
</text>
</box>
<Show when={todo().length <= 2 || todoExpanded()}>
<For each={todo()}>
{(todo) => (
<text style={{ fg: todo.status === "in_progress" ? theme.success : theme.textMuted }}>
[{todo.status === "completed" ? "✓" : " "}] {todo.content}
</text>
)}
</For>
</Show>
</box>
</Show>
<Show when={diff().length > 0}>
<box>
<text fg={theme.text}>
<b>Modified Files</b>
</text>
<For each={diff() || []}>
{(item) => {
const file = createMemo(() => {
const splits = item.file.split(path.sep).filter(Boolean)
const last = splits.at(-1)!
const rest = splits.slice(0, -1).join(path.sep)
return Locale.truncateMiddle(rest, 30 - last.length) + "/" + last
})
return (
<box flexDirection="row" gap={1} justifyContent="space-between">
<text fg={theme.textMuted} wrapMode="char">
{file()}
</text>
<box flexDirection="row" gap={1} flexShrink={0}>
<Show when={item.additions}>
<text fg={theme.diffAdded}>+{item.additions}</text>
</Show>
<Show when={item.deletions}>
<text fg={theme.diffRemoved}>-{item.deletions}</text>
</Show>
<box
flexDirection="row"
gap={1}
onMouseDown={() => diff().length > 2 && setDiffExpanded(!diffExpanded())}
>
<Show when={diff().length > 2}>
<text fg={theme.text}>{diffExpanded() ? "▼" : "▶"}</text>
</Show>
<text fg={theme.text}>
<b>Modified Files</b>
</text>
</box>
<Show when={diff().length <= 2 || diffExpanded()}>
<For each={diff() || []}>
{(item) => {
const file = createMemo(() => {
const splits = item.file.split(path.sep).filter(Boolean)
const last = splits.at(-1)!
const rest = splits.slice(0, -1).join(path.sep)
return Locale.truncateMiddle(rest, 30 - last.length) + "/" + last
})
return (
<box flexDirection="row" gap={1} justifyContent="space-between">
<text fg={theme.textMuted} wrapMode="char">
{file()}
</text>
<box flexDirection="row" gap={1} flexShrink={0}>
<Show when={item.additions}>
<text fg={theme.diffAdded}>+{item.additions}</text>
</Show>
<Show when={item.deletions}>
<text fg={theme.diffRemoved}>-{item.deletions}</text>
</Show>
</box>
</box>
</box>
)
}}
</For>
)
}}
</For>
</Show>
</box>
</Show>
</box>
+16 -8
View File
@@ -5,6 +5,7 @@ import { type rpc } from "./worker"
import path from "path"
import { UI } from "@/cli/ui"
import { iife } from "@/util/iife"
import { Log } from "@/util/log"
declare global {
const OPENCODE_WORKER_PATH: string
@@ -57,11 +58,16 @@ export const TuiThreadCommand = cmd({
// Resolve relative paths against PWD to preserve behavior when using --cwd flag
const baseCwd = process.env.PWD ?? process.cwd()
const cwd = args.project ? path.resolve(baseCwd, args.project) : process.cwd()
let workerPath: string | URL = new URL("./worker.ts", import.meta.url)
if (typeof OPENCODE_WORKER_PATH !== "undefined") {
workerPath = OPENCODE_WORKER_PATH
}
const defaultWorker = new URL("./worker.ts", import.meta.url)
// Nix build creates a bundled worker next to the binary; prefer it when present.
const execDir = path.dirname(process.execPath)
const bundledWorker = path.join(execDir, "opencode-worker.js")
const hasBundledWorker = await Bun.file(bundledWorker).exists()
const workerPath = (() => {
if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH
if (hasBundledWorker) return bundledWorker
return defaultWorker
})()
try {
process.chdir(cwd)
} catch (e) {
@@ -74,13 +80,15 @@ export const TuiThreadCommand = cmd({
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
),
})
worker.onerror = console.error
worker.onerror = (e) => {
Log.Default.error(e)
}
const client = Rpc.client<typeof rpc>(worker)
process.on("uncaughtException", (e) => {
console.error(e)
Log.Default.error(e)
})
process.on("unhandledRejection", (e) => {
console.error(e)
Log.Default.error(e)
})
const server = await client.call("server", {
port: args.port,
@@ -2,10 +2,12 @@ import { TextAttributes } from "@opentui/core"
import { useTheme } from "@tui/context/theme"
import { useDialog } from "./dialog"
import { useKeyboard } from "@opentui/solid"
import { useKeybind } from "@tui/context/keybind"
export function DialogHelp() {
const dialog = useDialog()
const { theme } = useTheme()
const keybind = useKeybind()
useKeyboard((evt) => {
if (evt.name === "return" || evt.name === "escape") {
@@ -20,7 +22,9 @@ export function DialogHelp() {
<text fg={theme.textMuted}>esc/enter</text>
</box>
<box paddingBottom={1}>
<text fg={theme.textMuted}>Press Ctrl+P to see all available actions and commands in any context.</text>
<text fg={theme.textMuted}>
Press {keybind.print("command_list")} to see all available actions and commands in any context.
</text>
</box>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
<box paddingLeft={3} paddingRight={3} backgroundColor={theme.primary} onMouseUp={() => dialog.clear()}>
@@ -1,11 +1,13 @@
import { TextareaRenderable, TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { onMount } from "solid-js"
import { onMount, type JSX } from "solid-js"
import { useKeyboard } from "@opentui/solid"
export type DialogPromptProps = {
title: string
description?: () => JSX.Element
placeholder?: string
value?: string
onConfirm?: (value: string) => void
onCancel?: () => void
@@ -19,12 +21,11 @@ export function DialogPrompt(props: DialogPromptProps) {
useKeyboard((evt) => {
if (evt.name === "return") {
props.onConfirm?.(textarea.plainText)
dialog.clear()
}
})
onMount(() => {
dialog.setSize("large")
dialog.setSize("medium")
setTimeout(() => {
textarea.focus()
}, 1)
@@ -37,35 +38,36 @@ export function DialogPrompt(props: DialogPromptProps) {
<text attributes={TextAttributes.BOLD}>{props.title}</text>
<text fg={theme.textMuted}>esc</text>
</box>
<box>
<box gap={1}>
{props.description}
<textarea
onSubmit={() => {
props.onConfirm?.(textarea.plainText)
dialog.clear()
}}
height={3}
keyBindings={[{ name: "return", action: "submit" }]}
ref={(val: TextareaRenderable) => (textarea = val)}
initialValue={props.value}
placeholder="Enter text"
placeholder={props.placeholder ?? "Enter text"}
/>
</box>
<box paddingBottom={1}>
<text fg={theme.textMuted}>Press enter to confirm, esc to cancel</text>
<box paddingBottom={1} gap={1} flexDirection="row">
<text fg={theme.text}>
enter <span style={{ fg: theme.textMuted }}>submit</span>
</text>
<text fg={theme.text}>
esc <span style={{ fg: theme.textMuted }}>cancel</span>
</text>
</box>
</box>
)
}
DialogPrompt.show = (dialog: DialogContext, title: string, value?: string) => {
DialogPrompt.show = (dialog: DialogContext, title: string, options?: Omit<DialogPromptProps, "title">) => {
return new Promise<string | null>((resolve) => {
dialog.replace(
() => (
<DialogPrompt
title={title}
value={value}
onConfirm={(value) => resolve(value)}
onCancel={() => resolve(null)}
/>
<DialogPrompt title={title} {...options} onConfirm={(value) => resolve(value)} onCancel={() => resolve(null)} />
),
() => resolve(null),
)
@@ -157,7 +157,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
return (
<box gap={1}>
<box paddingLeft={3} paddingRight={2}>
<box paddingLeft={4} paddingRight={4}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text} attributes={TextAttributes.BOLD}>
{props.title}
@@ -184,8 +184,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
</box>
</box>
<scrollbox
paddingLeft={2}
paddingRight={2}
paddingLeft={1}
paddingRight={1}
scrollbarOptions={{ visible: false }}
ref={(r: ScrollBoxRenderable) => (scroll = r)}
maxHeight={height()}
@@ -194,7 +194,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
{([category, options], index) => (
<>
<Show when={category}>
<box paddingTop={index() > 0 ? 1 : 0} paddingLeft={1}>
<box paddingTop={index() > 0 ? 1 : 0} paddingLeft={3}>
<text fg={theme.accent} attributes={TextAttributes.BOLD}>
{category}
</text>
@@ -203,6 +203,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<For each={options}>
{(option) => {
const active = createMemo(() => isDeepEqual(option.value, selected()?.value))
const current = createMemo(() => isDeepEqual(option.value, props.current))
return (
<box
id={JSON.stringify(option.value)}
@@ -217,8 +218,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
moveTo(index)
}}
backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)}
paddingLeft={1}
paddingRight={1}
paddingLeft={current() ? 1 : 3}
paddingRight={3}
gap={1}
>
<Option
@@ -226,7 +227,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
footer={option.footer}
description={option.description !== category ? option.description : undefined}
active={active()}
current={isDeepEqual(option.value, props.current)}
current={current()}
/>
</box>
)
@@ -236,12 +237,14 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
)}
</For>
</scrollbox>
<box paddingRight={2} paddingLeft={3} flexDirection="row" paddingBottom={1} gap={1}>
<box paddingRight={2} paddingLeft={4} flexDirection="row" paddingBottom={1} gap={1}>
<For each={props.keybind ?? []}>
{(item) => (
<text>
<span style={{ fg: theme.text, attributes: TextAttributes.BOLD }}>{Keybind.toString(item.keybind)}</span>
<span style={{ fg: theme.textMuted }}> {item.title}</span>
<span style={{ fg: theme.text }}>
<b>{item.title}</b>{" "}
</span>
<span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span>
</text>
)}
</For>
@@ -268,7 +271,7 @@ function Option(props: {
fg={props.active ? theme.background : props.current ? theme.primary : theme.text}
marginRight={0.5}
>
</text>
</Show>
<text
@@ -277,6 +280,7 @@ function Option(props: {
attributes={props.active ? TextAttributes.BOLD : undefined}
overflow="hidden"
wrapMode="none"
paddingLeft={3}
>
{Locale.truncate(props.title, 62)}
<span style={{ fg: props.active ? theme.background : theme.textMuted }}> {props.description}</span>
@@ -3,6 +3,8 @@ import { batch, createContext, Show, useContext, type JSX, type ParentProps } fr
import { useTheme } from "@tui/context/theme"
import { Renderable, RGBA } from "@opentui/core"
import { createStore } from "solid-js/store"
import { Clipboard } from "@tui/util/clipboard"
import { useToast } from "./toast"
export function Dialog(
props: ParentProps<{
@@ -12,10 +14,12 @@ export function Dialog(
) {
const dimensions = useTerminalDimensions()
const { theme } = useTheme()
const renderer = useRenderer()
return (
<box
onMouseUp={async () => {
if (renderer.getSelection()) return
props.onClose?.()
}}
width={dimensions().width}
@@ -29,6 +33,7 @@ export function Dialog(
>
<box
onMouseUp={async (e) => {
if (renderer.getSelection()) return
e.stopPropagation()
}}
width={props.size === "large" ? 80 : 60}
@@ -124,10 +129,28 @@ const ctx = createContext<DialogContext>()
export function DialogProvider(props: ParentProps) {
const value = init()
const renderer = useRenderer()
const toast = useToast()
return (
<ctx.Provider value={value}>
{props.children}
<box position="absolute">
<box
position="absolute"
onMouseUp={async () => {
const text = renderer.getSelection()?.getSelectedText()
if (text && text.length > 0) {
const base64 = Buffer.from(text).toString("base64")
const osc52 = `\x1b]52;c;${base64}\x07`
const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
/* @ts-expect-error */
renderer.writeOut(finalOsc52)
await Clipboard.copy(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
renderer.clearSelection()
}
}}
>
<Show when={value.stack.length}>
<Dialog onClose={() => value.clear()} size={value.size}>
{value.stack.at(-1)!.element}
@@ -1,6 +1,7 @@
import { createContext, useContext, type ParentProps, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useTheme } from "@tui/context/theme"
import { useTerminalDimensions } from "@opentui/solid"
import { SplitBorder } from "../component/border"
import { TextAttributes } from "@opentui/core"
import z from "zod"
@@ -11,6 +12,7 @@ export type ToastOptions = z.infer<typeof TuiEvent.ToastShow.properties>
export function Toast() {
const toast = useToast()
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
return (
<Show when={toast.currentToast}>
@@ -21,6 +23,7 @@ export function Toast() {
alignItems="flex-start"
top={2}
right={2}
maxWidth={Math.min(60, dimensions().width - 6)}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
@@ -31,11 +34,13 @@ export function Toast() {
customBorderChars={SplitBorder.customBorderChars}
>
<Show when={current().title}>
<text attributes={TextAttributes.BOLD} marginBottom={1}>
<text attributes={TextAttributes.BOLD} marginBottom={1} fg={theme.text}>
{current().title}
</text>
</Show>
<text>{current().message}</text>
<text fg={theme.text} wrapMode="word" width="100%">
{current().message}
</text>
</box>
)}
</Show>
@@ -1,5 +1,5 @@
import { $ } from "bun"
import { platform } from "os"
import { platform, release } from "os"
import clipboardy from "clipboardy"
import { lazy } from "../../../../util/lazy.js"
import { tmpdir } from "os"
@@ -29,6 +29,18 @@ export namespace Clipboard {
}
}
if (os === "win32" || release().includes("WSL")) {
const script =
"Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
const base64 = await $`powershell.exe -command "${script}"`.nothrow().text()
if (base64) {
const imageBuffer = Buffer.from(base64.trim(), "base64")
if (imageBuffer.length > 0) {
return { data: imageBuffer.toString("base64"), mime: "image/png" }
}
}
}
if (os === "linux") {
const wayland = await $`wl-paste -t image/png`.nothrow().arrayBuffer()
if (wayland && wayland.byteLength > 0) {
@@ -40,18 +52,6 @@ export namespace Clipboard {
}
}
if (os === "win32") {
const script =
"Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
const base64 = await $`powershell -command "${script}"`.nothrow().text()
if (base64) {
const imageBuffer = Buffer.from(base64.trim(), "base64")
if (imageBuffer.length > 0) {
return { data: imageBuffer.toString("base64"), mime: "image/png" }
}
}
}
const text = await clipboardy.read().catch(() => {})
if (text) {
return { data: text, mime: "text/plain" }
@@ -6,7 +6,7 @@ import { CliRenderer } from "@opentui/core"
export namespace Editor {
export async function open(opts: { value: string; renderer: CliRenderer }): Promise<string | undefined> {
const editor = process.env["EDITOR"]
const editor = process.env["VISUAL"] || process.env["EDITOR"]
if (!editor) return
const filepath = join(tmpdir(), `${Date.now()}.md`)
@@ -43,6 +43,7 @@ export const rpc = {
}
},
async shutdown() {
Log.Default.info("worker shutting down")
await Instance.disposeAll()
await server.stop(true)
},
+1 -1
View File
@@ -25,7 +25,7 @@ export function FormatError(input: unknown) {
)
}
if (Config.ConfigDirectoryTypoError.isInstance(input)) {
return `Directory "${input.data.dir}" in ${input.data.path} is not valid. Use "${input.data.suggestion}" instead. This is a common typo.`
return `Directory "${input.data.dir}" in ${input.data.path} is not valid. Rename the directory to "${input.data.suggestion}" or remove it. This is a common typo.`
}
if (ConfigMarkdown.FrontmatterError.isInstance(input)) {
return `Failed to parse frontmatter in ${input.data.path}:\n${input.data.message}`
+50 -30
View File
@@ -75,7 +75,7 @@ export namespace Config {
for (const dir of directories) {
await assertValid(dir)
if (dir.endsWith(".opencode")) {
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
for (const file of ["opencode.jsonc", "opencode.json"]) {
log.debug(`loading config from ${path.join(dir, file)}`)
result = mergeDeep(result, await loadFile(path.join(dir, file)))
@@ -337,7 +337,7 @@ export namespace Config {
export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote])
export type Mcp = z.infer<typeof Mcp>
export const Permission = z.union([z.literal("ask"), z.literal("allow"), z.literal("deny")])
export const Permission = z.enum(["ask", "allow", "deny"])
export type Permission = z.infer<typeof Permission>
export const Command = z.object({
@@ -358,7 +358,7 @@ export namespace Config {
tools: z.record(z.string(), z.boolean()).optional(),
disable: z.boolean().optional(),
description: z.string().optional().describe("Description of when to use the agent"),
mode: z.union([z.literal("subagent"), z.literal("primary"), z.literal("all")]).optional(),
mode: z.enum(["subagent", "primary", "all"]).optional(),
color: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color format")
@@ -428,8 +428,8 @@ export namespace Config {
input_newline: z.string().optional().default("shift+return,ctrl+j").describe("Insert newline in input"),
history_previous: z.string().optional().default("up").describe("Previous history item"),
history_next: z.string().optional().default("down").describe("Next history item"),
session_child_cycle: z.string().optional().default("ctrl+right").describe("Next child session"),
session_child_cycle_reverse: z.string().optional().default("ctrl+left").describe("Previous child session"),
session_child_cycle: z.string().optional().default("<leader>right").describe("Next child session"),
session_child_cycle_reverse: z.string().optional().default("<leader>left").describe("Previous child session"),
})
.strict()
.meta({
@@ -437,7 +437,13 @@ export namespace Config {
})
export const TUI = z.object({
scroll_speed: z.number().min(1).optional().default(2).describe("TUI scroll speed"),
scroll_speed: z.number().min(0.001).optional().default(1).describe("TUI scroll speed"),
scroll_acceleration: z
.object({
enabled: z.boolean().describe("Enable scroll acceleration"),
})
.optional()
.describe("Scroll acceleration settings"),
})
export const Layout = z.enum(["auto", "stretch"]).meta({
@@ -474,6 +480,10 @@ export namespace Config {
.describe("@deprecated Use 'share' field instead. Share newly created sessions automatically"),
autoupdate: z.boolean().optional().describe("Automatically update to the latest version"),
disabled_providers: z.array(z.string()).optional().describe("Disable providers that are loaded automatically"),
enabled_providers: z
.array(z.string())
.optional()
.describe("When set, ONLY these providers will be enabled. All other providers will be ignored"),
model: z.string().describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
small_model: z
.string()
@@ -505,6 +515,8 @@ export namespace Config {
z.string(),
ModelsDev.Provider.partial()
.extend({
whitelist: z.array(z.string()).optional(),
blacklist: z.array(z.string()).optional(),
models: z.record(z.string(), ModelsDev.Model.partial()).optional(),
options: z
.object({
@@ -536,36 +548,43 @@ export namespace Config {
.describe("Custom provider configurations and model overrides"),
mcp: z.record(z.string(), Mcp).optional().describe("MCP (Model Context Protocol) server configurations"),
formatter: z
.record(
z.string(),
z.object({
disabled: z.boolean().optional(),
command: z.array(z.string()).optional(),
environment: z.record(z.string(), z.string()).optional(),
extensions: z.array(z.string()).optional(),
}),
)
.union([
z.literal(false),
z.record(
z.string(),
z.object({
disabled: z.boolean().optional(),
command: z.array(z.string()).optional(),
environment: z.record(z.string(), z.string()).optional(),
extensions: z.array(z.string()).optional(),
}),
),
])
.optional(),
lsp: z
.record(
z.string(),
z.union([
z.object({
disabled: z.literal(true),
}),
z.object({
command: z.array(z.string()),
extensions: z.array(z.string()).optional(),
disabled: z.boolean().optional(),
env: z.record(z.string(), z.string()).optional(),
initialization: z.record(z.string(), z.any()).optional(),
}),
]),
)
.union([
z.literal(false),
z.record(
z.string(),
z.union([
z.object({
disabled: z.literal(true),
}),
z.object({
command: z.array(z.string()),
extensions: z.array(z.string()).optional(),
disabled: z.boolean().optional(),
env: z.record(z.string(), z.string()).optional(),
initialization: z.record(z.string(), z.any()).optional(),
}),
]),
),
])
.optional()
.refine(
(data) => {
if (!data) return true
if (typeof data === "boolean") return true
const serverIds = new Set(Object.values(LSPServer).map((s) => s.id))
return Object.entries(data).every(([id, config]) => {
@@ -616,6 +635,7 @@ export namespace Config {
.optional(),
chatMaxRetries: z.number().optional().describe("Number of retries for chat completions on failure"),
disable_paste_summary: z.boolean().optional(),
batch_tool: z.boolean().optional().describe("Enable the batch tool"),
})
.optional(),
})
+3
View File
@@ -8,8 +8,10 @@ import { lazy } from "../util/lazy"
import { $ } from "bun"
import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js"
import { Log } from "@/util/log"
export namespace Ripgrep {
const log = Log.create({ service: "ripgrep" })
const Stats = z.object({
elapsed: z.object({
secs: z.number(),
@@ -254,6 +256,7 @@ export namespace Ripgrep {
}
export async function tree(input: { cwd: string; limit?: number }) {
log.info("tree", input)
const files = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd }))
interface Node {
path: string[]
+2
View File
@@ -11,6 +11,8 @@ export namespace Flag {
export const OPENCODE_ENABLE_EXPERIMENTAL_MODELS = truthy("OPENCODE_ENABLE_EXPERIMENTAL_MODELS")
export const OPENCODE_DISABLE_AUTOCOMPACT = truthy("OPENCODE_DISABLE_AUTOCOMPACT")
export const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"]
export const OPENCODE_EXPERIMENTAL_BASH_MAX_OUTPUT_LENGTH =
process.env["OPENCODE_EXPERIMENTAL_BASH_MAX_OUTPUT_LENGTH"]
// Experimental
export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL")
+11
View File
@@ -28,6 +28,14 @@ export namespace Format {
const cfg = await Config.get()
const formatters: Record<string, Formatter.Info> = {}
if (cfg.formatter === false) {
log.info("all formatters are disabled")
return {
enabled,
formatters,
}
}
for (const item of Object.values(Formatter)) {
formatters[item.name] = item
}
@@ -41,6 +49,9 @@ export namespace Format {
extensions: [],
...item,
})
if (result.command.length === 0) continue
result.enabled = async () => true
result.name = name
formatters[name] = result
+12 -1
View File
@@ -62,10 +62,21 @@ export namespace LSP {
async () => {
const clients: LSPClient.Info[] = []
const servers: Record<string, LSPServer.Info> = {}
const cfg = await Config.get()
if (cfg.lsp === false) {
log.info("all LSPs are disabled")
return {
broken: new Set<string>(),
servers,
clients,
spawning: new Map<string, Promise<LSPClient.Info | undefined>>(),
}
}
for (const server of Object.values(LSPServer)) {
servers[server.id] = server
}
const cfg = await Config.get()
for (const [name, item] of Object.entries(cfg.lsp ?? {})) {
const existing = servers[name]
if (item.disabled) {
+96 -8
View File
@@ -250,12 +250,12 @@ export namespace LSPServer {
},
}
export const RubyLsp: Info = {
export const Rubocop: Info = {
id: "ruby-lsp",
root: NearestRoot(["Gemfile"]),
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async spawn(root) {
let bin = Bun.which("ruby-lsp", {
let bin = Bun.which("rubocop", {
PATH: process.env["PATH"] + ":" + Global.Path.bin,
})
if (!bin) {
@@ -266,25 +266,25 @@ export namespace LSPServer {
return
}
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
log.info("installing ruby-lsp")
log.info("installing rubocop")
const proc = Bun.spawn({
cmd: ["gem", "install", "ruby-lsp", "--bindir", Global.Path.bin],
cmd: ["gem", "install", "rubocop", "--bindir", Global.Path.bin],
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
})
const exit = await proc.exited
if (exit !== 0) {
log.error("Failed to install ruby-lsp")
log.error("Failed to install rubocop")
return
}
bin = path.join(Global.Path.bin, "ruby-lsp" + (process.platform === "win32" ? ".exe" : ""))
log.info(`installed ruby-lsp`, {
bin = path.join(Global.Path.bin, "rubocop" + (process.platform === "win32" ? ".exe" : ""))
log.info(`installed rubocop`, {
bin,
})
}
return {
process: spawn(bin!, ["--stdio"], {
process: spawn(bin!, ["--lsp"], {
cwd: root,
}),
}
@@ -945,6 +945,54 @@ export namespace LSPServer {
},
}
export const YamlLS: Info = {
id: "yaml-ls",
extensions: [".yaml", ".yml"],
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
async spawn(root) {
let binary = Bun.which("yaml-language-server")
const args: string[] = []
if (!binary) {
const js = path.join(
Global.Path.bin,
"node_modules",
"yaml-language-server",
"out",
"server",
"src",
"server.js",
)
const exists = await Bun.file(js).exists()
if (!exists) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "yaml-language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
}
args.push("--stdio")
const proc = spawn(binary, args, {
cwd: root,
env: {
...process.env,
BUN_BE_BUN: "1",
},
})
return {
process: proc,
}
},
}
export const LuaLS: Info = {
id: "lua-ls",
root: NearestRoot([
@@ -1077,4 +1125,44 @@ export namespace LSPServer {
}
},
}
export const PHPIntelephense: Info = {
id: "php intelephense",
extensions: [".php"],
root: NearestRoot(["composer.json", "composer.lock", ".php-version"]),
async spawn(root) {
let binary = Bun.which("intelephense")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "intelephense", "lib", "intelephense.js")
if (!(await Bun.file(js).exists())) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "intelephense"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
}
args.push("--stdio")
const proc = spawn(binary, args, {
cwd: root,
env: {
...process.env,
BUN_BE_BUN: "1",
},
})
return {
process: proc,
initialization: {},
}
},
}
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { experimental_createMCPClient, type Tool } from "ai"
import { type Tool } from "ai"
import { experimental_createMCPClient } from "@ai-sdk/mcp"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
+6 -1
View File
@@ -186,8 +186,13 @@ export namespace Permission {
public readonly permissionID: string,
public readonly toolCallID?: string,
public readonly metadata?: Record<string, any>,
public readonly reason?: string,
) {
super(`The user rejected permission to use this specific tool call. You may try again with different parameters.`)
super(
reason !== undefined
? reason
: `The user rejected permission to use this specific tool call. You may try again with different parameters.`,
)
}
}
}
+8 -3
View File
@@ -51,12 +51,17 @@ export const Instance = {
async dispose() {
Log.Default.info("disposing instance", { directory: Instance.directory })
await State.dispose(Instance.directory)
cache.delete(Instance.directory)
},
async disposeAll() {
Log.Default.info("disposing all instances")
for (const [_key, value] of cache) {
await context.provide(await value, async () => {
await Instance.dispose()
})
const awaited = await value.catch(() => {})
if (awaited) {
await context.provide(await value, async () => {
await Instance.dispose()
})
}
}
cache.clear()
},

Some files were not shown because too many files have changed in this diff Show More