mirror of
https://github.com/aljazceru/opencode.git
synced 2025-12-30 14:14:20 +01:00
Merge branch 'dev' of https://github.com/sst/opencode into dev
This commit is contained in:
@@ -41,7 +41,7 @@ export function Header(props: { zen?: boolean }) {
|
||||
notation: "compact",
|
||||
compactDisplay: "short",
|
||||
}).format(githubData()?.stars!)
|
||||
: "25K",
|
||||
: "29K",
|
||||
)
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
|
||||
@@ -26,7 +26,7 @@ export async function POST(event: APIEvent) {
|
||||
// Create email content
|
||||
const emailContent = `
|
||||
${body.message}<br><br>
|
||||
--
|
||||
--<br>
|
||||
${body.name}<br>
|
||||
${body.role}<br>
|
||||
${body.email}`.trim()
|
||||
|
||||
@@ -65,94 +65,95 @@ export default function Enterprise() {
|
||||
<h2>Your code is yours</h2>
|
||||
<p>
|
||||
OpenCode operates securely inside your organization with no data or context stored
|
||||
and no licensing restrictions or ownership claims. Start a trial with your team
|
||||
, then deploy it across your organization by integrating it with your SSO and internal AI gateway.
|
||||
and no licensing restrictions or ownership claims. Start a trial with your team,
|
||||
then deploy it across your organization by integrating it with your SSO and
|
||||
internal AI gateway.
|
||||
</p>
|
||||
<p>Let us know and how we can help.</p>
|
||||
|
||||
<Show when={false}>
|
||||
<div data-component="testimonial">
|
||||
<div data-component="quotation">
|
||||
<svg
|
||||
width="20"
|
||||
height="17"
|
||||
viewBox="0 0 20 17"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M19.4118 0L16.5882 9.20833H20V17H12.2353V10.0938L16 0H19.4118ZM7.17647 0L4.35294 9.20833H7.76471V17H0V10.0938L3.76471 0H7.17647Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<div data-component="testimonial">
|
||||
<div data-component="quotation">
|
||||
<svg
|
||||
width="20"
|
||||
height="17"
|
||||
viewBox="0 0 20 17"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M19.4118 0L16.5882 9.20833H20V17H12.2353V10.0938L16 0H19.4118ZM7.17647 0L4.35294 9.20833H7.76471V17H0V10.0938L3.76471 0H7.17647Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
Thanks to OpenCode, we found a way to create software to track all our assets —
|
||||
even the imaginary ones.
|
||||
<div data-component="testimonial-logo">
|
||||
<svg
|
||||
width="80"
|
||||
height="79"
|
||||
viewBox="0 0 80 79"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M0 39.3087L10.0579 29.251L15.6862 34.7868L13.7488 36.7248L10.3345 33.2186L8.48897 35.0639L11.8111 38.4781L9.96557 40.4156L6.55181 37.0018L4.06028 39.4928L7.56674 42.9991L5.62884 44.845L0 39.3087Z"
|
||||
fill="#0083C6"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M17.7182 36.8164L20.2094 39.4003L16.6108 46.9666L22.2393 41.3374L24.3615 43.46L14.2118 53.5179L11.9047 51.1187L15.4112 43.3677L9.78254 49.0888L7.66016 46.9666L17.7182 36.8164Z"
|
||||
fill="#0083C6"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M42.8139 61.915L45.3055 64.4064L41.6145 71.9731L47.243 66.3441L49.3652 68.4663L39.3077 78.5244L36.9088 76.1252L40.5072 68.374L34.7866 74.0953L32.6641 71.9731L42.8139 61.915Z"
|
||||
fill="#0083C6"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M16.4258 55.7324L26.4833 45.582L28.6061 47.7042C31.0049 50.1034 32.3892 51.9497 30.1746 54.1642C28.7902 55.548 27.6831 56.0094 26.1145 54.9016L26.0222 54.994C27.2218 56.1941 26.9448 57.1162 25.4688 58.5931L23.9 60.1615C23.4383 60.6232 22.8847 61.2693 22.7927 62.0067L20.6705 59.8845C20.7625 59.146 21.3161 58.5008 21.778 58.1316L23.5307 56.3788C24.269 55.6403 23.715 54.2555 23.254 53.8872L22.8847 53.4256L18.548 57.7623L16.4258 55.7324ZM24.3611 51.9495C25.4689 53.0563 26.4833 53.3332 27.4984 52.3178C28.5134 51.3957 28.2367 50.3802 27.1295 49.1812L24.3611 51.9495Z"
|
||||
fill="#0083C6"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M33.4952 66.9899C31.096 69.3891 28.8815 68.4659 27.4047 66.9899C26.021 65.6062 25.0978 63.3907 27.4972 60.9003L31.8336 56.6548C34.2333 54.2556 36.4478 55.0864 37.9241 56.5635C39.308 58.0396 40.2311 60.2541 37.8315 62.6531L33.4952 66.9899ZM29.0659 63.5752C28.6048 64.0369 28.6048 64.7753 29.1583 65.3292C29.6196 65.8821 30.4502 65.7897 30.8194 65.4215L36.2633 59.9769C36.7246 59.6076 36.7246 58.7779 36.171 58.3164C35.7097 57.7626 34.8791 57.7626 34.5101 58.2241L29.0659 63.5752Z"
|
||||
fill="#0083C6"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M78.5267 39.308L68.2845 29.0654L47.5231 49.735L49.6453 51.8572L68.2845 33.2179L74.3746 39.308L47.2461 66.3435L49.3683 68.4657L78.5267 39.308Z"
|
||||
fill="#0083C6"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M49.6443 51.8577L43.3695 45.4902L64.0386 24.8215L53.7969 14.4873L33.0352 35.2482L35.1574 37.3705L53.7969 18.7315L59.7947 24.8215L39.1251 45.4902L47.5221 53.9799L49.6443 51.8577Z"
|
||||
fill="#2D9C5C"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M35.1564 37.3706L28.7896 31.0038L49.5515 10.3347L39.3088 0L10.0586 29.2507L12.1804 31.2804L39.3088 4.24476L45.3066 10.3347L24.6377 31.0038L33.0342 39.4008L35.1564 37.3706Z"
|
||||
fill="#E92A35"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M77.2332 52.4105C76.0336 52.4105 75.111 51.4884 75.111 50.196C75.111 48.9046 76.0336 47.9814 77.2332 47.9814C78.3405 47.9814 79.263 48.9046 79.263 50.196C79.263 51.4884 78.3405 52.4105 77.2332 52.4105ZM77.2332 52.9643C78.7098 52.9643 80.0015 51.6729 80.0015 50.196C80.0015 48.6276 78.7096 47.4287 77.2332 47.4287C75.6644 47.4287 74.4648 48.6278 74.4648 50.196C74.4647 51.6731 75.6643 52.9643 77.2332 52.9643ZM76.1259 51.7653H76.6797V50.3804H77.0485L77.8788 51.7653H78.4332L77.6023 50.3804C78.1558 50.2881 78.4332 50.0122 78.4332 49.5507C78.4332 48.9046 78.0633 48.6276 77.3253 48.6276H76.1257V51.7653H76.1259ZM76.6797 49.0892H77.2332C77.5102 49.0892 77.8788 49.0892 77.8788 49.4586C77.8788 49.9202 77.6023 49.9202 77.2332 49.9202H76.6797V49.0892Z"
|
||||
fill="#0083C6"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
Thanks to OpenCode, we found a way to create software to track all our assets —
|
||||
even the imaginary ones.
|
||||
<div data-component="testimonial-logo">
|
||||
<svg
|
||||
width="80"
|
||||
height="79"
|
||||
viewBox="0 0 80 79"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M0 39.3087L10.0579 29.251L15.6862 34.7868L13.7488 36.7248L10.3345 33.2186L8.48897 35.0639L11.8111 38.4781L9.96557 40.4156L6.55181 37.0018L4.06028 39.4928L7.56674 42.9991L5.62884 44.845L0 39.3087Z"
|
||||
fill="#0083C6"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M17.7182 36.8164L20.2094 39.4003L16.6108 46.9666L22.2393 41.3374L24.3615 43.46L14.2118 53.5179L11.9047 51.1187L15.4112 43.3677L9.78254 49.0888L7.66016 46.9666L17.7182 36.8164Z"
|
||||
fill="#0083C6"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M42.8139 61.915L45.3055 64.4064L41.6145 71.9731L47.243 66.3441L49.3652 68.4663L39.3077 78.5244L36.9088 76.1252L40.5072 68.374L34.7866 74.0953L32.6641 71.9731L42.8139 61.915Z"
|
||||
fill="#0083C6"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M16.4258 55.7324L26.4833 45.582L28.6061 47.7042C31.0049 50.1034 32.3892 51.9497 30.1746 54.1642C28.7902 55.548 27.6831 56.0094 26.1145 54.9016L26.0222 54.994C27.2218 56.1941 26.9448 57.1162 25.4688 58.5931L23.9 60.1615C23.4383 60.6232 22.8847 61.2693 22.7927 62.0067L20.6705 59.8845C20.7625 59.146 21.3161 58.5008 21.778 58.1316L23.5307 56.3788C24.269 55.6403 23.715 54.2555 23.254 53.8872L22.8847 53.4256L18.548 57.7623L16.4258 55.7324ZM24.3611 51.9495C25.4689 53.0563 26.4833 53.3332 27.4984 52.3178C28.5134 51.3957 28.2367 50.3802 27.1295 49.1812L24.3611 51.9495Z"
|
||||
fill="#0083C6"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M33.4952 66.9899C31.096 69.3891 28.8815 68.4659 27.4047 66.9899C26.021 65.6062 25.0978 63.3907 27.4972 60.9003L31.8336 56.6548C34.2333 54.2556 36.4478 55.0864 37.9241 56.5635C39.308 58.0396 40.2311 60.2541 37.8315 62.6531L33.4952 66.9899ZM29.0659 63.5752C28.6048 64.0369 28.6048 64.7753 29.1583 65.3292C29.6196 65.8821 30.4502 65.7897 30.8194 65.4215L36.2633 59.9769C36.7246 59.6076 36.7246 58.7779 36.171 58.3164C35.7097 57.7626 34.8791 57.7626 34.5101 58.2241L29.0659 63.5752Z"
|
||||
fill="#0083C6"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M78.5267 39.308L68.2845 29.0654L47.5231 49.735L49.6453 51.8572L68.2845 33.2179L74.3746 39.308L47.2461 66.3435L49.3683 68.4657L78.5267 39.308Z"
|
||||
fill="#0083C6"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M49.6443 51.8577L43.3695 45.4902L64.0386 24.8215L53.7969 14.4873L33.0352 35.2482L35.1574 37.3705L53.7969 18.7315L59.7947 24.8215L39.1251 45.4902L47.5221 53.9799L49.6443 51.8577Z"
|
||||
fill="#2D9C5C"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M35.1564 37.3706L28.7896 31.0038L49.5515 10.3347L39.3088 0L10.0586 29.2507L12.1804 31.2804L39.3088 4.24476L45.3066 10.3347L24.6377 31.0038L33.0342 39.4008L35.1564 37.3706Z"
|
||||
fill="#E92A35"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M77.2332 52.4105C76.0336 52.4105 75.111 51.4884 75.111 50.196C75.111 48.9046 76.0336 47.9814 77.2332 47.9814C78.3405 47.9814 79.263 48.9046 79.263 50.196C79.263 51.4884 78.3405 52.4105 77.2332 52.4105ZM77.2332 52.9643C78.7098 52.9643 80.0015 51.6729 80.0015 50.196C80.0015 48.6276 78.7096 47.4287 77.2332 47.4287C75.6644 47.4287 74.4648 48.6278 74.4648 50.196C74.4647 51.6731 75.6643 52.9643 77.2332 52.9643ZM76.1259 51.7653H76.6797V50.3804H77.0485L77.8788 51.7653H78.4332L77.6023 50.3804C78.1558 50.2881 78.4332 50.0122 78.4332 49.5507C78.4332 48.9046 78.0633 48.6276 77.3253 48.6276H76.1257V51.7653H76.1259ZM76.6797 49.0892H77.2332C77.5102 49.0892 77.8788 49.0892 77.8788 49.4586C77.8788 49.9202 77.6023 49.9202 77.2332 49.9202H76.6797V49.0892Z"
|
||||
fill="#0083C6"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -219,8 +219,8 @@ export default function Home() {
|
||||
<div>
|
||||
<span>[*]</span>
|
||||
<p>
|
||||
With over <strong>26,000</strong> GitHub stars, <strong>188</strong> contributors, and almost{" "}
|
||||
<strong>3,000</strong> commits, OpenCode is used and trusted by over <strong>200,000</strong>{" "}
|
||||
With over <strong>29,000</strong> GitHub stars, <strong>230</strong> contributors, and almost{" "}
|
||||
<strong>3,500</strong> commits, OpenCode is used and trusted by over <strong>250,000</strong>{" "}
|
||||
developers every month.
|
||||
</p>
|
||||
</div>
|
||||
@@ -274,7 +274,7 @@ export default function Home() {
|
||||
</svg>
|
||||
</div>
|
||||
<span>
|
||||
<figure>Fig 1.</figure> <strong>26K</strong> GitHub Stars
|
||||
<figure>Fig 1.</figure> <strong>29K</strong> GitHub Stars
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -577,7 +577,7 @@ export default function Home() {
|
||||
</svg>
|
||||
</div>
|
||||
<span>
|
||||
<figure>Fig 2.</figure> <strong>188</strong> Contributors
|
||||
<figure>Fig 2.</figure> <strong>230</strong> Contributors
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -619,7 +619,7 @@ export default function Home() {
|
||||
</svg>
|
||||
</div>
|
||||
<span>
|
||||
<figure>Fig 3.</figure> <strong>200K</strong> Monthly Devs
|
||||
<figure>Fig 3.</figure> <strong>250K</strong> Monthly Devs
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,11 @@ import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js"
|
||||
import { ProviderTable } from "@opencode-ai/console-core/schema/provider.sql.js"
|
||||
import { logger } from "./logger"
|
||||
import { AuthError, CreditsError, MonthlyLimitError, UserLimitError, ModelError } from "./error"
|
||||
import { createBodyConverter, createStreamPartConverter, createResponseConverter } from "./provider/provider"
|
||||
import {
|
||||
createBodyConverter,
|
||||
createStreamPartConverter,
|
||||
createResponseConverter,
|
||||
} from "./provider/provider"
|
||||
import { Format } from "./format"
|
||||
import { anthropicHelper } from "./provider/anthropic"
|
||||
import { openaiHelper } from "./provider/openai"
|
||||
@@ -43,7 +47,11 @@ export async function handler(
|
||||
})
|
||||
const zenData = ZenData.list()
|
||||
const modelInfo = validateModel(zenData, body.model)
|
||||
const providerInfo = selectProvider(zenData, modelInfo, input.request.headers.get("x-real-ip") ?? "")
|
||||
const providerInfo = selectProvider(
|
||||
zenData,
|
||||
modelInfo,
|
||||
input.request.headers.get("x-real-ip") ?? "",
|
||||
)
|
||||
const authInfo = await authenticate(modelInfo, providerInfo)
|
||||
validateBilling(modelInfo, authInfo)
|
||||
validateModelSettings(authInfo)
|
||||
@@ -222,7 +230,11 @@ export async function handler(
|
||||
return { id: modelId, ...modelData }
|
||||
}
|
||||
|
||||
function selectProvider(zenData: ZenData, model: Awaited<ReturnType<typeof validateModel>>, ip: string) {
|
||||
function selectProvider(
|
||||
zenData: ZenData,
|
||||
model: Awaited<ReturnType<typeof validateModel>>,
|
||||
ip: string,
|
||||
) {
|
||||
const providers = model.providers
|
||||
.filter((provider) => !provider.disabled)
|
||||
.flatMap((provider) => Array<typeof provider>(provider.weight ?? 1).fill(provider))
|
||||
@@ -239,7 +251,11 @@ export async function handler(
|
||||
return {
|
||||
...provider,
|
||||
...zenData.providers[provider.id],
|
||||
...(provider.id === "anthropic" ? anthropicHelper : provider.id === "openai" ? openaiHelper : oaCompatHelper),
|
||||
...(provider.id === "anthropic"
|
||||
? anthropicHelper
|
||||
: provider.id === "openai"
|
||||
? openaiHelper
|
||||
: oaCompatHelper),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,11 +295,20 @@ export async function handler(
|
||||
.from(KeyTable)
|
||||
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, KeyTable.workspaceID))
|
||||
.innerJoin(BillingTable, eq(BillingTable.workspaceID, KeyTable.workspaceID))
|
||||
.innerJoin(UserTable, and(eq(UserTable.workspaceID, KeyTable.workspaceID), eq(UserTable.id, KeyTable.userID)))
|
||||
.leftJoin(ModelTable, and(eq(ModelTable.workspaceID, KeyTable.workspaceID), eq(ModelTable.model, model.id)))
|
||||
.innerJoin(
|
||||
UserTable,
|
||||
and(eq(UserTable.workspaceID, KeyTable.workspaceID), eq(UserTable.id, KeyTable.userID)),
|
||||
)
|
||||
.leftJoin(
|
||||
ModelTable,
|
||||
and(eq(ModelTable.workspaceID, KeyTable.workspaceID), eq(ModelTable.model, model.id)),
|
||||
)
|
||||
.leftJoin(
|
||||
ProviderTable,
|
||||
and(eq(ProviderTable.workspaceID, KeyTable.workspaceID), eq(ProviderTable.provider, providerInfo.id)),
|
||||
and(
|
||||
eq(ProviderTable.workspaceID, KeyTable.workspaceID),
|
||||
eq(ProviderTable.provider, providerInfo.id),
|
||||
),
|
||||
)
|
||||
.where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted)))
|
||||
.then((rows) => rows[0]),
|
||||
@@ -307,12 +332,20 @@ export async function handler(
|
||||
}
|
||||
|
||||
function validateBilling(model: Model, authInfo: Awaited<ReturnType<typeof authenticate>>) {
|
||||
if (!authInfo || authInfo.isFree) return
|
||||
if (!authInfo) return
|
||||
if (authInfo.provider?.credentials) return
|
||||
if (authInfo.isFree) return
|
||||
if (model.allowAnonymous) return
|
||||
|
||||
const billing = authInfo.billing
|
||||
if (!billing.paymentMethodID) throw new CreditsError("No payment method")
|
||||
if (billing.balance <= 0) throw new CreditsError("Insufficient balance")
|
||||
if (!billing.paymentMethodID)
|
||||
throw new CreditsError(
|
||||
`No payment method. Add a payment method here: https://opencode.ai/workspace/${authInfo.workspaceID}/billing`,
|
||||
)
|
||||
if (billing.balance <= 0)
|
||||
throw new CreditsError(
|
||||
`Insufficient balance. Manage your billing here: https://opencode.ai/workspace/${authInfo.workspaceID}/billing`,
|
||||
)
|
||||
|
||||
const now = new Date()
|
||||
const currentYear = now.getUTCFullYear()
|
||||
@@ -327,7 +360,7 @@ export async function handler(
|
||||
const dateMonth = billing.timeMonthlyUsageUpdated.getUTCMonth()
|
||||
if (currentYear === dateYear && currentMonth === dateMonth)
|
||||
throw new MonthlyLimitError(
|
||||
`Your workspace has reached its monthly spending limit of $${billing.monthlyLimit}.`,
|
||||
`Your workspace has reached its monthly spending limit of $${billing.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/billing`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -340,7 +373,9 @@ export async function handler(
|
||||
const dateYear = authInfo.user.timeMonthlyUsageUpdated.getUTCFullYear()
|
||||
const dateMonth = authInfo.user.timeMonthlyUsageUpdated.getUTCMonth()
|
||||
if (currentYear === dateYear && currentMonth === dateMonth)
|
||||
throw new UserLimitError(`You have reached your monthly spending limit of $${authInfo.user.monthlyLimit}.`)
|
||||
throw new UserLimitError(
|
||||
`You have reached your monthly spending limit of $${authInfo.user.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/members`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,12 +399,19 @@ export async function handler(
|
||||
providerInfo: Awaited<ReturnType<typeof selectProvider>>,
|
||||
usage: any,
|
||||
) {
|
||||
const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } =
|
||||
providerInfo.normalizeUsage(usage)
|
||||
const {
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
reasoningTokens,
|
||||
cacheReadTokens,
|
||||
cacheWrite5mTokens,
|
||||
cacheWrite1hTokens,
|
||||
} = providerInfo.normalizeUsage(usage)
|
||||
|
||||
const modelCost =
|
||||
modelInfo.cost200K &&
|
||||
inputTokens + (cacheReadTokens ?? 0) + (cacheWrite5mTokens ?? 0) + (cacheWrite1hTokens ?? 0) > 200_000
|
||||
inputTokens + (cacheReadTokens ?? 0) + (cacheWrite5mTokens ?? 0) + (cacheWrite1hTokens ?? 0) >
|
||||
200_000
|
||||
? modelInfo.cost200K
|
||||
: modelInfo.cost
|
||||
|
||||
@@ -420,7 +462,8 @@ export async function handler(
|
||||
|
||||
if (!authInfo) return
|
||||
|
||||
const cost = authInfo.isFree || authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent)
|
||||
const cost =
|
||||
authInfo.isFree || authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent)
|
||||
await Database.transaction(async (tx) => {
|
||||
await tx.insert(UsageTable).values({
|
||||
workspaceID: authInfo.workspaceID,
|
||||
@@ -460,7 +503,9 @@ export async function handler(
|
||||
`,
|
||||
timeMonthlyUsageUpdated: sql`now()`,
|
||||
})
|
||||
.where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id)))
|
||||
.where(
|
||||
and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id)),
|
||||
)
|
||||
})
|
||||
|
||||
await Database.use((tx) =>
|
||||
@@ -487,7 +532,10 @@ export async function handler(
|
||||
eq(BillingTable.workspaceID, authInfo.workspaceID),
|
||||
eq(BillingTable.reload, true),
|
||||
lt(BillingTable.balance, centsToMicroCents(Billing.CHARGE_THRESHOLD)),
|
||||
or(isNull(BillingTable.timeReloadLockedTill), lt(BillingTable.timeReloadLockedTill, sql`now()`)),
|
||||
or(
|
||||
isNull(BillingTable.timeReloadLockedTill),
|
||||
lt(BillingTable.timeReloadLockedTill, sql`now()`),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
33
packages/console/core/script/lookup-user.ts
Normal file
33
packages/console/core/script/lookup-user.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Database, eq } from "../src/drizzle/index.js"
|
||||
import { AuthTable } from "../src/schema/auth.sql"
|
||||
|
||||
// get input from command line
|
||||
const email = process.argv[2]
|
||||
if (!email) {
|
||||
console.error("Usage: bun lookup-user.ts <email>")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const authData = await printTable("Auth", (tx) =>
|
||||
tx.select().from(AuthTable).where(eq(AuthTable.subject, email)),
|
||||
)
|
||||
if (authData.length === 0) {
|
||||
console.error("User not found")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await printTable("Auth", (tx) =>
|
||||
tx.select().from(AuthTable).where(eq(AuthTable.accountID, authData[0].accountID)),
|
||||
)
|
||||
|
||||
function printTable(
|
||||
title: string,
|
||||
callback: (tx: Database.TxOrDb) => Promise<any[]>,
|
||||
): Promise<any[]> {
|
||||
return Database.use(async (tx) => {
|
||||
const data = await callback(tx)
|
||||
console.log(`== ${title} ==`)
|
||||
console.table(data)
|
||||
return data
|
||||
})
|
||||
}
|
||||
@@ -1,13 +1,21 @@
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { Database } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
|
||||
import { AccountTable } from "@opencode-ai/console-core/schema/account.sql.js"
|
||||
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
|
||||
import { BillingTable, PaymentTable, UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
||||
import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
|
||||
import { Database } from "../src/drizzle/index.js"
|
||||
import { UserTable } from "../src/schema/user.sql.js"
|
||||
import { AccountTable } from "../src/schema/account.sql.js"
|
||||
import { WorkspaceTable } from "../src/schema/workspace.sql.js"
|
||||
import { BillingTable, PaymentTable, UsageTable } from "../src/schema/billing.sql.js"
|
||||
import { KeyTable } from "../src/schema/key.sql.js"
|
||||
|
||||
if (Resource.App.stage !== "frank") throw new Error("This script is only for frank")
|
||||
|
||||
for (const table of [AccountTable, BillingTable, KeyTable, PaymentTable, UsageTable, UserTable, WorkspaceTable]) {
|
||||
for (const table of [
|
||||
AccountTable,
|
||||
BillingTable,
|
||||
KeyTable,
|
||||
PaymentTable,
|
||||
UsageTable,
|
||||
UserTable,
|
||||
WorkspaceTable,
|
||||
]) {
|
||||
await Database.use((tx) => tx.delete(table))
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"start": "vite",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
@@ -11,7 +12,6 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"opencode": "workspace:*",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@tsconfig/bun": "1.0.9",
|
||||
"@types/luxon": "3.7.1",
|
||||
@@ -26,7 +26,6 @@
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@pierre/precision-diffs": "catalog:",
|
||||
"@shikijs/transformers": "3.9.2",
|
||||
"@solid-primitives/active-element": "2.1.3",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { bundledLanguages, type BundledLanguage, type ShikiTransformer } from "s
|
||||
import { splitProps, type ComponentProps, createEffect, onMount, onCleanup, createMemo, createResource } from "solid-js"
|
||||
import { useLocal, type TextSelection } from "@/context/local"
|
||||
import { getFileExtension, getNodeOffsetInLine, getSelectionInContainer } from "@/utils"
|
||||
import { useShiki } from "@/context/shiki"
|
||||
import { useShiki } from "@opencode-ai/ui"
|
||||
|
||||
type DefinedSelection = Exclude<TextSelection, undefined>
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { FileDiff } from "@opencode-ai/sdk"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
|
||||
export function DiffChanges(props: { diff: FileDiff | FileDiff[] }) {
|
||||
const additions = createMemo(() =>
|
||||
Array.isArray(props.diff) ? props.diff.reduce((acc, diff) => acc + (diff.additions ?? 0), 0) : props.diff.additions,
|
||||
)
|
||||
const deletions = createMemo(() =>
|
||||
Array.isArray(props.diff) ? props.diff.reduce((acc, diff) => acc + (diff.deletions ?? 0), 0) : props.diff.deletions,
|
||||
)
|
||||
const total = createMemo(() => additions() + deletions())
|
||||
return (
|
||||
<Show when={total() > 0}>
|
||||
<div class="flex gap-2 justify-end items-center">
|
||||
<span class="text-12-mono text-right text-text-diff-add-base">{`+${additions()}`}</span>
|
||||
<span class="text-12-mono text-right text-text-diff-delete-base">{`-${deletions()}`}</span>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { useMarked } from "@/context/marked"
|
||||
import { createResource } from "solid-js"
|
||||
|
||||
function strip(text: string): string {
|
||||
const wrappedRe = /^\s*<([A-Za-z]\w*)>\s*([\s\S]*?)\s*<\/\1>\s*$/
|
||||
const match = text.match(wrappedRe)
|
||||
return match ? match[2] : text
|
||||
}
|
||||
export function Markdown(props: { text: string; class?: string }) {
|
||||
const marked = useMarked()
|
||||
const [html] = createResource(
|
||||
() => strip(props.text),
|
||||
async (markdown) => {
|
||||
return marked.parse(markdown)
|
||||
},
|
||||
)
|
||||
return (
|
||||
<div
|
||||
class={`min-w-0 max-w-full overflow-auto no-scrollbar text-14-regular text-text-base ${props.class ?? ""}`}
|
||||
innerHTML={html()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,459 +0,0 @@
|
||||
import type { Part, ReasoningPart, TextPart, ToolPart, Message, AssistantMessage, UserMessage } from "@opencode-ai/sdk"
|
||||
import { children, Component, createMemo, For, Match, Show, Switch, type JSX } from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { Markdown } from "./markdown"
|
||||
import { Checkbox, Collapsible, Diff, Icon, IconProps } from "@opencode-ai/ui"
|
||||
import { getDirectory, getFilename } from "@/utils"
|
||||
import type { Tool } from "opencode/tool/tool"
|
||||
import type { ReadTool } from "opencode/tool/read"
|
||||
import type { ListTool } from "opencode/tool/ls"
|
||||
import type { GlobTool } from "opencode/tool/glob"
|
||||
import type { GrepTool } from "opencode/tool/grep"
|
||||
import type { WebFetchTool } from "opencode/tool/webfetch"
|
||||
import type { TaskTool } from "opencode/tool/task"
|
||||
import type { BashTool } from "opencode/tool/bash"
|
||||
import type { EditTool } from "opencode/tool/edit"
|
||||
import type { WriteTool } from "opencode/tool/write"
|
||||
import type { TodoWriteTool } from "opencode/tool/todo"
|
||||
import { DiffChanges } from "./diff-changes"
|
||||
|
||||
export function Message(props: { message: Message; parts: Part[] }) {
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.message.role === "user" && props.message}>
|
||||
{(userMessage) => <UserMessage message={userMessage()} parts={props.parts} />}
|
||||
</Match>
|
||||
<Match when={props.message.role === "assistant" && props.message}>
|
||||
{(assistantMessage) => <AssistantMessage message={assistantMessage()} parts={props.parts} />}
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
function AssistantMessage(props: { message: AssistantMessage; parts: Part[] }) {
|
||||
const filteredParts = createMemo(() => {
|
||||
return props.parts?.filter((x) => {
|
||||
if (x.type === "reasoning") return false
|
||||
return x.type !== "tool" || x.tool !== "todoread"
|
||||
})
|
||||
})
|
||||
return (
|
||||
<div class="w-full flex flex-col items-start gap-4">
|
||||
<For each={filteredParts()}>{(part) => <Part part={part} message={props.message} />}</For>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UserMessage(props: { message: UserMessage; parts: Part[] }) {
|
||||
const text = createMemo(() =>
|
||||
props.parts
|
||||
?.filter((p) => p.type === "text" && !p.synthetic)
|
||||
?.map((p) => (p as TextPart).text)
|
||||
?.join(""),
|
||||
)
|
||||
return <div class="text-12-regular text-text-base line-clamp-3">{text()}</div>
|
||||
}
|
||||
|
||||
export function Part(props: { part: Part; message: Message; hideDetails?: boolean }) {
|
||||
const component = createMemo(() => PART_MAPPING[props.part.type as keyof typeof PART_MAPPING])
|
||||
return (
|
||||
<Show when={component()}>
|
||||
<Dynamic
|
||||
component={component()}
|
||||
part={props.part as any}
|
||||
message={props.message}
|
||||
hideDetails={props.hideDetails}
|
||||
/>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
const PART_MAPPING = {
|
||||
text: TextPart,
|
||||
tool: ToolPart,
|
||||
reasoning: ReasoningPart,
|
||||
}
|
||||
|
||||
function ReasoningPart(props: { part: ReasoningPart; message: Message }) {
|
||||
return (
|
||||
<Show when={props.part.text.trim()}>
|
||||
<Markdown text={props.part.text.trim()} />
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function TextPart(props: { part: TextPart; message: Message }) {
|
||||
return (
|
||||
<Show when={props.part.text.trim()}>
|
||||
<Markdown text={props.part.text.trim()} />
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolPart(props: { part: ToolPart; message: Message; hideDetails?: boolean }) {
|
||||
const component = createMemo(() => {
|
||||
const render = ToolRegistry.render(props.part.tool) ?? GenericTool
|
||||
const metadata = props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {})
|
||||
const input = props.part.state.status === "completed" ? props.part.state.input : {}
|
||||
|
||||
return (
|
||||
<Dynamic
|
||||
component={render}
|
||||
input={input}
|
||||
tool={props.part.tool}
|
||||
metadata={metadata}
|
||||
output={props.part.state.status === "completed" ? props.part.state.output : undefined}
|
||||
hideDetails={props.hideDetails}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
return <Show when={component()}>{component()}</Show>
|
||||
}
|
||||
|
||||
type TriggerTitle = {
|
||||
title: string
|
||||
titleClass?: string
|
||||
subtitle?: string
|
||||
subtitleClass?: string
|
||||
args?: string[]
|
||||
argsClass?: string
|
||||
action?: JSX.Element
|
||||
}
|
||||
|
||||
const isTriggerTitle = (val: any): val is TriggerTitle => {
|
||||
return typeof val === "object" && val !== null && "title" in val && !(val instanceof Node)
|
||||
}
|
||||
|
||||
function BasicTool(props: {
|
||||
icon: IconProps["name"]
|
||||
trigger: TriggerTitle | JSX.Element
|
||||
children?: JSX.Element
|
||||
hideDetails?: boolean
|
||||
}) {
|
||||
const resolved = children(() => props.children)
|
||||
return (
|
||||
<Collapsible>
|
||||
<Collapsible.Trigger>
|
||||
<div class="w-full flex items-center self-stretch gap-5 justify-between">
|
||||
<div class="w-full flex items-center self-stretch gap-5">
|
||||
<Icon name={props.icon} size="small" class="shrink-0" />
|
||||
<div class="grow min-w-0">
|
||||
<Switch>
|
||||
<Match when={isTriggerTitle(props.trigger) && props.trigger}>
|
||||
{(trigger) => (
|
||||
<div class="w-full flex items-center gap-2 justify-between">
|
||||
<div class="flex items-center gap-2 whitespace-nowrap truncate">
|
||||
<span
|
||||
classList={{
|
||||
"text-12-medium text-text-base": true,
|
||||
[trigger().titleClass ?? ""]: !!trigger().titleClass,
|
||||
}}
|
||||
>
|
||||
{trigger().title}
|
||||
</span>
|
||||
<Show when={trigger().subtitle}>
|
||||
<span
|
||||
classList={{
|
||||
"text-12-medium text-text-weak": true,
|
||||
[trigger().subtitleClass ?? ""]: !!trigger().subtitleClass,
|
||||
}}
|
||||
>
|
||||
{trigger().subtitle}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={trigger().args?.length}>
|
||||
<For each={trigger().args}>
|
||||
{(arg) => (
|
||||
<span
|
||||
classList={{
|
||||
"text-12-regular text-text-weak": true,
|
||||
[trigger().argsClass ?? ""]: !!trigger().argsClass,
|
||||
}}
|
||||
>
|
||||
{arg}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={trigger().action}>{trigger().action}</Show>
|
||||
</div>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={true}>{props.trigger as JSX.Element}</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={resolved() && !props.hideDetails}>
|
||||
<Collapsible.Arrow />
|
||||
</Show>
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Show when={resolved() && !props.hideDetails}>
|
||||
<Collapsible.Content>{resolved()}</Collapsible.Content>
|
||||
</Show>
|
||||
</Collapsible>
|
||||
// <>
|
||||
// <Show when={props.part.state.status === "error"}>{props.part.state.error.replace("Error: ", "")}</Show>
|
||||
// </>
|
||||
)
|
||||
}
|
||||
|
||||
function GenericTool(props: ToolProps<any>) {
|
||||
return <BasicTool icon="mcp" trigger={{ title: props.tool }} hideDetails={props.hideDetails} />
|
||||
}
|
||||
|
||||
type ToolProps<T extends Tool.Info> = {
|
||||
input: Partial<Tool.InferParameters<T>>
|
||||
metadata: Partial<Tool.InferMetadata<T>>
|
||||
tool: string
|
||||
output?: string
|
||||
hideDetails?: boolean
|
||||
}
|
||||
|
||||
const ToolRegistry = (() => {
|
||||
const state: Record<
|
||||
string,
|
||||
{
|
||||
name: string
|
||||
render?: Component<ToolProps<any>>
|
||||
}
|
||||
> = {}
|
||||
function register<T extends Tool.Info>(input: { name: string; render?: Component<ToolProps<T>> }) {
|
||||
state[input.name] = input
|
||||
return input
|
||||
}
|
||||
return {
|
||||
register,
|
||||
render(name: string) {
|
||||
return state[name]?.render
|
||||
},
|
||||
}
|
||||
})()
|
||||
|
||||
ToolRegistry.register<typeof ReadTool>({
|
||||
name: "read",
|
||||
render(props) {
|
||||
return (
|
||||
<BasicTool
|
||||
icon="glasses"
|
||||
trigger={{ title: "Read", subtitle: props.input.filePath ? getFilename(props.input.filePath) : "" }}
|
||||
/>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register<typeof ListTool>({
|
||||
name: "list",
|
||||
render(props) {
|
||||
return (
|
||||
<BasicTool icon="bullet-list" trigger={{ title: "List", subtitle: getDirectory(props.input.path || "/") }}>
|
||||
<Show when={false && props.output}>
|
||||
<div class="whitespace-pre">{props.output}</div>
|
||||
</Show>
|
||||
</BasicTool>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register<typeof GlobTool>({
|
||||
name: "glob",
|
||||
render(props) {
|
||||
return (
|
||||
<BasicTool
|
||||
icon="magnifying-glass-menu"
|
||||
trigger={{
|
||||
title: "Glob",
|
||||
subtitle: getDirectory(props.input.path || "/"),
|
||||
args: props.input.pattern ? ["pattern=" + props.input.pattern] : [],
|
||||
}}
|
||||
>
|
||||
<Show when={false && props.output}>
|
||||
<div class="whitespace-pre">{props.output}</div>
|
||||
</Show>
|
||||
</BasicTool>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register<typeof GrepTool>({
|
||||
name: "grep",
|
||||
render(props) {
|
||||
const args = []
|
||||
if (props.input.pattern) args.push("pattern=" + props.input.pattern)
|
||||
if (props.input.include) args.push("include=" + props.input.include)
|
||||
return (
|
||||
<BasicTool
|
||||
icon="magnifying-glass-menu"
|
||||
trigger={{
|
||||
title: "Grep",
|
||||
subtitle: getDirectory(props.input.path || "/"),
|
||||
args,
|
||||
}}
|
||||
>
|
||||
<Show when={false && props.output}>
|
||||
<div class="whitespace-pre">{props.output}</div>
|
||||
</Show>
|
||||
</BasicTool>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register<typeof WebFetchTool>({
|
||||
name: "webfetch",
|
||||
render(props) {
|
||||
return (
|
||||
<BasicTool
|
||||
icon="window-cursor"
|
||||
trigger={{
|
||||
title: "Webfetch",
|
||||
subtitle: props.input.url || "",
|
||||
args: props.input.format ? ["format=" + props.input.format] : [],
|
||||
action: (
|
||||
<div class="size-6 flex items-center justify-center">
|
||||
<Icon name="square-arrow-top-right" size="small" />
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
>
|
||||
<Show when={false && props.output}>
|
||||
<div class="whitespace-pre">{props.output}</div>
|
||||
</Show>
|
||||
</BasicTool>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register<typeof TaskTool>({
|
||||
name: "task",
|
||||
render(props) {
|
||||
return (
|
||||
<BasicTool
|
||||
icon="task"
|
||||
trigger={{
|
||||
title: `${props.input.subagent_type || props.tool} Agent`,
|
||||
titleClass: "capitalize",
|
||||
subtitle: props.input.description,
|
||||
}}
|
||||
>
|
||||
<Show when={false && props.output}>
|
||||
<div class="whitespace-pre">{props.output}</div>
|
||||
</Show>
|
||||
</BasicTool>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register<typeof BashTool>({
|
||||
name: "bash",
|
||||
render(props) {
|
||||
return (
|
||||
<BasicTool
|
||||
icon="console"
|
||||
trigger={{
|
||||
title: "Shell",
|
||||
subtitle: "Ran " + props.input.command,
|
||||
}}
|
||||
>
|
||||
<Show when={false && props.output}>
|
||||
<div class="whitespace-pre">{props.output}</div>
|
||||
</Show>
|
||||
</BasicTool>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register<typeof EditTool>({
|
||||
name: "edit",
|
||||
render(props) {
|
||||
return (
|
||||
<BasicTool
|
||||
icon="code-lines"
|
||||
trigger={
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="text-12-medium text-text-base capitalize">Edit</div>
|
||||
<div class="flex">
|
||||
<Show when={props.input.filePath?.includes("/")}>
|
||||
<span class="text-text-weak">{getDirectory(props.input.filePath!)}</span>
|
||||
</Show>
|
||||
<span class="text-text-strong">{getFilename(props.input.filePath ?? "")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-4 items-center justify-end">
|
||||
<Show when={props.metadata.filediff}>
|
||||
<DiffChanges diff={props.metadata.filediff} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show when={props.metadata.filediff}>
|
||||
<div class="border-t border-border-weaker-base">
|
||||
<Diff
|
||||
before={{ name: getFilename(props.metadata.filediff.path), contents: props.metadata.filediff.before }}
|
||||
after={{ name: getFilename(props.metadata.filediff.path), contents: props.metadata.filediff.after }}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</BasicTool>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register<typeof WriteTool>({
|
||||
name: "write",
|
||||
render(props) {
|
||||
return (
|
||||
<BasicTool
|
||||
icon="code-lines"
|
||||
trigger={
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="text-12-medium text-text-base capitalize">Write</div>
|
||||
<div class="flex">
|
||||
<Show when={props.input.filePath?.includes("/")}>
|
||||
<span class="text-text-weak">{getDirectory(props.input.filePath!)}</span>
|
||||
</Show>
|
||||
<span class="text-text-strong">{getFilename(props.input.filePath ?? "")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-4 items-center justify-end">{/* <DiffChanges diff={diff} /> */}</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show when={false && props.output}>
|
||||
<div class="whitespace-pre">{props.output}</div>
|
||||
</Show>
|
||||
</BasicTool>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register<typeof TodoWriteTool>({
|
||||
name: "todowrite",
|
||||
render(props) {
|
||||
return (
|
||||
<BasicTool
|
||||
icon="checklist"
|
||||
trigger={{
|
||||
title: "To-dos",
|
||||
subtitle: `${props.input.todos?.filter((t) => t.status === "completed").length}/${props.input.todos?.length}`,
|
||||
}}
|
||||
>
|
||||
<Show when={props.input.todos?.length}>
|
||||
<div class="px-12 pt-2.5 pb-6 flex flex-col gap-2">
|
||||
<For each={props.input.todos}>
|
||||
{(todo) => (
|
||||
<Checkbox readOnly checked={todo.status === "completed"}>
|
||||
<div classList={{ "line-through text-text-weaker": todo.status === "completed" }}>{todo.content}</div>
|
||||
</Checkbox>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</BasicTool>
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -1,48 +0,0 @@
|
||||
import { Component, createMemo } from "solid-js"
|
||||
|
||||
interface ProgressCircleProps {
|
||||
percentage: number
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
}
|
||||
|
||||
export const ProgressCircle: Component<ProgressCircleProps> = (props) => {
|
||||
// --- Set default values for props ---
|
||||
const size = () => props.size || 16
|
||||
const strokeWidth = () => props.strokeWidth || 3
|
||||
|
||||
// --- Constants for SVG calculation ---
|
||||
const viewBoxSize = 16
|
||||
const center = viewBoxSize / 2
|
||||
const radius = () => center - strokeWidth() / 2
|
||||
const circumference = createMemo(() => 2 * Math.PI * radius())
|
||||
|
||||
// --- Reactive Calculation for the progress offset ---
|
||||
const offset = createMemo(() => {
|
||||
const clampedPercentage = Math.max(0, Math.min(100, props.percentage || 0))
|
||||
const progress = clampedPercentage / 100
|
||||
return circumference() * (1 - progress)
|
||||
})
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size()}
|
||||
height={size()}
|
||||
viewBox={`0 0 ${viewBoxSize} ${viewBoxSize}`}
|
||||
fill="none"
|
||||
class="transform -rotate-90"
|
||||
>
|
||||
<circle cx={center} cy={center} r={radius()} class="stroke-border-weak-base" stroke-width={strokeWidth()} />
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius()}
|
||||
class="stroke-border-active"
|
||||
stroke-width={strokeWidth()}
|
||||
stroke-dasharray={circumference().toString()}
|
||||
stroke-dashoffset={offset()}
|
||||
style={{ transition: "stroke-dashoffset 0.35s cubic-bezier(0.65, 0, 0.35, 1)" }}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -71,7 +71,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
})
|
||||
|
||||
const { flat, active, onInput, onKeyDown } = useFilteredList<string>({
|
||||
const { flat, active, onInput, onKeyDown, refetch } = useFilteredList<string>({
|
||||
items: local.file.search,
|
||||
key: (x) => x,
|
||||
onSelect: (path) => {
|
||||
@@ -81,6 +81,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
},
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
local.model.recent()
|
||||
refetch()
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => store.contentParts,
|
||||
@@ -369,16 +374,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
items={local.model.list()}
|
||||
current={local.model.current()}
|
||||
filterKeys={["provider.name", "name", "id"]}
|
||||
groupBy={(x) => x.provider.name}
|
||||
groupBy={(x) => (local.model.recent().includes(x) ? "Recent" : x.provider.name)}
|
||||
sortGroupsBy={(a, b) => {
|
||||
const order = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"]
|
||||
if (a.category === "Recent" && b.category !== "Recent") return -1
|
||||
if (b.category === "Recent" && a.category !== "Recent") return 1
|
||||
const aProvider = a.items[0].provider.id
|
||||
const bProvider = b.items[0].provider.id
|
||||
if (order.includes(aProvider) && !order.includes(bProvider)) return -1
|
||||
if (!order.includes(aProvider) && order.includes(bProvider)) return 1
|
||||
return order.indexOf(aProvider) - order.indexOf(bProvider)
|
||||
}}
|
||||
onSelect={(x) => local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined)}
|
||||
onSelect={(x) =>
|
||||
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { recent: true })
|
||||
}
|
||||
trigger={
|
||||
<Button as="div" variant="ghost">
|
||||
{local.model.current()?.name ?? "Select model"}
|
||||
|
||||
@@ -1,536 +0,0 @@
|
||||
import { Icon, Tooltip } from "@opencode-ai/ui"
|
||||
import { Collapsible } from "@/ui"
|
||||
import type { AssistantMessage, Message, Part, ToolPart } from "@opencode-ai/sdk"
|
||||
import { DateTime } from "luxon"
|
||||
import {
|
||||
createSignal,
|
||||
For,
|
||||
Match,
|
||||
splitProps,
|
||||
Switch,
|
||||
type ComponentProps,
|
||||
type ParentProps,
|
||||
createEffect,
|
||||
createMemo,
|
||||
Show,
|
||||
} from "solid-js"
|
||||
import { getFilename } from "@/utils"
|
||||
import { Markdown } from "./markdown"
|
||||
import { Code } from "./code"
|
||||
import { createElementSize } from "@solid-primitives/resize-observer"
|
||||
import { createScrollPosition } from "@solid-primitives/scroll"
|
||||
import { ProgressCircle } from "./progress-circle"
|
||||
import { pipe, sumBy } from "remeda"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useLocal } from "@/context/local"
|
||||
|
||||
function Part(props: ParentProps & ComponentProps<"div">) {
|
||||
const [local, others] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<div
|
||||
classList={{
|
||||
...(local.classList ?? {}),
|
||||
"h-6 flex items-center": true,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
{...others}
|
||||
>
|
||||
<p class="text-12-medium text-left">{local.children}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsiblePart(props: { title: ParentProps["children"] } & ParentProps & ComponentProps<typeof Collapsible>) {
|
||||
return (
|
||||
<Collapsible {...props}>
|
||||
<Collapsible.Trigger class="peer/collapsible">
|
||||
<Part>{props.title}</Part>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<p class="flex-auto min-w-0 text-pretty">
|
||||
<span class="text-12-medium text-text-weak break-words">{props.children}</span>
|
||||
</p>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
function ReadToolPart(props: { part: ToolPart }) {
|
||||
const sync = useSync()
|
||||
const local = useLocal()
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.part.state.status === "pending"}>
|
||||
<Part>Reading file...</Part>
|
||||
</Match>
|
||||
<Match when={props.part.state.status === "completed" && props.part.state}>
|
||||
{(state) => {
|
||||
const path = state().input["filePath"] as string
|
||||
return (
|
||||
<Part onClick={() => local.file.open(path)}>
|
||||
<span class="">Read</span> {getFilename(path)}
|
||||
</Part>
|
||||
)
|
||||
}}
|
||||
</Match>
|
||||
<Match when={props.part.state.status === "error" && props.part.state}>
|
||||
{(state) => (
|
||||
<div>
|
||||
<Part>
|
||||
<span class="">Read</span> {getFilename(state().input["filePath"] as string)}
|
||||
</Part>
|
||||
<div class="text-icon-critical-active">{sync.sanitize(state().error)}</div>
|
||||
</div>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
function EditToolPart(props: { part: ToolPart }) {
|
||||
const sync = useSync()
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.part.state.status === "pending"}>
|
||||
<Part>Preparing edit...</Part>
|
||||
</Match>
|
||||
<Match when={props.part.state.status === "completed" && props.part.state}>
|
||||
{(state) => (
|
||||
<CollapsiblePart
|
||||
title={
|
||||
<>
|
||||
<span class="">Edit</span> {getFilename(state().input["filePath"] as string)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Code path={state().input["filePath"] as string} code={state().metadata["diff"] as string} />
|
||||
</CollapsiblePart>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={props.part.state.status === "error" && props.part.state}>
|
||||
{(state) => (
|
||||
<CollapsiblePart
|
||||
title={
|
||||
<>
|
||||
<span class="">Edit</span> {getFilename(state().input["filePath"] as string)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div class="text-icon-critical-active">{sync.sanitize(state().error)}</div>
|
||||
</CollapsiblePart>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
function WriteToolPart(props: { part: ToolPart }) {
|
||||
const sync = useSync()
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.part.state.status === "pending"}>
|
||||
<Part>Preparing write...</Part>
|
||||
</Match>
|
||||
<Match when={props.part.state.status === "completed" && props.part.state}>
|
||||
{(state) => (
|
||||
<CollapsiblePart
|
||||
title={
|
||||
<>
|
||||
<span class="">Write</span> {getFilename(state().input["filePath"] as string)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div class="p-2 bg-background-panel rounded-md border border-border-subtle"></div>
|
||||
</CollapsiblePart>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={props.part.state.status === "error" && props.part.state}>
|
||||
{(state) => (
|
||||
<div>
|
||||
<Part>
|
||||
<span class="">Write</span> {getFilename(state().input["filePath"] as string)}
|
||||
</Part>
|
||||
<div class="text-icon-critical-active">{sync.sanitize(state().error)}</div>
|
||||
</div>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
function BashToolPart(props: { part: ToolPart }) {
|
||||
const sync = useSync()
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.part.state.status === "pending"}>
|
||||
<Part>Writing shell command...</Part>
|
||||
</Match>
|
||||
<Match when={props.part.state.status === "completed" && props.part.state}>
|
||||
{(state) => (
|
||||
<CollapsiblePart
|
||||
defaultOpen
|
||||
title={
|
||||
<>
|
||||
<span class="">Run command:</span> {state().input["command"]}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Markdown text={`\`\`\`command\n${state().input["command"]}\n${state().output}\`\`\``} />
|
||||
</CollapsiblePart>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={props.part.state.status === "error" && props.part.state}>
|
||||
{(state) => (
|
||||
<CollapsiblePart
|
||||
title={
|
||||
<>
|
||||
<span class="">Shell</span> {state().input["command"]}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div class="text-icon-critical-active">{sync.sanitize(state().error)}</div>
|
||||
</CollapsiblePart>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolPart(props: { part: ToolPart }) {
|
||||
// read
|
||||
// edit
|
||||
// write
|
||||
// bash
|
||||
// ls
|
||||
// glob
|
||||
// grep
|
||||
// todowrite
|
||||
// todoread
|
||||
// webfetch
|
||||
// websearch
|
||||
// patch
|
||||
// task
|
||||
return (
|
||||
<div class="min-w-0 flex-auto text-12-medium">
|
||||
<Switch
|
||||
fallback={
|
||||
<span>
|
||||
{props.part.type}:{props.part.tool}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Match when={props.part.tool === "read"}>
|
||||
<ReadToolPart part={props.part} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "edit"}>
|
||||
<EditToolPart part={props.part} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "write"}>
|
||||
<WriteToolPart part={props.part} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "bash"}>
|
||||
<BashToolPart part={props.part} />
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SessionTimeline(props: { session: string; class?: string }) {
|
||||
const sync = useSync()
|
||||
const [scrollElement, setScrollElement] = createSignal<HTMLElement | undefined>(undefined)
|
||||
const [root, setRoot] = createSignal<HTMLDivElement | undefined>(undefined)
|
||||
const [tail, setTail] = createSignal(true)
|
||||
const size = createElementSize(root)
|
||||
const scroll = createScrollPosition(scrollElement)
|
||||
|
||||
const valid = (part: Part) => {
|
||||
if (!part) return false
|
||||
switch (part.type) {
|
||||
case "step-start":
|
||||
case "step-finish":
|
||||
case "file":
|
||||
case "patch":
|
||||
return false
|
||||
case "text":
|
||||
return !part.synthetic && part.text.trim()
|
||||
case "reasoning":
|
||||
return part.text.trim()
|
||||
case "tool":
|
||||
switch (part.tool) {
|
||||
case "todoread":
|
||||
case "todowrite":
|
||||
case "list":
|
||||
case "grep":
|
||||
return false
|
||||
}
|
||||
return true
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const hasValidParts = (message: Message) => {
|
||||
return sync.data.part[message.id]?.filter(valid).length > 0
|
||||
}
|
||||
|
||||
const hasTextPart = (message: Message) => {
|
||||
return !!sync.data.part[message.id]?.filter(valid).find((p) => p.type === "text")
|
||||
}
|
||||
|
||||
const session = createMemo(() => sync.session.get(props.session))
|
||||
const messages = createMemo(() => sync.data.message[props.session] ?? [])
|
||||
const messagesWithValidParts = createMemo(() => sync.data.message[props.session]?.filter(hasValidParts) ?? [])
|
||||
const working = createMemo(() => {
|
||||
const last = messages()[messages().length - 1]
|
||||
if (!last) return false
|
||||
if (last.role === "user") return true
|
||||
return !last.time.completed
|
||||
})
|
||||
|
||||
const cost = createMemo(() => {
|
||||
const total = pipe(
|
||||
messages(),
|
||||
sumBy((x) => (x.role === "assistant" ? x.cost : 0)),
|
||||
)
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(total)
|
||||
})
|
||||
|
||||
const last = createMemo(() => {
|
||||
return messages().findLast((x) => x.role === "assistant") as AssistantMessage
|
||||
})
|
||||
|
||||
const model = createMemo(() => {
|
||||
if (!last()) return
|
||||
const model = sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID]
|
||||
return model
|
||||
})
|
||||
|
||||
const tokens = createMemo(() => {
|
||||
if (!last()) return
|
||||
const tokens = last().tokens
|
||||
const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
compactDisplay: "short",
|
||||
}).format(total)
|
||||
})
|
||||
|
||||
const context = createMemo(() => {
|
||||
if (!last()) return
|
||||
if (!model()?.limit.context) return 0
|
||||
const tokens = last().tokens
|
||||
const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
|
||||
return Math.round((total / model()!.limit.context) * 100)
|
||||
})
|
||||
|
||||
const getScrollParent = (el: HTMLElement | null): HTMLElement | undefined => {
|
||||
let p = el?.parentElement
|
||||
while (p && p !== document.body) {
|
||||
const s = getComputedStyle(p)
|
||||
if (s.overflowY === "auto" || s.overflowY === "scroll") return p
|
||||
p = p.parentElement
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!root()) return
|
||||
setScrollElement(getScrollParent(root()!))
|
||||
})
|
||||
|
||||
const scrollToBottom = () => {
|
||||
const element = scrollElement()
|
||||
if (!element) return
|
||||
element.scrollTop = element.scrollHeight
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
size.height
|
||||
if (tail()) scrollToBottom()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (working()) {
|
||||
setTail(true)
|
||||
scrollToBottom()
|
||||
}
|
||||
})
|
||||
|
||||
let lastScrollY = 0
|
||||
createEffect(() => {
|
||||
if (scroll.y < lastScrollY) {
|
||||
setTail(false)
|
||||
}
|
||||
lastScrollY = scroll.y
|
||||
})
|
||||
|
||||
const duration = (part: Part) => {
|
||||
switch (part.type) {
|
||||
default:
|
||||
if (
|
||||
"time" in part &&
|
||||
part.time &&
|
||||
"start" in part.time &&
|
||||
part.time.start &&
|
||||
"end" in part.time &&
|
||||
part.time.end
|
||||
) {
|
||||
const start = DateTime.fromMillis(part.time.start)
|
||||
const end = DateTime.fromMillis(part.time.end)
|
||||
return end.diff(start).toFormat("s")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
console.log("WHAT")
|
||||
console.log(JSON.stringify(messagesWithValidParts()))
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setRoot}
|
||||
classList={{
|
||||
"select-text flex flex-col text-text-weak": true,
|
||||
[props.class ?? ""]: !!props.class,
|
||||
}}
|
||||
>
|
||||
<div class="flex justify-end items-center self-stretch">
|
||||
<div class="flex items-center gap-6">
|
||||
<Tooltip value={`${tokens()} Tokens`} class="flex items-center gap-1.5">
|
||||
<Show when={context()}>
|
||||
<ProgressCircle percentage={context()!} />
|
||||
</Show>
|
||||
<div class="text-14-regular text-text-weak text-right">{context()}%</div>
|
||||
</Tooltip>
|
||||
<div class="text-14-regular text-text-strong text-right">{cost()}</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul role="list" class="flex flex-col items-start self-stretch">
|
||||
<For each={messagesWithValidParts()}>
|
||||
{(message) => (
|
||||
<div
|
||||
classList={{
|
||||
"flex flex-col gap-1 justify-center items-start self-stretch": true,
|
||||
"mt-6": hasTextPart(message),
|
||||
}}
|
||||
>
|
||||
<For each={sync.data.part[message.id]?.filter(valid) ?? []}>
|
||||
{(part) => (
|
||||
<li class="group/li">
|
||||
<Switch fallback={<div class="">{part.type}</div>}>
|
||||
<Match when={part.type === "text" && part}>
|
||||
{(part) => (
|
||||
<Switch>
|
||||
<Match when={message.role === "user"}>
|
||||
<div class="w-fit flex items-center px-3 py-1 rounded-md bg-surface-weak">
|
||||
<span class="text-14-regular text-text-strong whitespace-pre-wrap break-words">
|
||||
{part().text}
|
||||
</span>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={message.role === "assistant"}>
|
||||
<Markdown text={sync.sanitize(part().text)} />
|
||||
</Match>
|
||||
</Switch>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={part.type === "reasoning" && part}>
|
||||
{(part) => (
|
||||
<CollapsiblePart
|
||||
title={
|
||||
<Switch fallback={<span class="text-text-weak">Thinking</span>}>
|
||||
<Match when={part().time.end}>
|
||||
<span class="text-12-medium text-text-weak">Thought</span> for {duration(part())}s
|
||||
</Match>
|
||||
</Switch>
|
||||
}
|
||||
>
|
||||
<Markdown text={part().text} />
|
||||
</CollapsiblePart>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={part.type === "tool" && part}>{(part) => <ToolPart part={part()} />}</Match>
|
||||
</Switch>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
<Show when={false}>
|
||||
<Collapsible defaultOpen={false}>
|
||||
<Collapsible.Trigger>
|
||||
<div class="mt-12 ml-1 flex items-center gap-x-2 text-xs text-text-muted">
|
||||
<Icon name="file-code" />
|
||||
<span>Raw Session Data</span>
|
||||
<Collapsible.Arrow class="text-text-muted" />
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content class="mt-5">
|
||||
<ul role="list" class="space-y-2">
|
||||
<li>
|
||||
<Collapsible>
|
||||
<Collapsible.Trigger>
|
||||
<div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
|
||||
<Icon name="file-code" />
|
||||
<span>session</span>
|
||||
<Collapsible.Arrow class="text-text-muted" />
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<Code path="session.json" code={JSON.stringify(session(), null, 2)} />
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</li>
|
||||
<For each={messages()}>
|
||||
{(message) => (
|
||||
<>
|
||||
<li>
|
||||
<Collapsible>
|
||||
<Collapsible.Trigger>
|
||||
<div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
|
||||
<Icon name="file-code" />
|
||||
<span>{message.role === "user" ? "user" : "assistant"}</span>
|
||||
<Collapsible.Arrow class="text-text-muted" />
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<Code path={message.id + ".json"} code={JSON.stringify(message, null, 2)} />
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</li>
|
||||
<For each={sync.data.part[message.id]}>
|
||||
{(part) => (
|
||||
<li>
|
||||
<Collapsible>
|
||||
<Collapsible.Trigger>
|
||||
<div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
|
||||
<Icon name="file-code" />
|
||||
<span>{part.type}</span>
|
||||
<Collapsible.Arrow class="text-text-muted" />
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<Code path={message.id + "." + part.id + ".json"} code={JSON.stringify(part, null, 2)} />
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -45,6 +45,37 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
|
||||
function isModelValid(model: ModelKey) {
|
||||
const provider = sync.data.provider.find((x) => x.id === model.providerID)
|
||||
return !!provider?.models[model.modelID]
|
||||
}
|
||||
|
||||
function getFirstValidModel(...modelFns: (() => ModelKey | undefined)[]) {
|
||||
for (const modelFn of modelFns) {
|
||||
const model = modelFn()
|
||||
if (!model) continue
|
||||
if (isModelValid(model)) return model
|
||||
}
|
||||
}
|
||||
|
||||
// Automatically update model when agent changes
|
||||
createEffect(() => {
|
||||
const value = agent.current()
|
||||
if (value.model) {
|
||||
if (isModelValid(value.model))
|
||||
model.set({
|
||||
providerID: value.model.providerID,
|
||||
modelID: value.model.modelID,
|
||||
})
|
||||
// else
|
||||
// toast.show({
|
||||
// type: "warning",
|
||||
// message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`,
|
||||
// duration: 3000,
|
||||
// })
|
||||
}
|
||||
})
|
||||
|
||||
const agent = (() => {
|
||||
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent"))
|
||||
const [store, setStore] = createStore<{
|
||||
@@ -76,11 +107,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
})()
|
||||
|
||||
const model = (() => {
|
||||
const list = createMemo(() =>
|
||||
sync.data.provider.flatMap((p) => Object.values(p.models).map((m) => ({ ...m, provider: p }) as LocalModel)),
|
||||
)
|
||||
const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID)
|
||||
|
||||
const [store, setStore] = createStore<{
|
||||
model: Record<string, ModelKey>
|
||||
recent: ModelKey[]
|
||||
@@ -95,27 +121,54 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
localStorage.setItem("model", JSON.stringify(store.recent))
|
||||
})
|
||||
|
||||
const fallback = createMemo(() => {
|
||||
if (store.recent.length) return store.recent[0]
|
||||
const list = createMemo(() =>
|
||||
sync.data.provider.flatMap((p) => Object.values(p.models).map((m) => ({ ...m, provider: p }) as LocalModel)),
|
||||
)
|
||||
const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID)
|
||||
|
||||
const fallbackModel = createMemo(() => {
|
||||
if (sync.data.config.model) {
|
||||
const [providerID, modelID] = sync.data.config.model.split("/")
|
||||
if (isModelValid({ providerID, modelID })) {
|
||||
return {
|
||||
providerID,
|
||||
modelID,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of store.recent) {
|
||||
if (isModelValid(item)) {
|
||||
return item
|
||||
}
|
||||
}
|
||||
const provider = sync.data.provider[0]
|
||||
const model = Object.values(provider.models)[0]
|
||||
return { modelID: model.id, providerID: provider.id }
|
||||
return {
|
||||
providerID: provider.id,
|
||||
modelID: model.id,
|
||||
}
|
||||
})
|
||||
|
||||
const current = createMemo(() => {
|
||||
const currentModel = createMemo(() => {
|
||||
const a = agent.current()
|
||||
return find(store.model[agent.current().name]) ?? find(a.model ?? fallback())
|
||||
const key = getFirstValidModel(
|
||||
() => store.model[a.name],
|
||||
() => a.model,
|
||||
fallbackModel,
|
||||
)!
|
||||
return find(key)
|
||||
})
|
||||
|
||||
const recent = createMemo(() => store.recent.map(find).filter(Boolean))
|
||||
|
||||
return {
|
||||
list,
|
||||
current,
|
||||
current: currentModel,
|
||||
recent,
|
||||
list,
|
||||
set(model: ModelKey | undefined, options?: { recent?: boolean }) {
|
||||
batch(() => {
|
||||
setStore("model", agent.current().name, model ?? fallback())
|
||||
setStore("model", agent.current().name, model ?? fallbackModel())
|
||||
if (options?.recent && model) {
|
||||
const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
|
||||
if (uniq.length > 5) uniq.pop()
|
||||
@@ -279,10 +332,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
}
|
||||
break
|
||||
case "file.watcher.updated":
|
||||
setTimeout(sync.load.changes, 1000)
|
||||
const relativePath = relative(event.properties.file)
|
||||
if (relativePath.startsWith(".git/")) return
|
||||
load(relativePath)
|
||||
// setTimeout(sync.load.changes, 1000)
|
||||
// const relativePath = relative(event.properties.file)
|
||||
// if (relativePath.startsWith(".git/")) return
|
||||
// load(relativePath)
|
||||
break
|
||||
}
|
||||
})
|
||||
@@ -480,8 +533,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const getMessageText = (message: Message | Message[] | undefined): string => {
|
||||
if (!message) return ""
|
||||
if (Array.isArray(message)) return message.map((m) => getMessageText(m)).join(" ")
|
||||
const fileParts = sync.data.part[message.id]?.filter((p) => p.type === "file")
|
||||
|
||||
return sync.data.part[message.id]
|
||||
?.filter((p) => p.type === "text")
|
||||
?.filter((p) => !p.synthetic)
|
||||
|
||||
@@ -77,21 +77,22 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
break
|
||||
}
|
||||
case "message.part.updated": {
|
||||
const parts = store.part[event.properties.part.messageID]
|
||||
const part = sanitizePart(event.properties.part)
|
||||
const parts = store.part[part.messageID]
|
||||
if (!parts) {
|
||||
setStore("part", event.properties.part.messageID, [event.properties.part])
|
||||
setStore("part", part.messageID, [part])
|
||||
break
|
||||
}
|
||||
const result = Binary.search(parts, event.properties.part.id, (p) => p.id)
|
||||
const result = Binary.search(parts, part.id, (p) => p.id)
|
||||
if (result.found) {
|
||||
setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part))
|
||||
setStore("part", part.messageID, result.index, reconcile(part))
|
||||
break
|
||||
}
|
||||
setStore(
|
||||
"part",
|
||||
event.properties.part.messageID,
|
||||
part.messageID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, event.properties.part)
|
||||
draft.splice(result.index, 0, part)
|
||||
}),
|
||||
)
|
||||
break
|
||||
@@ -121,6 +122,23 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const sanitizer = createMemo(() => new RegExp(`${store.path.directory}/`, "g"))
|
||||
const sanitize = (text: string) => text.replace(sanitizer(), "")
|
||||
const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/")
|
||||
const sanitizePart = (part: Part) => {
|
||||
if (part.type === "tool") {
|
||||
if (part.state.status === "completed") {
|
||||
for (const key in part.state.metadata) {
|
||||
if (typeof part.state.metadata[key] === "string") {
|
||||
part.state.metadata[key] = sanitize(part.state.metadata[key] as string)
|
||||
}
|
||||
}
|
||||
for (const key in part.state.input) {
|
||||
if (typeof part.state.input[key] === "string") {
|
||||
part.state.input[key] = sanitize(part.state.input[key] as string)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return part
|
||||
}
|
||||
|
||||
return {
|
||||
data: store,
|
||||
@@ -155,7 +173,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
for (const message of messages.data!) {
|
||||
draft.part[message.info.id] = message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
|
||||
draft.part[message.info.id] = message.parts
|
||||
.slice()
|
||||
.map(sanitizePart)
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -3,9 +3,7 @@ import "@/index.css"
|
||||
import { render } from "solid-js/web"
|
||||
import { Router, Route } from "@solidjs/router"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { Fonts } from "@opencode-ai/ui"
|
||||
import { ShikiProvider } from "./context/shiki"
|
||||
import { MarkedProvider } from "./context/marked"
|
||||
import { Fonts, ShikiProvider, MarkedProvider } from "@opencode-ai/ui"
|
||||
import { SDKProvider } from "./context/sdk"
|
||||
import { SyncProvider } from "./context/sync"
|
||||
import { LocalProvider } from "./context/local"
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
Accordion,
|
||||
Diff,
|
||||
Collapsible,
|
||||
Part,
|
||||
DiffChanges,
|
||||
ProgressCircle,
|
||||
Message,
|
||||
} from "@opencode-ai/ui"
|
||||
import { FileIcon } from "@/ui"
|
||||
import FileTree from "@/components/file-tree"
|
||||
@@ -32,11 +36,8 @@ import type { JSX } from "solid-js"
|
||||
import { Code } from "@/components/code"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { ProgressCircle } from "@/components/progress-circle"
|
||||
import { Message, Part } from "@/components/message"
|
||||
import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk"
|
||||
import { DiffChanges } from "@/components/diff-changes"
|
||||
import { Markdown } from "@/components/markdown"
|
||||
import { Markdown } from "@opencode-ai/ui"
|
||||
|
||||
export default function Page() {
|
||||
const local = useLocal()
|
||||
@@ -271,7 +272,7 @@ export default function Page() {
|
||||
const TabVisual = (props: { file: LocalFile }): JSX.Element => {
|
||||
return (
|
||||
<div class="flex items-center gap-x-1.5">
|
||||
<FileIcon node={props.file} class="_grayscale-100" />
|
||||
<FileIcon node={props.file} class="grayscale-100 group-data-[selected]/tab:grayscale-0" />
|
||||
<span
|
||||
classList={{
|
||||
"text-14-medium": true,
|
||||
@@ -310,15 +311,21 @@ export default function Page() {
|
||||
<div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
|
||||
<Tooltip value={props.file.path} placement="bottom" class="h-full">
|
||||
<div class="relative h-full">
|
||||
<Tabs.Trigger value={props.file.path} class="peer/tab pr-7" onClick={() => props.onTabClick(props.file)}>
|
||||
<Tabs.Trigger
|
||||
value={props.file.path}
|
||||
class="group/tab pl-3 pr-1"
|
||||
onClick={() => props.onTabClick(props.file)}
|
||||
>
|
||||
<TabVisual file={props.file} />
|
||||
<IconButton
|
||||
icon="close"
|
||||
class="mt-0.5 opacity-0 text-text-muted/60 group-data-[selected]/tab:opacity-100
|
||||
group-data-[selected]/tab:text-text group-data-[selected]/tab:hover:bg-border-subtle
|
||||
hover:opacity-100 group-hover/tab:opacity-100"
|
||||
variant="ghost"
|
||||
onClick={() => props.onTabClose(props.file)}
|
||||
/>
|
||||
</Tabs.Trigger>
|
||||
<IconButton
|
||||
icon="close"
|
||||
class="absolute right-1 top-1.5 opacity-0 text-text-muted/60 peer-data-[selected]/tab:opacity-100 peer-data-[selected]/tab:text-text peer-data-[selected]/tab:hover:bg-border-subtle hover:opacity-100 peer-hover/tab:opacity-100"
|
||||
variant="ghost"
|
||||
onClick={() => props.onTabClose(props.file)}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -377,6 +384,7 @@ export default function Page() {
|
||||
{(session) => {
|
||||
const diffs = createMemo(() => session.summary?.diffs ?? [])
|
||||
const filesChanged = createMemo(() => diffs().length)
|
||||
const updated = DateTime.fromMillis(session.time.updated)
|
||||
return (
|
||||
<Tooltip placement="right" value={session.title}>
|
||||
<div>
|
||||
@@ -385,7 +393,14 @@ export default function Page() {
|
||||
{session.title}
|
||||
</span>
|
||||
<span class="text-12-regular text-text-weak text-right whitespace-nowrap">
|
||||
{DateTime.fromMillis(session.time.updated).toRelative()}
|
||||
{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">
|
||||
@@ -497,7 +512,7 @@ export default function Page() {
|
||||
<Show
|
||||
when={local.session.active()}
|
||||
fallback={
|
||||
<div class="flex flex-col pb-36 justify-end items-start gap-4 flex-[1_0_0] self-stretch">
|
||||
<div class="flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch">
|
||||
<div class="text-20-medium text-text-weaker">New session</div>
|
||||
<div class="flex justify-center items-center gap-3">
|
||||
<Icon name="folder" size="small" />
|
||||
@@ -528,101 +543,14 @@ export default function Page() {
|
||||
>
|
||||
<For each={local.session.userMessages()}>
|
||||
{(message) => {
|
||||
const countLines = (text: string) => {
|
||||
if (!text) return 0
|
||||
return text.split("\n").length
|
||||
}
|
||||
|
||||
const additions = createMemo(
|
||||
() =>
|
||||
message.summary?.diffs.reduce((acc, diff) => acc + (diff.additions ?? 0), 0) ?? 0,
|
||||
)
|
||||
|
||||
const deletions = createMemo(
|
||||
() =>
|
||||
message.summary?.diffs.reduce((acc, diff) => acc + (diff.deletions ?? 0), 0) ?? 0,
|
||||
)
|
||||
|
||||
const totalBeforeLines = createMemo(
|
||||
() =>
|
||||
message.summary?.diffs.reduce((acc, diff) => acc + countLines(diff.before), 0) ??
|
||||
0,
|
||||
)
|
||||
|
||||
const blockCounts = createMemo(() => {
|
||||
const TOTAL_BLOCKS = 5
|
||||
|
||||
const adds = additions()
|
||||
const dels = deletions()
|
||||
const unchanged = Math.max(0, totalBeforeLines() - dels)
|
||||
|
||||
const totalActivity = unchanged + adds + dels
|
||||
|
||||
if (totalActivity === 0) {
|
||||
return { added: 0, deleted: 0, neutral: TOTAL_BLOCKS }
|
||||
}
|
||||
|
||||
const percentAdded = adds / totalActivity
|
||||
const percentDeleted = dels / totalActivity
|
||||
const added_raw = percentAdded * TOTAL_BLOCKS
|
||||
const deleted_raw = percentDeleted * TOTAL_BLOCKS
|
||||
|
||||
let added = adds > 0 ? Math.ceil(added_raw) : 0
|
||||
let deleted = dels > 0 ? Math.ceil(deleted_raw) : 0
|
||||
|
||||
let total_allocated = added + deleted
|
||||
if (total_allocated > TOTAL_BLOCKS) {
|
||||
if (added_raw < deleted_raw) {
|
||||
added = Math.floor(added_raw)
|
||||
} else {
|
||||
deleted = Math.floor(deleted_raw)
|
||||
}
|
||||
|
||||
total_allocated = added + deleted
|
||||
if (total_allocated > TOTAL_BLOCKS) {
|
||||
if (added_raw < deleted_raw) {
|
||||
deleted = Math.floor(deleted_raw)
|
||||
} else {
|
||||
added = Math.floor(added_raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const neutral = Math.max(0, TOTAL_BLOCKS - added - deleted)
|
||||
|
||||
return { added, deleted, neutral }
|
||||
})
|
||||
|
||||
const ADD_COLOR = "var(--icon-diff-add-base)"
|
||||
const DELETE_COLOR = "var(--icon-diff-delete-base)"
|
||||
const NEUTRAL_COLOR = "var(--icon-weak-base)"
|
||||
|
||||
const visibleBlocks = createMemo(() => {
|
||||
const counts = blockCounts()
|
||||
const blocks = [
|
||||
...Array(counts.added).fill(ADD_COLOR),
|
||||
...Array(counts.deleted).fill(DELETE_COLOR),
|
||||
...Array(counts.neutral).fill(NEUTRAL_COLOR),
|
||||
]
|
||||
return blocks.slice(0, 5)
|
||||
})
|
||||
const diffs = createMemo(() => message.summary?.diffs ?? [])
|
||||
|
||||
return (
|
||||
<li
|
||||
class="group/li flex items-center gap-x-2 py-1 self-stretch cursor-default"
|
||||
onClick={() => local.session.setActiveMessage(message.id)}
|
||||
>
|
||||
<div class="w-[18px] shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 12" fill="none">
|
||||
<g>
|
||||
<For each={visibleBlocks()}>
|
||||
{(color, i) => (
|
||||
<rect x={i() * 4} width="2" height="12" rx="1" fill={color} />
|
||||
)}
|
||||
</For>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<DiffChanges diff={diffs()} variant="bars" />
|
||||
<div
|
||||
data-active={local.session.activeMessage()?.id === message.id}
|
||||
classList={{
|
||||
@@ -660,7 +588,7 @@ export default function Page() {
|
||||
class="flex flex-col items-start self-stretch gap-8 min-h-screen"
|
||||
>
|
||||
{/* Title */}
|
||||
<div class="py-2 flex flex-col items-start gap-2 self-stretch sticky top-0 bg-background-stronger">
|
||||
<div class="py-2 flex flex-col items-start gap-2 self-stretch sticky top-0 bg-background-stronger z-10">
|
||||
<h1 class="text-14-medium text-text-strong overflow-hidden text-ellipsis min-w-0">
|
||||
{title() ?? prompt()}
|
||||
</h1>
|
||||
@@ -825,7 +753,7 @@ export default function Page() {
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<div class="w-full flex flex-col items-start self-stretch gap-8">
|
||||
<div class="w-full flex flex-col items-start self-stretch gap-3">
|
||||
<For each={assistantMessages()}>
|
||||
{(assistantMessage) => {
|
||||
const parts = createMemo(
|
||||
@@ -873,7 +801,7 @@ export default function Page() {
|
||||
const draggedFile = local.file.node(id)
|
||||
if (!draggedFile) return null
|
||||
return (
|
||||
<div class="relative px-3 h-8 flex items-center text-sm font-medium text-text whitespace-nowrap shrink-0 bg-background-panel border-x border-border-subtle/40 border-b border-b-transparent">
|
||||
<div class="relative px-3 h-10 flex items-center bg-background-base border-x border-border-weak-base border-b border-b-transparent">
|
||||
<TabVisual file={draggedFile} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -14,6 +14,8 @@ import { Agent } from "../../agent/agent"
|
||||
import { Command } from "../../command"
|
||||
import { SessionPrompt } from "../../session/prompt"
|
||||
import { EOL } from "os"
|
||||
import { Permission } from "@/permission"
|
||||
import { select } from "@clack/prompts"
|
||||
|
||||
const TOOL: Record<string, [string, string]> = {
|
||||
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
|
||||
@@ -229,7 +231,9 @@ export const RunCommand = cmd({
|
||||
const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD]
|
||||
const title =
|
||||
part.state.title ||
|
||||
(Object.keys(part.state.input).length > 0 ? JSON.stringify(part.state.input) : "Unknown")
|
||||
(Object.keys(part.state.input).length > 0
|
||||
? JSON.stringify(part.state.input)
|
||||
: "Unknown")
|
||||
|
||||
printEvent(color, tool, title)
|
||||
|
||||
@@ -275,6 +279,31 @@ export const RunCommand = cmd({
|
||||
UI.error(err)
|
||||
})
|
||||
|
||||
Bus.subscribe(Permission.Event.Updated, async (evt) => {
|
||||
const permission = evt.properties
|
||||
const message = `Permission required to run: ${permission.title}`
|
||||
|
||||
const result = await select({
|
||||
message,
|
||||
options: [
|
||||
{ value: "once", label: "Allow once" },
|
||||
{ value: "always", label: "Always allow" },
|
||||
{ value: "reject", label: "Reject" },
|
||||
],
|
||||
initialValue: "once",
|
||||
}).catch(() => "reject")
|
||||
const response = (result.toString().includes("cancel") ? "reject" : result) as
|
||||
| "once"
|
||||
| "always"
|
||||
| "reject"
|
||||
|
||||
Permission.respond({
|
||||
sessionID: session.id,
|
||||
permissionID: permission.id,
|
||||
response,
|
||||
})
|
||||
})
|
||||
|
||||
await (async () => {
|
||||
if (args.command) {
|
||||
return await SessionPrompt.command({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ConfigMarkdown } from "@/config/markdown"
|
||||
import { Config } from "../config/config"
|
||||
import { MCP } from "../mcp"
|
||||
import { UI } from "./ui"
|
||||
@@ -7,16 +8,22 @@ export function FormatError(input: unknown) {
|
||||
return `MCP server "${input.data.name}" failed. Note, opencode does not support MCP authentication yet.`
|
||||
if (Config.JsonError.isInstance(input)) {
|
||||
return (
|
||||
`Config file at ${input.data.path} is not valid JSON(C)` + (input.data.message ? `: ${input.data.message}` : "")
|
||||
`Config file at ${input.data.path} is not valid JSON(C)` +
|
||||
(input.data.message ? `: ${input.data.message}` : "")
|
||||
)
|
||||
}
|
||||
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.`
|
||||
}
|
||||
if (ConfigMarkdown.FrontmatterError.isInstance(input)) {
|
||||
return `Failed to parse frontmatter in ${input.data.path}:\n${input.data.message}`
|
||||
}
|
||||
if (Config.InvalidError.isInstance(input))
|
||||
return [
|
||||
`Config file at ${input.data.path} is invalid` + (input.data.message ? `: ${input.data.message}` : ""),
|
||||
...(input.data.issues?.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ?? []),
|
||||
`Config file at ${input.data.path} is invalid` +
|
||||
(input.data.message ? `: ${input.data.message}` : ""),
|
||||
...(input.data.issues?.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ??
|
||||
[]),
|
||||
].join("\n")
|
||||
|
||||
if (UI.CancelledError.isInstance(input)) return ""
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Global } from "../global"
|
||||
import fs from "fs/promises"
|
||||
import { lazy } from "../util/lazy"
|
||||
import { NamedError } from "../util/error"
|
||||
import matter from "gray-matter"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { Auth } from "../auth"
|
||||
import {
|
||||
@@ -21,6 +20,7 @@ import { Instance } from "../project/instance"
|
||||
import { LSPServer } from "../lsp/server"
|
||||
import { BunProc } from "@/bun"
|
||||
import { Installation } from "@/installation"
|
||||
import { ConfigMarkdown } from "./markdown"
|
||||
|
||||
export namespace Config {
|
||||
const log = Log.create({ service: "config" })
|
||||
@@ -191,8 +191,7 @@ export namespace Config {
|
||||
dot: true,
|
||||
cwd: dir,
|
||||
})) {
|
||||
const content = await Bun.file(item).text()
|
||||
const md = matter(content)
|
||||
const md = await ConfigMarkdown.parse(item)
|
||||
if (!md.data) continue
|
||||
|
||||
const name = (() => {
|
||||
@@ -231,8 +230,7 @@ export namespace Config {
|
||||
dot: true,
|
||||
cwd: dir,
|
||||
})) {
|
||||
const content = await Bun.file(item).text()
|
||||
const md = matter(content)
|
||||
const md = await ConfigMarkdown.parse(item)
|
||||
if (!md.data) continue
|
||||
|
||||
// Extract relative path from agent folder for nested agents
|
||||
@@ -274,8 +272,7 @@ export namespace Config {
|
||||
dot: true,
|
||||
cwd: dir,
|
||||
})) {
|
||||
const content = await Bun.file(item).text()
|
||||
const md = matter(content)
|
||||
const md = await ConfigMarkdown.parse(item)
|
||||
if (!md.data) continue
|
||||
|
||||
const config = {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { NamedError } from "@/util/error"
|
||||
import matter from "gray-matter"
|
||||
import { z } from "zod"
|
||||
|
||||
export namespace ConfigMarkdown {
|
||||
export const FILE_REGEX = /(?<![\w`])@(\.?[^\s`,.]*(?:\.[^\s`,.]+)*)/g
|
||||
export const SHELL_REGEX = /!`([^`]+)`/g
|
||||
@@ -9,4 +13,29 @@ export namespace ConfigMarkdown {
|
||||
export function shell(template: string) {
|
||||
return Array.from(template.matchAll(SHELL_REGEX))
|
||||
}
|
||||
|
||||
export async function parse(filePath: string) {
|
||||
const template = await Bun.file(filePath).text()
|
||||
|
||||
try {
|
||||
const md = matter(template)
|
||||
return md
|
||||
} catch (err) {
|
||||
throw new FrontmatterError(
|
||||
{
|
||||
path: filePath,
|
||||
message: `Failed to parse YAML frontmatter: ${err instanceof Error ? err.message : String(err)}`,
|
||||
},
|
||||
{ cause: err },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const FrontmatterError = NamedError.create(
|
||||
"ConfigFrontmatterError",
|
||||
z.object({
|
||||
path: z.string(),
|
||||
message: z.string(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -163,7 +163,10 @@ export namespace LSP {
|
||||
const clients = await getClients(input)
|
||||
await run(async (client) => {
|
||||
if (!clients.includes(client)) return
|
||||
const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve()
|
||||
|
||||
const wait = waitForDiagnostics
|
||||
? client.waitForDiagnostics({ path: input })
|
||||
: Promise.resolve()
|
||||
await client.notify.open({ path: input })
|
||||
return wait
|
||||
}).catch((err) => {
|
||||
|
||||
@@ -54,7 +54,17 @@ export namespace LSPServer {
|
||||
|
||||
export const Deno: Info = {
|
||||
id: "deno",
|
||||
root: NearestRoot(["deno.json", "deno.jsonc"]),
|
||||
root: async (file) => {
|
||||
const files = Filesystem.up({
|
||||
targets: ["deno.json", "deno.jsonc"],
|
||||
start: path.dirname(file),
|
||||
stop: Instance.directory,
|
||||
})
|
||||
const first = await files.next()
|
||||
await files.return()
|
||||
if (!first.value) return undefined
|
||||
return path.dirname(first.value)
|
||||
},
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"],
|
||||
async spawn(root) {
|
||||
const deno = Bun.which("deno")
|
||||
@@ -78,7 +88,9 @@ export namespace LSPServer {
|
||||
),
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
|
||||
async spawn(root) {
|
||||
const tsserver = await Bun.resolve("typescript/lib/tsserver.js", Instance.directory).catch(() => {})
|
||||
const tsserver = await Bun.resolve("typescript/lib/tsserver.js", Instance.directory).catch(
|
||||
() => {},
|
||||
)
|
||||
if (!tsserver) return
|
||||
const proc = spawn(BunProc.which(), ["x", "typescript-language-server", "--stdio"], {
|
||||
cwd: root,
|
||||
@@ -101,7 +113,13 @@ export namespace LSPServer {
|
||||
export const Vue: Info = {
|
||||
id: "vue",
|
||||
extensions: [".vue"],
|
||||
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
|
||||
root: NearestRoot([
|
||||
"package-lock.json",
|
||||
"bun.lockb",
|
||||
"bun.lock",
|
||||
"pnpm-lock.yaml",
|
||||
"yarn.lock",
|
||||
]),
|
||||
async spawn(root) {
|
||||
let binary = Bun.which("vue-language-server")
|
||||
const args: string[] = []
|
||||
@@ -149,17 +167,31 @@ export namespace LSPServer {
|
||||
|
||||
export const ESLint: Info = {
|
||||
id: "eslint",
|
||||
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
|
||||
root: NearestRoot([
|
||||
"package-lock.json",
|
||||
"bun.lockb",
|
||||
"bun.lock",
|
||||
"pnpm-lock.yaml",
|
||||
"yarn.lock",
|
||||
]),
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"],
|
||||
async spawn(root) {
|
||||
const eslint = await Bun.resolve("eslint", Instance.directory).catch(() => {})
|
||||
if (!eslint) return
|
||||
log.info("spawning eslint server")
|
||||
const serverPath = path.join(Global.Path.bin, "vscode-eslint", "server", "out", "eslintServer.js")
|
||||
const serverPath = path.join(
|
||||
Global.Path.bin,
|
||||
"vscode-eslint",
|
||||
"server",
|
||||
"out",
|
||||
"eslintServer.js",
|
||||
)
|
||||
if (!(await Bun.file(serverPath).exists())) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
log.info("downloading and building VS Code ESLint server")
|
||||
const response = await fetch("https://github.com/microsoft/vscode-eslint/archive/refs/heads/main.zip")
|
||||
const response = await fetch(
|
||||
"https://github.com/microsoft/vscode-eslint/archive/refs/heads/main.zip",
|
||||
)
|
||||
if (!response.ok) return
|
||||
|
||||
const zipPath = path.join(Global.Path.bin, "vscode-eslint.zip")
|
||||
@@ -284,12 +316,25 @@ export namespace LSPServer {
|
||||
export const Pyright: Info = {
|
||||
id: "pyright",
|
||||
extensions: [".py", ".pyi"],
|
||||
root: NearestRoot(["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "pyrightconfig.json"]),
|
||||
root: NearestRoot([
|
||||
"pyproject.toml",
|
||||
"setup.py",
|
||||
"setup.cfg",
|
||||
"requirements.txt",
|
||||
"Pipfile",
|
||||
"pyrightconfig.json",
|
||||
]),
|
||||
async spawn(root) {
|
||||
let binary = Bun.which("pyright-langserver")
|
||||
const args = []
|
||||
if (!binary) {
|
||||
const js = path.join(Global.Path.bin, "node_modules", "pyright", "dist", "pyright-langserver.js")
|
||||
const js = path.join(
|
||||
Global.Path.bin,
|
||||
"node_modules",
|
||||
"pyright",
|
||||
"dist",
|
||||
"pyright-langserver.js",
|
||||
)
|
||||
if (!(await Bun.file(js).exists())) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
await Bun.spawn([BunProc.which(), "install", "pyright"], {
|
||||
@@ -307,9 +352,11 @@ export namespace LSPServer {
|
||||
|
||||
const initialization: Record<string, string> = {}
|
||||
|
||||
const potentialVenvPaths = [process.env["VIRTUAL_ENV"], path.join(root, ".venv"), path.join(root, "venv")].filter(
|
||||
(p): p is string => p !== undefined,
|
||||
)
|
||||
const potentialVenvPaths = [
|
||||
process.env["VIRTUAL_ENV"],
|
||||
path.join(root, ".venv"),
|
||||
path.join(root, "venv"),
|
||||
].filter((p): p is string => p !== undefined)
|
||||
for (const venvPath of potentialVenvPaths) {
|
||||
const isWindows = process.platform === "win32"
|
||||
const potentialPythonPath = isWindows
|
||||
@@ -360,7 +407,9 @@ export namespace LSPServer {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
log.info("downloading elixir-ls from GitHub releases")
|
||||
|
||||
const response = await fetch("https://github.com/elixir-lsp/elixir-ls/archive/refs/heads/master.zip")
|
||||
const response = await fetch(
|
||||
"https://github.com/elixir-lsp/elixir-ls/archive/refs/heads/master.zip",
|
||||
)
|
||||
if (!response.ok) return
|
||||
const zipPath = path.join(Global.Path.bin, "elixir-ls.zip")
|
||||
await Bun.file(zipPath).write(response)
|
||||
@@ -410,7 +459,9 @@ export namespace LSPServer {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
log.info("downloading zls from GitHub releases")
|
||||
|
||||
const releaseResponse = await fetch("https://api.github.com/repos/zigtools/zls/releases/latest")
|
||||
const releaseResponse = await fetch(
|
||||
"https://api.github.com/repos/zigtools/zls/releases/latest",
|
||||
)
|
||||
if (!releaseResponse.ok) {
|
||||
log.error("Failed to fetch zls release info")
|
||||
return
|
||||
@@ -585,7 +636,13 @@ export namespace LSPServer {
|
||||
|
||||
export const Clangd: Info = {
|
||||
id: "clangd",
|
||||
root: NearestRoot(["compile_commands.json", "compile_flags.txt", ".clangd", "CMakeLists.txt", "Makefile"]),
|
||||
root: NearestRoot([
|
||||
"compile_commands.json",
|
||||
"compile_flags.txt",
|
||||
".clangd",
|
||||
"CMakeLists.txt",
|
||||
"Makefile",
|
||||
]),
|
||||
extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"],
|
||||
async spawn(root) {
|
||||
let bin = Bun.which("clangd", {
|
||||
@@ -595,7 +652,9 @@ export namespace LSPServer {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
log.info("downloading clangd from GitHub releases")
|
||||
|
||||
const releaseResponse = await fetch("https://api.github.com/repos/clangd/clangd/releases/latest")
|
||||
const releaseResponse = await fetch(
|
||||
"https://api.github.com/repos/clangd/clangd/releases/latest",
|
||||
)
|
||||
if (!releaseResponse.ok) {
|
||||
log.error("Failed to fetch clangd release info")
|
||||
return
|
||||
@@ -664,12 +723,24 @@ export namespace LSPServer {
|
||||
export const Svelte: Info = {
|
||||
id: "svelte",
|
||||
extensions: [".svelte"],
|
||||
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
|
||||
root: NearestRoot([
|
||||
"package-lock.json",
|
||||
"bun.lockb",
|
||||
"bun.lock",
|
||||
"pnpm-lock.yaml",
|
||||
"yarn.lock",
|
||||
]),
|
||||
async spawn(root) {
|
||||
let binary = Bun.which("svelteserver")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(Global.Path.bin, "node_modules", "svelte-language-server", "bin", "server.js")
|
||||
const js = path.join(
|
||||
Global.Path.bin,
|
||||
"node_modules",
|
||||
"svelte-language-server",
|
||||
"bin",
|
||||
"server.js",
|
||||
)
|
||||
if (!(await Bun.file(js).exists())) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
await Bun.spawn([BunProc.which(), "install", "svelte-language-server"], {
|
||||
@@ -704,9 +775,17 @@ export namespace LSPServer {
|
||||
export const Astro: Info = {
|
||||
id: "astro",
|
||||
extensions: [".astro"],
|
||||
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
|
||||
root: NearestRoot([
|
||||
"package-lock.json",
|
||||
"bun.lockb",
|
||||
"bun.lock",
|
||||
"pnpm-lock.yaml",
|
||||
"yarn.lock",
|
||||
]),
|
||||
async spawn(root) {
|
||||
const tsserver = await Bun.resolve("typescript/lib/tsserver.js", Instance.directory).catch(() => {})
|
||||
const tsserver = await Bun.resolve("typescript/lib/tsserver.js", Instance.directory).catch(
|
||||
() => {},
|
||||
)
|
||||
if (!tsserver) {
|
||||
log.info("typescript not found, required for Astro language server")
|
||||
return
|
||||
@@ -716,7 +795,14 @@ export namespace LSPServer {
|
||||
let binary = Bun.which("astro-ls")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(Global.Path.bin, "node_modules", "@astrojs", "language-server", "bin", "nodeServer.js")
|
||||
const js = path.join(
|
||||
Global.Path.bin,
|
||||
"node_modules",
|
||||
"@astrojs",
|
||||
"language-server",
|
||||
"bin",
|
||||
"nodeServer.js",
|
||||
)
|
||||
if (!(await Bun.file(js).exists())) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
await Bun.spawn([BunProc.which(), "install", "@astrojs/language-server"], {
|
||||
@@ -794,7 +880,9 @@ export namespace LSPServer {
|
||||
.then(({ stdout }) => stdout.toString().trim())
|
||||
const launcherJar = path.join(launcherDir, jarFileName)
|
||||
if (!(await fs.exists(launcherJar))) {
|
||||
log.error(`Failed to locate the JDTLS launcher module in the installed directory: ${distPath}.`)
|
||||
log.error(
|
||||
`Failed to locate the JDTLS launcher module in the installed directory: ${distPath}.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
const configFile = path.join(
|
||||
@@ -860,7 +948,9 @@ export namespace LSPServer {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
log.info("downloading lua-language-server from GitHub releases")
|
||||
|
||||
const releaseResponse = await fetch("https://api.github.com/repos/LuaLS/lua-language-server/releases/latest")
|
||||
const releaseResponse = await fetch(
|
||||
"https://api.github.com/repos/LuaLS/lua-language-server/releases/latest",
|
||||
)
|
||||
if (!releaseResponse.ok) {
|
||||
log.error("Failed to fetch lua-language-server release info")
|
||||
return
|
||||
@@ -897,7 +987,9 @@ export namespace LSPServer {
|
||||
|
||||
const assetSuffix = `${lualsPlatform}-${lualsArch}.${ext}`
|
||||
if (!supportedCombos.includes(assetSuffix)) {
|
||||
log.error(`Platform ${platform} and architecture ${arch} is not supported by lua-language-server`)
|
||||
log.error(
|
||||
`Platform ${platform} and architecture ${arch} is not supported by lua-language-server`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -920,7 +1012,10 @@ export namespace LSPServer {
|
||||
// Unlike zls which is a single self-contained binary,
|
||||
// lua-language-server needs supporting files (meta/, locale/, etc.)
|
||||
// Extract entire archive to dedicated directory to preserve all files
|
||||
const installDir = path.join(Global.Path.bin, `lua-language-server-${lualsArch}-${lualsPlatform}`)
|
||||
const installDir = path.join(
|
||||
Global.Path.bin,
|
||||
`lua-language-server-${lualsArch}-${lualsPlatform}`,
|
||||
)
|
||||
|
||||
// Remove old installation if exists
|
||||
const stats = await fs.stat(installDir).catch(() => undefined)
|
||||
@@ -945,7 +1040,11 @@ export namespace LSPServer {
|
||||
await fs.rm(tempPath, { force: true })
|
||||
|
||||
// Binary is located in bin/ subdirectory within the extracted archive
|
||||
bin = path.join(installDir, "bin", "lua-language-server" + (platform === "win32" ? ".exe" : ""))
|
||||
bin = path.join(
|
||||
installDir,
|
||||
"bin",
|
||||
"lua-language-server" + (platform === "win32" ? ".exe" : ""),
|
||||
)
|
||||
|
||||
if (!(await Bun.file(bin).exists())) {
|
||||
log.error("Failed to extract lua-language-server binary")
|
||||
@@ -954,7 +1053,9 @@ export namespace LSPServer {
|
||||
|
||||
if (platform !== "win32") {
|
||||
const ok = await $`chmod +x ${bin}`.quiet().catch((error) => {
|
||||
log.error("Failed to set executable permission for lua-language-server binary", { error })
|
||||
log.error("Failed to set executable permission for lua-language-server binary", {
|
||||
error,
|
||||
})
|
||||
})
|
||||
if (!ok) return
|
||||
}
|
||||
|
||||
@@ -534,7 +534,6 @@ export namespace SessionPrompt {
|
||||
args,
|
||||
},
|
||||
)
|
||||
item.parameters.parse(args)
|
||||
const result = await item.execute(args, {
|
||||
sessionID: input.sessionID,
|
||||
abort: options.abortSignal!,
|
||||
@@ -618,7 +617,7 @@ export namespace SessionPrompt {
|
||||
|
||||
return {
|
||||
title: "",
|
||||
metadata: {},
|
||||
metadata: result.metadata ?? {},
|
||||
output,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,14 @@ export const TaskTool = Tool.define("task", async () => {
|
||||
})
|
||||
const msg = await Session.getMessage({ sessionID: ctx.sessionID, messageID: ctx.messageID })
|
||||
if (msg.info.role !== "assistant") throw new Error("Not an assistant message")
|
||||
|
||||
ctx.metadata({
|
||||
title: params.description,
|
||||
metadata: {
|
||||
sessionId: session.id,
|
||||
},
|
||||
})
|
||||
|
||||
const messageID = Identifier.ascending("message")
|
||||
const parts: Record<string, MessageV2.ToolPart> = {}
|
||||
const unsub = Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => {
|
||||
@@ -44,6 +52,7 @@ export const TaskTool = Tool.define("task", async () => {
|
||||
title: params.description,
|
||||
metadata: {
|
||||
summary: Object.values(parts).sort((a, b) => a.id?.localeCompare(b.id)),
|
||||
sessionId: session.id,
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -87,6 +96,7 @@ export const TaskTool = Tool.define("task", async () => {
|
||||
title: params.description,
|
||||
metadata: {
|
||||
summary: all,
|
||||
sessionId: session.id,
|
||||
},
|
||||
output: (result.parts.findLast((x: any) => x.type === "text") as any)?.text ?? "",
|
||||
}
|
||||
|
||||
@@ -42,8 +42,13 @@ export namespace Tool {
|
||||
return {
|
||||
id,
|
||||
init: async () => {
|
||||
if (init instanceof Function) return init()
|
||||
return init
|
||||
const toolInfo = init instanceof Function ? await init() : init
|
||||
const execute = toolInfo.execute
|
||||
toolInfo.execute = (args, ctx) => {
|
||||
toolInfo.parameters.parse(args)
|
||||
return execute(args, ctx)
|
||||
}
|
||||
return toolInfo
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -490,19 +490,7 @@ func (a *App) InitializeProvider() tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: Config file model setting
|
||||
if selectedProvider == nil && a.Config.Model != "" {
|
||||
if provider, model := findModelByFullID(providers, a.Config.Model); provider != nil &&
|
||||
model != nil {
|
||||
selectedProvider = provider
|
||||
selectedModel = model
|
||||
slog.Debug("Selected model from config", "provider", provider.ID, "model", model.ID)
|
||||
} else {
|
||||
slog.Debug("Config model not found", "model", a.Config.Model)
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: Current agent's preferred model
|
||||
// Priority 2: Current agent's preferred model
|
||||
if selectedProvider == nil && a.Agent().Model.ModelID != "" {
|
||||
if provider, model := findModelByProviderAndModelID(providers, a.Agent().Model.ProviderID, a.Agent().Model.ModelID); provider != nil &&
|
||||
model != nil {
|
||||
@@ -522,6 +510,18 @@ func (a *App) InitializeProvider() tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: Config file model setting
|
||||
if selectedProvider == nil && a.Config.Model != "" {
|
||||
if provider, model := findModelByFullID(providers, a.Config.Model); provider != nil &&
|
||||
model != nil {
|
||||
selectedProvider = provider
|
||||
selectedModel = model
|
||||
slog.Debug("Selected model from config", "provider", provider.ID, "model", model.ID)
|
||||
} else {
|
||||
slog.Debug("Config model not found", "model", a.Config.Model)
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 4: Recent model usage (most recently used model)
|
||||
if selectedProvider == nil && len(a.State.RecentlyUsedModels) > 0 {
|
||||
recentUsage := a.State.RecentlyUsedModels[0] // Most recent is first
|
||||
|
||||
@@ -226,3 +226,79 @@ func TestFindProviderByID(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelSelectionPriority tests the priority order for model selection
|
||||
func TestModelSelectionPriority(t *testing.T) {
|
||||
providers := []opencode.Provider{
|
||||
{
|
||||
ID: "anthropic",
|
||||
Models: map[string]opencode.Model{
|
||||
"claude-opus": {ID: "claude-opus"},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "openai",
|
||||
Models: map[string]opencode.Model{
|
||||
"gpt-4": {ID: "gpt-4"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
agentProviderID string
|
||||
agentModelID string
|
||||
configModel string
|
||||
expectedProviderID string
|
||||
expectedModelID string
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "agent model takes priority over config",
|
||||
agentProviderID: "openai",
|
||||
agentModelID: "gpt-4",
|
||||
configModel: "anthropic/claude-opus",
|
||||
expectedProviderID: "openai",
|
||||
expectedModelID: "gpt-4",
|
||||
description: "When agent specifies a model, it should be used even if config has a different model",
|
||||
},
|
||||
{
|
||||
name: "config model used when agent has no model",
|
||||
agentProviderID: "",
|
||||
agentModelID: "",
|
||||
configModel: "anthropic/claude-opus",
|
||||
expectedProviderID: "anthropic",
|
||||
expectedModelID: "claude-opus",
|
||||
description: "When agent has no model specified, config model should be used as fallback",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var selectedProvider *opencode.Provider
|
||||
var selectedModel *opencode.Model
|
||||
|
||||
// Simulate priority 2: Agent model check
|
||||
if tt.agentModelID != "" {
|
||||
selectedProvider, selectedModel = findModelByProviderAndModelID(providers, tt.agentProviderID, tt.agentModelID)
|
||||
}
|
||||
|
||||
// Simulate priority 3: Config model fallback
|
||||
if selectedProvider == nil && tt.configModel != "" {
|
||||
selectedProvider, selectedModel = findModelByFullID(providers, tt.configModel)
|
||||
}
|
||||
|
||||
if selectedProvider == nil || selectedModel == nil {
|
||||
t.Fatalf("Expected to find model, but got nil - %s", tt.description)
|
||||
}
|
||||
|
||||
if selectedProvider.ID != tt.expectedProviderID {
|
||||
t.Errorf("Expected provider %s, got %s - %s", tt.expectedProviderID, selectedProvider.ID, tt.description)
|
||||
}
|
||||
|
||||
if selectedModel.ID != tt.expectedModelID {
|
||||
t.Errorf("Expected model %s, got %s - %s", tt.expectedModelID, selectedModel.ID, tt.description)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,11 +11,13 @@
|
||||
"./fonts/*": "./src/assets/fonts/*"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"dev": "vite",
|
||||
"generate:tailwind": "bun run script/tailwind.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "catalog:",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-solid": "catalog:",
|
||||
@@ -24,11 +26,17 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@pierre/precision-diffs": "catalog:",
|
||||
"@shikijs/transformers": "3.9.2",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"luxon": "catalog:",
|
||||
"marked": "16.2.0",
|
||||
"marked-shiki": "1.2.1",
|
||||
"remeda": "catalog:",
|
||||
"shiki": "3.9.2",
|
||||
"solid-js": "catalog:",
|
||||
"solid-list": "catalog:",
|
||||
"virtua": "catalog:"
|
||||
|
||||
76
packages/ui/src/components/basic-tool.css
Normal file
76
packages/ui/src/components/basic-tool.css
Normal file
@@ -0,0 +1,76 @@
|
||||
[data-component="tool-trigger"] {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
gap: 20px;
|
||||
justify-content: space-between;
|
||||
|
||||
[data-slot="tool-trigger-content"] {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
[data-slot="tool-icon"] {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[data-slot="tool-info"] {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-slot="tool-info-structured"] {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
[data-slot="tool-info-main"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
[data-slot="tool-title"] {
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--text-base);
|
||||
|
||||
&.capitalize {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="tool-subtitle"] {
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
[data-slot="tool-arg"] {
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--text-weak);
|
||||
}
|
||||
}
|
||||
95
packages/ui/src/components/basic-tool.tsx
Normal file
95
packages/ui/src/components/basic-tool.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { children, For, Match, Show, Switch, type JSX } from "solid-js"
|
||||
import { Collapsible } from "./collapsible"
|
||||
import { Icon, IconProps } from "./icon"
|
||||
|
||||
export type TriggerTitle = {
|
||||
title: string
|
||||
titleClass?: string
|
||||
subtitle?: string
|
||||
subtitleClass?: string
|
||||
args?: string[]
|
||||
argsClass?: string
|
||||
action?: JSX.Element
|
||||
}
|
||||
|
||||
const isTriggerTitle = (val: any): val is TriggerTitle => {
|
||||
return typeof val === "object" && val !== null && "title" in val && !(val instanceof Node)
|
||||
}
|
||||
|
||||
export interface BasicToolProps {
|
||||
icon: IconProps["name"]
|
||||
trigger: TriggerTitle | JSX.Element
|
||||
children?: JSX.Element
|
||||
hideDetails?: boolean
|
||||
}
|
||||
|
||||
export function BasicTool(props: BasicToolProps) {
|
||||
const resolved = children(() => props.children)
|
||||
return (
|
||||
<Collapsible>
|
||||
<Collapsible.Trigger>
|
||||
<div data-component="tool-trigger">
|
||||
<div data-slot="tool-trigger-content">
|
||||
<Icon name={props.icon} size="small" data-slot="tool-icon" />
|
||||
<div data-slot="tool-info">
|
||||
<Switch>
|
||||
<Match when={isTriggerTitle(props.trigger) && props.trigger}>
|
||||
{(trigger) => (
|
||||
<div data-slot="tool-info-structured">
|
||||
<div data-slot="tool-info-main">
|
||||
<span
|
||||
data-slot="tool-title"
|
||||
classList={{
|
||||
[trigger().titleClass ?? ""]: !!trigger().titleClass,
|
||||
}}
|
||||
>
|
||||
{trigger().title}
|
||||
</span>
|
||||
<Show when={trigger().subtitle}>
|
||||
<span
|
||||
data-slot="tool-subtitle"
|
||||
classList={{
|
||||
[trigger().subtitleClass ?? ""]: !!trigger().subtitleClass,
|
||||
}}
|
||||
>
|
||||
{trigger().subtitle}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={trigger().args?.length}>
|
||||
<For each={trigger().args}>
|
||||
{(arg) => (
|
||||
<span
|
||||
data-slot="tool-arg"
|
||||
classList={{
|
||||
[trigger().argsClass ?? ""]: !!trigger().argsClass,
|
||||
}}
|
||||
>
|
||||
{arg}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={trigger().action}>{trigger().action}</Show>
|
||||
</div>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={true}>{props.trigger as JSX.Element}</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={resolved() && !props.hideDetails}>
|
||||
<Collapsible.Arrow />
|
||||
</Show>
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Show when={resolved() && !props.hideDetails}>
|
||||
<Collapsible.Content>{resolved()}</Collapsible.Content>
|
||||
</Show>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
export function GenericTool(props: { tool: string; hideDetails?: boolean }) {
|
||||
return <BasicTool icon="mcp" trigger={{ title: props.tool }} hideDetails={props.hideDetails} />
|
||||
}
|
||||
29
packages/ui/src/components/card.css
Normal file
29
packages/ui/src/components/card.css
Normal file
@@ -0,0 +1,29 @@
|
||||
[data-component="card"] {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--surface-inset-base);
|
||||
border: 1px solid var(--border-weaker-base);
|
||||
transition: background-color 0.15s ease;
|
||||
border-radius: 8px;
|
||||
padding: 6px 12px;
|
||||
overflow: clip;
|
||||
|
||||
&[data-variant="error"] {
|
||||
background-color: var(--surface-critical-weak);
|
||||
border: 1px solid var(--border-critical-base);
|
||||
color: rgba(218, 51, 25, 0.6);
|
||||
|
||||
/* text-12-regular */
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-large); /* 166.667% */
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
|
||||
&[data-component="icon"] {
|
||||
color: var(--icon-critical-active);
|
||||
}
|
||||
}
|
||||
}
|
||||
22
packages/ui/src/components/card.tsx
Normal file
22
packages/ui/src/components/card.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { type ComponentProps, splitProps } from "solid-js"
|
||||
|
||||
export interface CardProps extends ComponentProps<"div"> {
|
||||
variant?: "normal" | "error" | "warning" | "success" | "info"
|
||||
}
|
||||
|
||||
export function Card(props: CardProps) {
|
||||
const [split, rest] = splitProps(props, ["variant", "class", "classList"])
|
||||
return (
|
||||
<div
|
||||
{...rest}
|
||||
data-component="card"
|
||||
data-variant={split.variant || "normal"}
|
||||
classList={{
|
||||
...(split.classList ?? {}),
|
||||
[split.class ?? ""]: !!split.class,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
[data-slot="collapsible-trigger"] {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
height: 40px;
|
||||
height: 32px;
|
||||
padding: 6px 8px 6px 12px;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
|
||||
39
packages/ui/src/components/diff-changes.css
Normal file
39
packages/ui/src/components/diff-changes.css
Normal file
@@ -0,0 +1,39 @@
|
||||
[data-component="diff-changes"] {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
|
||||
[data-slot="additions"] {
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-small);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
text-align: right;
|
||||
color: var(--text-diff-add-base);
|
||||
}
|
||||
|
||||
[data-slot="deletions"] {
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-small);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
text-align: right;
|
||||
color: var(--text-diff-delete-base);
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="diff-changes"][data-variant="bars"] {
|
||||
width: 18px;
|
||||
flex-shrink: 0;
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
122
packages/ui/src/components/diff-changes.tsx
Normal file
122
packages/ui/src/components/diff-changes.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { FileDiff } from "@opencode-ai/sdk"
|
||||
import { createMemo, For, Match, Show, Switch } from "solid-js"
|
||||
|
||||
export function DiffChanges(props: { diff: FileDiff | FileDiff[]; variant?: "default" | "bars" }) {
|
||||
const variant = () => props.variant ?? "default"
|
||||
|
||||
const additions = createMemo(() =>
|
||||
Array.isArray(props.diff)
|
||||
? props.diff.reduce((acc, diff) => acc + (diff.additions ?? 0), 0)
|
||||
: props.diff.additions,
|
||||
)
|
||||
const deletions = createMemo(() =>
|
||||
Array.isArray(props.diff)
|
||||
? props.diff.reduce((acc, diff) => acc + (diff.deletions ?? 0), 0)
|
||||
: props.diff.deletions,
|
||||
)
|
||||
const total = createMemo(() => (additions() ?? 0) + (deletions() ?? 0))
|
||||
|
||||
const countLines = (text: string) => {
|
||||
if (!text) return 0
|
||||
return text.split("\n").length
|
||||
}
|
||||
|
||||
const totalBeforeLines = createMemo(() => {
|
||||
if (!Array.isArray(props.diff)) return countLines(props.diff.before || "")
|
||||
return props.diff.reduce((acc, diff) => acc + countLines(diff.before || ""), 0)
|
||||
})
|
||||
|
||||
const blockCounts = createMemo(() => {
|
||||
const TOTAL_BLOCKS = 5
|
||||
|
||||
const adds = additions() ?? 0
|
||||
const dels = deletions() ?? 0
|
||||
|
||||
if (adds === 0 && dels === 0) {
|
||||
return { added: 0, deleted: 0, neutral: TOTAL_BLOCKS }
|
||||
}
|
||||
|
||||
const total = adds + dels
|
||||
|
||||
if (total < 5) {
|
||||
const added = adds > 0 ? 1 : 0
|
||||
const deleted = dels > 0 ? 1 : 0
|
||||
const neutral = TOTAL_BLOCKS - added - deleted
|
||||
return { added, deleted, neutral }
|
||||
}
|
||||
|
||||
const ratio = adds > dels ? adds / dels : dels / adds
|
||||
let BLOCKS_FOR_COLORS = TOTAL_BLOCKS
|
||||
|
||||
if (total < 20) {
|
||||
BLOCKS_FOR_COLORS = TOTAL_BLOCKS - 1
|
||||
} else if (ratio < 4) {
|
||||
BLOCKS_FOR_COLORS = TOTAL_BLOCKS - 1
|
||||
}
|
||||
|
||||
const percentAdded = adds / total
|
||||
const percentDeleted = dels / total
|
||||
|
||||
const added_raw = percentAdded * BLOCKS_FOR_COLORS
|
||||
const deleted_raw = percentDeleted * BLOCKS_FOR_COLORS
|
||||
|
||||
let added = adds > 0 ? Math.max(1, Math.round(added_raw)) : 0
|
||||
let deleted = dels > 0 ? Math.max(1, Math.round(deleted_raw)) : 0
|
||||
|
||||
// Cap bars based on actual change magnitude
|
||||
if (adds > 0 && adds <= 5) added = Math.min(added, 1)
|
||||
if (adds > 5 && adds <= 10) added = Math.min(added, 2)
|
||||
if (dels > 0 && dels <= 5) deleted = Math.min(deleted, 1)
|
||||
if (dels > 5 && dels <= 10) deleted = Math.min(deleted, 2)
|
||||
|
||||
let total_allocated = added + deleted
|
||||
if (total_allocated > BLOCKS_FOR_COLORS) {
|
||||
if (added_raw > deleted_raw) {
|
||||
added = BLOCKS_FOR_COLORS - deleted
|
||||
} else {
|
||||
deleted = BLOCKS_FOR_COLORS - added
|
||||
}
|
||||
total_allocated = added + deleted
|
||||
}
|
||||
|
||||
const neutral = Math.max(0, TOTAL_BLOCKS - total_allocated)
|
||||
|
||||
return { added, deleted, neutral }
|
||||
})
|
||||
|
||||
const ADD_COLOR = "var(--icon-diff-add-base)"
|
||||
const DELETE_COLOR = "var(--icon-diff-delete-base)"
|
||||
const NEUTRAL_COLOR = "var(--icon-weak-base)"
|
||||
|
||||
const visibleBlocks = createMemo(() => {
|
||||
const counts = blockCounts()
|
||||
const blocks = [
|
||||
...Array(counts.added).fill(ADD_COLOR),
|
||||
...Array(counts.deleted).fill(DELETE_COLOR),
|
||||
...Array(counts.neutral).fill(NEUTRAL_COLOR),
|
||||
]
|
||||
return blocks.slice(0, 5)
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={variant() === "default" ? total() > 0 : true}>
|
||||
<div data-component="diff-changes" data-variant={variant()}>
|
||||
<Switch>
|
||||
<Match when={variant() === "bars"}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 12" fill="none">
|
||||
<g>
|
||||
<For each={visibleBlocks()}>
|
||||
{(color, i) => <rect x={i() * 4} width="2" height="12" rx="1" fill={color} />}
|
||||
</For>
|
||||
</g>
|
||||
</svg>
|
||||
</Match>
|
||||
<Match when={variant() === "default"}>
|
||||
<span data-slot="additions">{`+${additions()}`}</span>
|
||||
<span data-slot="deletions">{`-${deletions()}`}</span>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -22,5 +22,9 @@
|
||||
width: var(--pjs-column-content-width);
|
||||
left: var(--pjs-column-number-width);
|
||||
padding-left: 8px;
|
||||
|
||||
[data-slot="diff-hunk-separator-content-span"] {
|
||||
mix-blend-mode: var(--text-mix-blend-mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@ import {
|
||||
FileDiff,
|
||||
type DiffLineAnnotation,
|
||||
type HunkData,
|
||||
DiffFileRendererOptions,
|
||||
FileDiffOptions,
|
||||
// registerCustomTheme,
|
||||
} from "@pierre/precision-diffs"
|
||||
import { ComponentProps, createEffect, splitProps } from "solid-js"
|
||||
|
||||
export type DiffProps<T = {}> = Omit<DiffFileRendererOptions<T>, "themes"> & {
|
||||
export type DiffProps<T = {}> = FileDiffOptions<T> & {
|
||||
before: FileContents
|
||||
after: FileContents
|
||||
annotations?: DiffLineAnnotation<T>[]
|
||||
@@ -54,13 +54,9 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
// When ready to render, simply call .render with old/new file, optional
|
||||
// annotations and a container element to hold the diff
|
||||
createEffect(() => {
|
||||
// @ts-expect-error
|
||||
const instance = new FileDiff<T>({
|
||||
// theme: "pierre-light",
|
||||
// theme: "pierre-light",
|
||||
// Or can also provide a 'themes' prop, which allows the code to adapt
|
||||
// to your OS light or dark theme
|
||||
themes: { dark: "pierre-dark", light: "pierre-light" },
|
||||
theme: { dark: "pierre-dark", light: "pierre-light" },
|
||||
// When using the 'themes' prop, 'themeType' allows you to force 'dark'
|
||||
// or 'light' theme, or inherit from the OS ('system') theme.
|
||||
themeType: "system",
|
||||
@@ -113,8 +109,11 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
numCol.dataset["slot"] = "diff-hunk-separator-line-number"
|
||||
fragment.appendChild(numCol)
|
||||
const contentCol = document.createElement("div")
|
||||
contentCol.textContent = `${hunkData.lines} unmodified lines`
|
||||
contentCol.dataset["slot"] = "diff-hunk-separator-content"
|
||||
const span = document.createElement("span")
|
||||
span.dataset["slot"] = "diff-hunk-separator-content-span"
|
||||
span.textContent = `${hunkData.lines} unmodified lines`
|
||||
contentCol.appendChild(span)
|
||||
fragment.appendChild(contentCol)
|
||||
return fragment
|
||||
},
|
||||
@@ -170,7 +169,7 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
"--pjs-font-family": "var(--font-family-mono)",
|
||||
"--pjs-font-size": "var(--font-size-small)",
|
||||
"--pjs-line-height": "24px",
|
||||
"--pjs-tab-size": 4,
|
||||
"--pjs-tab-size": 2,
|
||||
"--pjs-font-features": "var(--font-family-mono--font-feature-settings)",
|
||||
"--pjs-header-font-family": "var(--font-family-sans)",
|
||||
"--pjs-gap-block": 0,
|
||||
|
||||
@@ -149,6 +149,7 @@ const newIcons = {
|
||||
console: `<path d="M3.75 5.4165L8.33333 9.99984L3.75 14.5832M10.4167 14.5832H16.25" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"code-lines": `<path d="M2.08325 3.75H11.2499M14.5833 3.75H17.9166M2.08325 10L7.08325 10M10.4166 10L17.9166 10M2.08325 16.25L8.74992 16.25M12.0833 16.25L17.9166 16.25" stroke="currentColor" stroke-linecap="square" stroke-linejoin="round"/>`,
|
||||
"square-arrow-top-right": `<path d="M7.91675 2.9165H2.91675V17.0832H17.0834V12.0832M12.0834 2.9165H17.0834V7.9165M9.58342 10.4165L16.6667 3.33317" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"circle-ban-sign": `<path d="M15.3675 4.63087L4.55742 15.441M17.9163 9.9987C17.9163 14.371 14.3719 17.9154 9.99967 17.9154C7.81355 17.9154 5.83438 17.0293 4.40175 15.5966C2.96911 14.164 2.08301 12.1848 2.08301 9.9987C2.08301 5.62644 5.62742 2.08203 9.99967 2.08203C12.1858 2.08203 14.165 2.96813 15.5976 4.40077C17.0302 5.8334 17.9163 7.81257 17.9163 9.9987Z" stroke="currentColor" stroke-linecap="round"/>`,
|
||||
}
|
||||
|
||||
export interface IconProps extends ComponentProps<"svg"> {
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
export * from "./accordion"
|
||||
export * from "./button"
|
||||
export * from "./card"
|
||||
export * from "./checkbox"
|
||||
export * from "./collapsible"
|
||||
export * from "./dialog"
|
||||
export * from "./diff"
|
||||
export * from "./diff-changes"
|
||||
export * from "./icon"
|
||||
export * from "./icon-button"
|
||||
export * from "./input"
|
||||
export * from "./fonts"
|
||||
export * from "./list"
|
||||
export * from "./markdown"
|
||||
export * from "./message-part"
|
||||
export * from "./progress-circle"
|
||||
export * from "./select"
|
||||
export * from "./select-dialog"
|
||||
export * from "./tabs"
|
||||
export * from "./basic-tool"
|
||||
export * from "./tooltip"
|
||||
|
||||
export * from "../context/helper"
|
||||
export * from "../context/shiki"
|
||||
export * from "../context/marked"
|
||||
|
||||
37
packages/ui/src/components/markdown.css
Normal file
37
packages/ui/src/components/markdown.css
Normal file
@@ -0,0 +1,37 @@
|
||||
[data-component="markdown"] {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
scrollbar-width: none;
|
||||
color: var(--text-base);
|
||||
|
||||
/* text-14-regular */
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-base);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-large); /* 142.857% */
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 8px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
36
packages/ui/src/components/markdown.tsx
Normal file
36
packages/ui/src/components/markdown.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useMarked } from "../context/marked"
|
||||
import { ComponentProps, createResource, splitProps } from "solid-js"
|
||||
|
||||
function strip(text: string): string {
|
||||
const wrappedRe = /^\s*<([A-Za-z]\w*)>\s*([\s\S]*?)\s*<\/\1>\s*$/
|
||||
const match = text.match(wrappedRe)
|
||||
return match ? match[2] : text
|
||||
}
|
||||
|
||||
export function Markdown(
|
||||
props: ComponentProps<"div"> & {
|
||||
text: string
|
||||
class?: string
|
||||
classList?: Record<string, boolean>
|
||||
},
|
||||
) {
|
||||
const [local, others] = splitProps(props, ["text", "class", "classList"])
|
||||
const marked = useMarked()
|
||||
const [html] = createResource(
|
||||
() => strip(local.text),
|
||||
async (markdown) => {
|
||||
return marked.parse(markdown)
|
||||
},
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-component="markdown"
|
||||
classList={{
|
||||
...(local.classList ?? {}),
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
innerHTML={html()}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
129
packages/ui/src/components/message-part.css
Normal file
129
packages/ui/src/components/message-part.css
Normal file
@@ -0,0 +1,129 @@
|
||||
[data-component="assistant-message"] {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
[data-component="user-message"] {
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--text-base);
|
||||
display: -webkit-box;
|
||||
line-clamp: 3;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-component="text-part"] {
|
||||
[data-component="markdown"] {
|
||||
margin-top: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="tool-error"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
[data-slot="icon"] {
|
||||
color: var(--icon-critical-active);
|
||||
}
|
||||
|
||||
[data-slot="content"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
[data-slot="title"] {
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--ember-light-11);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="tool-output"] {
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
[data-component="edit-trigger"],
|
||||
[data-component="write-trigger"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
|
||||
[data-slot="title-area"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
[data-slot="title"] {
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--text-base);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
[data-slot="path"] {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
[data-slot="directory"] {
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
[data-slot="filename"] {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
[data-slot="actions"] {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="edit-content"] {
|
||||
border-top: 1px solid var(--border-weaker-base);
|
||||
}
|
||||
|
||||
[data-component="tool-action"] {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
[data-component="todos"] {
|
||||
padding: 10px 12px 24px 48px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
[data-slot="todo-content"] {
|
||||
&[data-completed="completed"] {
|
||||
text-decoration: line-through;
|
||||
color: var(--text-weaker);
|
||||
}
|
||||
}
|
||||
}
|
||||
446
packages/ui/src/components/message-part.tsx
Normal file
446
packages/ui/src/components/message-part.tsx
Normal file
@@ -0,0 +1,446 @@
|
||||
import { Component, createMemo, For, Match, Show, Switch } from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import {
|
||||
AssistantMessage,
|
||||
Message as MessageType,
|
||||
Part as PartType,
|
||||
TextPart,
|
||||
ToolPart,
|
||||
UserMessage,
|
||||
} from "@opencode-ai/sdk"
|
||||
import { BasicTool } from "./basic-tool"
|
||||
import { GenericTool } from "./basic-tool"
|
||||
import { Card } from "./card"
|
||||
import { Icon } from "./icon"
|
||||
import { Checkbox } from "./checkbox"
|
||||
import { Diff } from "./diff"
|
||||
import { DiffChanges } from "./diff-changes"
|
||||
import { Markdown } from "./markdown"
|
||||
|
||||
export interface MessageProps {
|
||||
message: MessageType
|
||||
parts: PartType[]
|
||||
}
|
||||
|
||||
export interface MessagePartProps {
|
||||
part: PartType
|
||||
message: MessageType
|
||||
hideDetails?: boolean
|
||||
}
|
||||
|
||||
export type PartComponent = Component<MessagePartProps>
|
||||
|
||||
export const PART_MAPPING: Record<string, PartComponent | undefined> = {}
|
||||
|
||||
function getFilename(path: string) {
|
||||
if (!path) return ""
|
||||
const trimmed = path.replace(/[\/]+$/, "")
|
||||
const parts = trimmed.split("/")
|
||||
return parts[parts.length - 1] ?? ""
|
||||
}
|
||||
|
||||
function getDirectory(path: string) {
|
||||
const parts = path.split("/")
|
||||
const dir = parts.slice(0, parts.length - 1).join("/")
|
||||
return dir ? dir + "/" : ""
|
||||
}
|
||||
|
||||
export function registerPartComponent(type: string, component: PartComponent) {
|
||||
PART_MAPPING[type] = component
|
||||
}
|
||||
|
||||
export function Message(props: MessageProps) {
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.message.role === "user" && props.message}>
|
||||
{(userMessage) => (
|
||||
<UserMessageDisplay message={userMessage() as UserMessage} parts={props.parts} />
|
||||
)}
|
||||
</Match>
|
||||
<Match when={props.message.role === "assistant" && props.message}>
|
||||
{(assistantMessage) => (
|
||||
<AssistantMessageDisplay
|
||||
message={assistantMessage() as AssistantMessage}
|
||||
parts={props.parts}
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
export function AssistantMessageDisplay(props: { message: AssistantMessage; parts: PartType[] }) {
|
||||
const filteredParts = createMemo(() => {
|
||||
return props.parts?.filter((x) => {
|
||||
if (x.type === "reasoning") return false
|
||||
return x.type !== "tool" || (x as ToolPart).tool !== "todoread"
|
||||
})
|
||||
})
|
||||
return <For each={filteredParts()}>{(part) => <Part part={part} message={props.message} />}</For>
|
||||
}
|
||||
|
||||
export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) {
|
||||
const text = createMemo(() =>
|
||||
props.parts
|
||||
?.filter((p) => p.type === "text" && !(p as TextPart).synthetic)
|
||||
?.map((p) => (p as TextPart).text)
|
||||
?.join(""),
|
||||
)
|
||||
return <div data-component="user-message">{text()}</div>
|
||||
}
|
||||
|
||||
export function Part(props: MessagePartProps) {
|
||||
const component = createMemo(() => PART_MAPPING[props.part.type])
|
||||
return (
|
||||
<Show when={component()}>
|
||||
<Dynamic
|
||||
component={component()}
|
||||
part={props.part}
|
||||
message={props.message}
|
||||
hideDetails={props.hideDetails}
|
||||
/>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export interface ToolProps {
|
||||
input: Record<string, any>
|
||||
metadata: Record<string, any>
|
||||
tool: string
|
||||
output?: string
|
||||
hideDetails?: boolean
|
||||
}
|
||||
|
||||
export type ToolComponent = Component<ToolProps>
|
||||
|
||||
const state: Record<
|
||||
string,
|
||||
{
|
||||
name: string
|
||||
render?: ToolComponent
|
||||
}
|
||||
> = {}
|
||||
|
||||
export function registerTool(input: { name: string; render?: ToolComponent }) {
|
||||
state[input.name] = input
|
||||
return input
|
||||
}
|
||||
|
||||
export function getTool(name: string) {
|
||||
return state[name]?.render
|
||||
}
|
||||
|
||||
export const ToolRegistry = {
|
||||
register: registerTool,
|
||||
render: getTool,
|
||||
}
|
||||
|
||||
PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
||||
const part = props.part as ToolPart
|
||||
const component = createMemo(() => {
|
||||
const render = ToolRegistry.render(part.tool) ?? GenericTool
|
||||
const metadata = part.state.status === "pending" ? {} : (part.state.metadata ?? {})
|
||||
const input = part.state.status === "completed" ? part.state.input : {}
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={part.state.status === "error" && part.state.error}>
|
||||
{(error) => {
|
||||
const cleaned = error().replace("Error: ", "")
|
||||
const [title, ...rest] = cleaned.split(": ")
|
||||
return (
|
||||
<Card variant="error">
|
||||
<div data-component="tool-error">
|
||||
<Icon name="circle-ban-sign" size="small" data-slot="icon" />
|
||||
<Switch>
|
||||
<Match when={title}>
|
||||
<div data-slot="content">
|
||||
<div data-slot="title">{title}</div>
|
||||
<span>{rest.join(": ")}</span>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={true}>{cleaned}</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}}
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<Dynamic
|
||||
component={render}
|
||||
input={input}
|
||||
tool={part.tool}
|
||||
metadata={metadata}
|
||||
output={part.state.status === "completed" ? part.state.output : undefined}
|
||||
hideDetails={props.hideDetails}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
})
|
||||
|
||||
return <Show when={component()}>{component()}</Show>
|
||||
}
|
||||
|
||||
PART_MAPPING["text"] = function TextPartDisplay(props) {
|
||||
const part = props.part as TextPart
|
||||
return (
|
||||
<Show when={part.text.trim()}>
|
||||
<div data-component="text-part">
|
||||
<Markdown text={part.text.trim()} />
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) {
|
||||
const part = props.part as any
|
||||
return (
|
||||
<Show when={part.text.trim()}>
|
||||
<div data-component="reasoning-part">
|
||||
<Markdown text={part.text.trim()} />
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
ToolRegistry.register({
|
||||
name: "read",
|
||||
render(props) {
|
||||
return (
|
||||
<BasicTool
|
||||
icon="glasses"
|
||||
trigger={{
|
||||
title: "Read",
|
||||
subtitle: props.input.filePath ? getFilename(props.input.filePath) : "",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register({
|
||||
name: "list",
|
||||
render(props) {
|
||||
return (
|
||||
<BasicTool
|
||||
icon="bullet-list"
|
||||
trigger={{ title: "List", subtitle: getDirectory(props.input.path || "/") }}
|
||||
>
|
||||
<Show when={false && props.output}>
|
||||
<div data-component="tool-output">{props.output}</div>
|
||||
</Show>
|
||||
</BasicTool>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register({
|
||||
name: "glob",
|
||||
render(props) {
|
||||
return (
|
||||
<BasicTool
|
||||
icon="magnifying-glass-menu"
|
||||
trigger={{
|
||||
title: "Glob",
|
||||
subtitle: getDirectory(props.input.path || "/"),
|
||||
args: props.input.pattern ? ["pattern=" + props.input.pattern] : [],
|
||||
}}
|
||||
>
|
||||
<Show when={false && props.output}>
|
||||
<div data-component="tool-output">{props.output}</div>
|
||||
</Show>
|
||||
</BasicTool>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register({
|
||||
name: "grep",
|
||||
render(props) {
|
||||
const args = []
|
||||
if (props.input.pattern) args.push("pattern=" + props.input.pattern)
|
||||
if (props.input.include) args.push("include=" + props.input.include)
|
||||
return (
|
||||
<BasicTool
|
||||
icon="magnifying-glass-menu"
|
||||
trigger={{
|
||||
title: "Grep",
|
||||
subtitle: getDirectory(props.input.path || "/"),
|
||||
args,
|
||||
}}
|
||||
>
|
||||
<Show when={false && props.output}>
|
||||
<div data-component="tool-output">{props.output}</div>
|
||||
</Show>
|
||||
</BasicTool>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register({
|
||||
name: "webfetch",
|
||||
render(props) {
|
||||
return (
|
||||
<BasicTool
|
||||
icon="window-cursor"
|
||||
trigger={{
|
||||
title: "Webfetch",
|
||||
subtitle: props.input.url || "",
|
||||
args: props.input.format ? ["format=" + props.input.format] : [],
|
||||
action: (
|
||||
<div data-component="tool-action">
|
||||
<Icon name="square-arrow-top-right" size="small" />
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
>
|
||||
<Show when={false && props.output}>
|
||||
<div data-component="tool-output">{props.output}</div>
|
||||
</Show>
|
||||
</BasicTool>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register({
|
||||
name: "task",
|
||||
render(props) {
|
||||
return (
|
||||
<BasicTool
|
||||
icon="task"
|
||||
trigger={{
|
||||
title: `${props.input.subagent_type || props.tool} Agent`,
|
||||
titleClass: "capitalize",
|
||||
subtitle: props.input.description,
|
||||
}}
|
||||
>
|
||||
<Show when={false && props.output}>
|
||||
<div data-component="tool-output">{props.output}</div>
|
||||
</Show>
|
||||
</BasicTool>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register({
|
||||
name: "bash",
|
||||
render(props) {
|
||||
return (
|
||||
<BasicTool
|
||||
icon="console"
|
||||
trigger={{
|
||||
title: "Shell",
|
||||
subtitle: "Ran " + props.input.command,
|
||||
}}
|
||||
>
|
||||
<Show when={false && props.output}>
|
||||
<div data-component="tool-output">{props.output}</div>
|
||||
</Show>
|
||||
</BasicTool>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register({
|
||||
name: "edit",
|
||||
render(props) {
|
||||
return (
|
||||
<BasicTool
|
||||
icon="code-lines"
|
||||
trigger={
|
||||
<div data-component="edit-trigger">
|
||||
<div data-slot="title-area">
|
||||
<div data-slot="title">Edit</div>
|
||||
<div data-slot="path">
|
||||
<Show when={props.input.filePath?.includes("/")}>
|
||||
<span data-slot="directory">{getDirectory(props.input.filePath!)}</span>
|
||||
</Show>
|
||||
<span data-slot="filename">{getFilename(props.input.filePath ?? "")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="actions">
|
||||
<Show when={props.metadata.filediff}>
|
||||
<DiffChanges diff={props.metadata.filediff} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show when={props.metadata.filediff}>
|
||||
<div data-component="edit-content">
|
||||
<Diff
|
||||
before={{
|
||||
name: getFilename(props.metadata.filediff.path),
|
||||
contents: props.metadata.filediff.before,
|
||||
}}
|
||||
after={{
|
||||
name: getFilename(props.metadata.filediff.path),
|
||||
contents: props.metadata.filediff.after,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</BasicTool>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register({
|
||||
name: "write",
|
||||
render(props) {
|
||||
return (
|
||||
<BasicTool
|
||||
icon="code-lines"
|
||||
trigger={
|
||||
<div data-component="write-trigger">
|
||||
<div data-slot="title-area">
|
||||
<div data-slot="title">Write</div>
|
||||
<div data-slot="path">
|
||||
<Show when={props.input.filePath?.includes("/")}>
|
||||
<span data-slot="directory">{getDirectory(props.input.filePath!)}</span>
|
||||
</Show>
|
||||
<span data-slot="filename">{getFilename(props.input.filePath ?? "")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="actions">{/* <DiffChanges diff={diff} /> */}</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show when={false && props.output}>
|
||||
<div data-component="tool-output">{props.output}</div>
|
||||
</Show>
|
||||
</BasicTool>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register({
|
||||
name: "todowrite",
|
||||
render(props) {
|
||||
return (
|
||||
<BasicTool
|
||||
icon="checklist"
|
||||
trigger={{
|
||||
title: "To-dos",
|
||||
subtitle: `${props.input.todos?.filter((t: any) => t.status === "completed").length}/${props.input.todos?.length}`,
|
||||
}}
|
||||
>
|
||||
<Show when={props.input.todos?.length}>
|
||||
<div data-component="todos">
|
||||
<For each={props.input.todos}>
|
||||
{(todo: any) => (
|
||||
<Checkbox readOnly checked={todo.status === "completed"}>
|
||||
<div data-slot="todo-content" data-completed={todo.status === "completed"}>
|
||||
{todo.content}
|
||||
</div>
|
||||
</Checkbox>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</BasicTool>
|
||||
)
|
||||
},
|
||||
})
|
||||
12
packages/ui/src/components/progress-circle.css
Normal file
12
packages/ui/src/components/progress-circle.css
Normal file
@@ -0,0 +1,12 @@
|
||||
[data-component="progress-circle"] {
|
||||
transform: rotate(-90deg);
|
||||
|
||||
[data-slot="background"] {
|
||||
stroke: var(--border-weak-base);
|
||||
}
|
||||
|
||||
[data-slot="progress"] {
|
||||
stroke: var(--border-active);
|
||||
transition: stroke-dashoffset 0.35s cubic-bezier(0.65, 0, 0.35, 1);
|
||||
}
|
||||
}
|
||||
63
packages/ui/src/components/progress-circle.tsx
Normal file
63
packages/ui/src/components/progress-circle.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { type ComponentProps, createMemo, splitProps } from "solid-js"
|
||||
|
||||
export interface ProgressCircleProps extends Pick<ComponentProps<"svg">, "class" | "classList"> {
|
||||
percentage: number
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
}
|
||||
|
||||
export function ProgressCircle(props: ProgressCircleProps) {
|
||||
const [split, rest] = splitProps(props, [
|
||||
"percentage",
|
||||
"size",
|
||||
"strokeWidth",
|
||||
"class",
|
||||
"classList",
|
||||
])
|
||||
|
||||
const size = () => split.size || 16
|
||||
const strokeWidth = () => split.strokeWidth || 3
|
||||
|
||||
const viewBoxSize = 16
|
||||
const center = viewBoxSize / 2
|
||||
const radius = () => center - strokeWidth() / 2
|
||||
const circumference = createMemo(() => 2 * Math.PI * radius())
|
||||
|
||||
const offset = createMemo(() => {
|
||||
const clampedPercentage = Math.max(0, Math.min(100, split.percentage || 0))
|
||||
const progress = clampedPercentage / 100
|
||||
return circumference() * (1 - progress)
|
||||
})
|
||||
|
||||
return (
|
||||
<svg
|
||||
{...rest}
|
||||
width={size()}
|
||||
height={size()}
|
||||
viewBox={`0 0 ${viewBoxSize} ${viewBoxSize}`}
|
||||
fill="none"
|
||||
data-component="progress-circle"
|
||||
classList={{
|
||||
...(split.classList ?? {}),
|
||||
[split.class ?? ""]: !!split.class,
|
||||
}}
|
||||
>
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius()}
|
||||
data-slot="background"
|
||||
stroke-width={strokeWidth()}
|
||||
/>
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius()}
|
||||
data-slot="progress"
|
||||
stroke-width={strokeWidth()}
|
||||
stroke-dasharray={circumference().toString()}
|
||||
stroke-dashoffset={offset()}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -57,9 +57,6 @@
|
||||
border-bottom: 1px solid var(--border-weak-base);
|
||||
border-right: 1px solid var(--border-weak-base);
|
||||
background-color: var(--background-base);
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
|
||||
&:disabled {
|
||||
pointer-events: none;
|
||||
|
||||
25
packages/ui/src/context/helper.tsx
Normal file
25
packages/ui/src/context/helper.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { createContext, Show, useContext, type ParentProps } from "solid-js"
|
||||
|
||||
export function createSimpleContext<T, Props extends Record<string, any>>(input: {
|
||||
name: string
|
||||
init: ((input: Props) => T) | (() => T)
|
||||
}) {
|
||||
const ctx = createContext<T>()
|
||||
|
||||
return {
|
||||
provider: (props: ParentProps<Props>) => {
|
||||
const init = input.init(props)
|
||||
return (
|
||||
// @ts-expect-error
|
||||
<Show when={init.ready === undefined || init.ready === true}>
|
||||
<ctx.Provider value={init}>{props.children}</ctx.Provider>
|
||||
</Show>
|
||||
)
|
||||
},
|
||||
use() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) throw new Error(`${input.name} context must be used within a context provider`)
|
||||
return value
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -373,7 +373,11 @@ const theme: ThemeInput = {
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["storage.modifier.import.java", "variable.language.wildcard.java", "storage.modifier.package.java"],
|
||||
scope: [
|
||||
"storage.modifier.import.java",
|
||||
"variable.language.wildcard.java",
|
||||
"storage.modifier.package.java",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--text-base)",
|
||||
},
|
||||
@@ -11,18 +11,22 @@ export interface FilteredListProps<T> {
|
||||
current?: T
|
||||
groupBy?: (x: T) => string
|
||||
sortBy?: (a: T, b: T) => number
|
||||
sortGroupsBy?: (a: { category: string; items: T[] }, b: { category: string; items: T[] }) => number
|
||||
sortGroupsBy?: (
|
||||
a: { category: string; items: T[] },
|
||||
b: { category: string; items: T[] },
|
||||
) => number
|
||||
onSelect?: (value: T | undefined) => void
|
||||
}
|
||||
|
||||
export function useFilteredList<T>(props: FilteredListProps<T>) {
|
||||
const [store, setStore] = createStore<{ filter: string }>({ filter: "" })
|
||||
|
||||
const [grouped] = createResource(
|
||||
const [grouped, { refetch }] = createResource(
|
||||
() => store.filter,
|
||||
async (filter) => {
|
||||
const needle = filter?.toLowerCase()
|
||||
const all = (typeof props.items === "function" ? await props.items(needle) : props.items) || []
|
||||
const all =
|
||||
(typeof props.items === "function" ? await props.items(needle) : props.items) || []
|
||||
const result = pipe(
|
||||
all,
|
||||
(x) => {
|
||||
@@ -76,10 +80,11 @@ export function useFilteredList<T>(props: FilteredListProps<T>) {
|
||||
}
|
||||
|
||||
return {
|
||||
filter: () => store.filter,
|
||||
grouped,
|
||||
filter: () => store.filter,
|
||||
flat,
|
||||
reset,
|
||||
refetch,
|
||||
clear: () => setStore("filter", ""),
|
||||
onKeyDown,
|
||||
onInput,
|
||||
|
||||
@@ -6,15 +6,21 @@
|
||||
@import "./base.css" layer(base);
|
||||
|
||||
@import "../components/accordion.css" layer(components);
|
||||
@import "../components/basic-tool.css" layer(components);
|
||||
@import "../components/button.css" layer(components);
|
||||
@import "../components/card.css" layer(components);
|
||||
@import "../components/checkbox.css" layer(components);
|
||||
@import "../components/diff.css" layer(components);
|
||||
@import "../components/diff-changes.css" layer(components);
|
||||
@import "../components/collapsible.css" layer(components);
|
||||
@import "../components/dialog.css" layer(components);
|
||||
@import "../components/icon.css" layer(components);
|
||||
@import "../components/icon-button.css" layer(components);
|
||||
@import "../components/input.css" layer(components);
|
||||
@import "../components/list.css" layer(components);
|
||||
@import "../components/markdown.css" layer(components);
|
||||
@import "../components/message-part.css" layer(components);
|
||||
@import "../components/progress-circle.css" layer(components);
|
||||
@import "../components/select.css" layer(components);
|
||||
@import "../components/select-dialog.css" layer(components);
|
||||
@import "../components/tabs.css" layer(components);
|
||||
|
||||
@@ -59,10 +59,14 @@
|
||||
0 0 0 3px var(--border-weak-selected, rgba(1, 103, 255, 0.29)),
|
||||
0 0 0 1px var(--border-selected, rgba(0, 74, 255, 0.99)), 0 1px 2px -1px rgba(19, 16, 16, 0.25),
|
||||
0 1px 2px 0 rgba(19, 16, 16, 0.08), 0 1px 3px 0 rgba(19, 16, 16, 0.12);
|
||||
|
||||
--text-mix-blend-mode: multiply;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* OC-1-Light */
|
||||
--text-mix-blend-mode: multiply;
|
||||
|
||||
color-scheme: light;
|
||||
--background-base: #f8f7f7;
|
||||
--background-weak: var(--smoke-light-3);
|
||||
@@ -292,6 +296,8 @@
|
||||
--button-ghost-hover2: var(--smoke-light-alpha-3);
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
--text-mix-blend-mode: plus-lighter;
|
||||
|
||||
/* OC-1-Dark */
|
||||
color-scheme: dark;
|
||||
--background-base: var(--smoke-dark-1);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/node22/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
// General
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
"target": "ESNext",
|
||||
|
||||
// Modules
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
@@ -12,9 +13,16 @@
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"noEmit": true,
|
||||
|
||||
"lib": [
|
||||
"es2022",
|
||||
"dom",
|
||||
"dom.iterable"
|
||||
],
|
||||
// Type Checking & Safety
|
||||
"strict": true,
|
||||
"types": ["vite/client"]
|
||||
"types": [
|
||||
"vite/client",
|
||||
"bun"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user