merge dev
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,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": {
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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,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",
|
||||
|
||||
@@ -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,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)}‎</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,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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")
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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 }
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1 +1,7 @@
|
||||
@import "@opencode-ai/ui/styles/tailwind";
|
||||
|
||||
:root {
|
||||
a {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)}‎
|
||||
</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)}‎</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -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,2 +1,3 @@
|
||||
export * from "./path"
|
||||
export * from "./dom"
|
||||
export * from "./encode"
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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%
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { EventEmitter } from "events"
|
||||
|
||||
export const GlobalBus = new EventEmitter<{
|
||||
event: [
|
||||
{
|
||||
directory: string
|
||||
payload: any
|
||||
},
|
||||
]
|
||||
}>()
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {},
|
||||
})
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: {},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user