Merge branch 'dev' into update-design-subscriptions
commit
e258662178
8
bun.lock
8
bun.lock
|
|
@ -411,7 +411,7 @@
|
|||
"@solid-primitives/resize-observer": "2.1.3",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"dompurify": "catalog:",
|
||||
"dompurify": "3.3.1",
|
||||
"fuzzysort": "catalog:",
|
||||
"katex": "0.16.27",
|
||||
"luxon": "catalog:",
|
||||
|
|
@ -506,7 +506,7 @@
|
|||
"@tailwindcss/vite": "4.1.11",
|
||||
"@tsconfig/bun": "1.0.9",
|
||||
"@tsconfig/node22": "22.0.2",
|
||||
"@types/bun": "1.3.6",
|
||||
"@types/bun": "1.3.5",
|
||||
"@types/luxon": "3.7.1",
|
||||
"@types/node": "22.13.9",
|
||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||
|
|
@ -1774,7 +1774,7 @@
|
|||
|
||||
"@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
|
||||
"@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
|
||||
|
||||
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||
|
||||
|
|
@ -2076,7 +2076,7 @@
|
|||
|
||||
"bun-pty": ["bun-pty@0.4.4", "", {}, "sha512-WK4G6uWsZgu1v4hKIlw6G1q2AOf8Rbga2Yr7RnxArVjjyb+mtVa/CFc9GOJf+OYSJSH8k7LonAtQOVeNAddRyg=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
|
||||
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
|
||||
|
||||
"bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="],
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-hB6PWxkvRgb7o8vQO88SDHArG/FcdnLD7FR/BHvkYik=",
|
||||
"aarch64-darwin": "sha256-P63bPPVm5F/YQ6DTaIQNB7SqDmQHQBhVsOh3Kd/c8Jw="
|
||||
"x86_64-linux": "sha256-4ndHIlS9t1ynRdFszJ1nvcu3YhunhuOc7jcuHI1FbnM=",
|
||||
"aarch64-darwin": "sha256-C0E9KAEj3GI83HwirIL2zlXYIe92T+7Iv6F51BB6slY="
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"description": "AI-powered development tool",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.6",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
||||
"typecheck": "bun turbo typecheck",
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
"packages/slack"
|
||||
],
|
||||
"catalog": {
|
||||
"@types/bun": "1.3.6",
|
||||
"@types/bun": "1.3.5",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"ulid": "3.0.1",
|
||||
|
|
|
|||
|
|
@ -54,14 +54,17 @@ export function SessionHeader() {
|
|||
<Portal mount={mount()}>
|
||||
<button
|
||||
type="button"
|
||||
class="hidden md:flex w-[320px] h-7 px-1.5 items-center gap-2 rounded-md border border-border-weak-base bg-surface-raised-base transition-colors cursor-default hover:bg-surface-raised-base-hover focus:bg-surface-raised-base-hover active:bg-surface-raised-base-active"
|
||||
class="hidden md:flex w-[320px] h-8 p-1.5 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-raised-base transition-colors cursor-default hover:bg-surface-raised-base-hover focus:bg-surface-raised-base-hover active:bg-surface-raised-base-active"
|
||||
onClick={() => command.trigger("file.open")}
|
||||
>
|
||||
<Icon name="magnifying-glass" size="small" class="text-text-weak" />
|
||||
<span class="flex-1 min-w-0 text-14-regular text-text-weak truncate">Search {name()}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon name="magnifying-glass" size="normal" class="icon-base" />
|
||||
<span class="flex-1 min-w-0 text-14-regular text-text-weak truncate">Search {name()}</span>
|
||||
</div>
|
||||
|
||||
<Show when={hotkey()}>
|
||||
{(keybind) => (
|
||||
<span class="shrink-0 flex items-center justify-center h-5 px-2 rounded-md border border-border-weak-base bg-surface-base text-12-medium text-text-weak">
|
||||
<span class="shrink-0 flex items-center justify-center h-5 px-2 rounded-[2px] border border-border-weak-base bg-surface-base text-12-medium text-text-weak">
|
||||
{keybind()}
|
||||
</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ export function Titlebar() {
|
|||
onClick={layout.mobileSidebar.toggle}
|
||||
/>
|
||||
<TooltipKeybind
|
||||
class="hidden xl:flex shrink-0"
|
||||
class="hidden xl:flex shrink-0 ml-14"
|
||||
placement="bottom"
|
||||
title="Toggle sidebar"
|
||||
keybind={command.keybind("sidebar.toggle")}
|
||||
|
|
|
|||
|
|
@ -161,53 +161,64 @@ export default function Layout(props: ParentProps) {
|
|||
})
|
||||
|
||||
onMount(() => {
|
||||
const alerts = {
|
||||
"permission.asked": {
|
||||
title: "Permission required",
|
||||
icon: "checklist" as const,
|
||||
description: (sessionTitle: string, projectName: string) =>
|
||||
`${sessionTitle} in ${projectName} needs permission`,
|
||||
},
|
||||
"question.asked": {
|
||||
title: "Question",
|
||||
icon: "bubble-5" as const,
|
||||
description: (sessionTitle: string, projectName: string) => `${sessionTitle} in ${projectName} has a question`,
|
||||
},
|
||||
}
|
||||
|
||||
const toastBySession = new Map<string, number>()
|
||||
const alertedAtBySession = new Map<string, number>()
|
||||
const permissionAlertCooldownMs = 5000
|
||||
const cooldownMs = 5000
|
||||
|
||||
const unsub = globalSDK.event.listen((e) => {
|
||||
if (e.details?.type !== "permission.asked") return
|
||||
if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return
|
||||
const config = alerts[e.details.type]
|
||||
const directory = e.name
|
||||
const perm = e.details.properties
|
||||
if (permission.autoResponds(perm, directory)) return
|
||||
const props = e.details.properties
|
||||
if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return
|
||||
|
||||
const [store] = globalSync.child(directory)
|
||||
const session = store.session.find((s) => s.id === perm.sessionID)
|
||||
const sessionKey = `${directory}:${perm.sessionID}`
|
||||
const session = store.session.find((s) => s.id === props.sessionID)
|
||||
const sessionKey = `${directory}:${props.sessionID}`
|
||||
|
||||
const sessionTitle = session?.title ?? "New session"
|
||||
const projectName = getFilename(directory)
|
||||
const description = `${sessionTitle} in ${projectName} needs permission`
|
||||
const href = `/${base64Encode(directory)}/session/${perm.sessionID}`
|
||||
const description = config.description(sessionTitle, projectName)
|
||||
const href = `/${base64Encode(directory)}/session/${props.sessionID}`
|
||||
|
||||
const now = Date.now()
|
||||
const lastAlerted = alertedAtBySession.get(sessionKey) ?? 0
|
||||
if (now - lastAlerted < permissionAlertCooldownMs) return
|
||||
if (now - lastAlerted < cooldownMs) return
|
||||
alertedAtBySession.set(sessionKey, now)
|
||||
|
||||
void platform.notify("Permission required", description, href)
|
||||
void platform.notify(config.title, description, href)
|
||||
|
||||
const currentDir = params.dir ? base64Decode(params.dir) : undefined
|
||||
const currentSession = params.id
|
||||
if (directory === currentDir && perm.sessionID === currentSession) return
|
||||
if (directory === currentDir && props.sessionID === currentSession) return
|
||||
if (directory === currentDir && session?.parentID === currentSession) return
|
||||
|
||||
const existingToastId = toastBySession.get(sessionKey)
|
||||
if (existingToastId !== undefined) {
|
||||
toaster.dismiss(existingToastId)
|
||||
}
|
||||
if (existingToastId !== undefined) toaster.dismiss(existingToastId)
|
||||
|
||||
const toastId = showToast({
|
||||
persistent: true,
|
||||
icon: "checklist",
|
||||
title: "Permission required",
|
||||
icon: config.icon,
|
||||
title: config.title,
|
||||
description,
|
||||
actions: [
|
||||
{
|
||||
label: "Go to session",
|
||||
onClick: () => {
|
||||
navigate(href)
|
||||
},
|
||||
onClick: () => navigate(href),
|
||||
},
|
||||
{
|
||||
label: "Dismiss",
|
||||
|
|
@ -848,7 +859,6 @@ export default function Layout(props: ParentProps) {
|
|||
return false
|
||||
})
|
||||
const isWorking = createMemo(() => {
|
||||
if (props.session.id === params.id) return false
|
||||
if (hasPermissions()) return false
|
||||
const status = sessionStore.session_status[props.session.id]
|
||||
return status?.type === "busy" || status?.type === "retry"
|
||||
|
|
@ -871,9 +881,9 @@ export default function Layout(props: ParentProps) {
|
|||
<div
|
||||
data-session-id={props.session.id}
|
||||
class="group/session relative w-full rounded-md cursor-default transition-colors px-3
|
||||
hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover"
|
||||
hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
|
||||
>
|
||||
<Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
|
||||
<Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={16} openDelay={1000}>
|
||||
<A
|
||||
href={`${props.slug}/session/${props.session.id}`}
|
||||
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
|
||||
|
|
@ -903,7 +913,13 @@ export default function Layout(props: ParentProps) {
|
|||
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
|
||||
{props.session.title}
|
||||
</span>
|
||||
<Show when={props.session.summary}>{(summary) => <DiffChanges changes={summary()} />}</Show>
|
||||
<Show when={props.session.summary}>
|
||||
{(summary) => (
|
||||
<div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
|
||||
<DiffChanges changes={summary()} />
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</A>
|
||||
</Tooltip>
|
||||
|
|
@ -914,6 +930,7 @@ export default function Layout(props: ParentProps) {
|
|||
placement={props.mobile ? "bottom" : "right"}
|
||||
title="Archive session"
|
||||
keybind={command.keybind("session.archive")}
|
||||
gutter={8}
|
||||
>
|
||||
<IconButton icon="archive" variant="ghost" onClick={() => archiveSession(props.session)} />
|
||||
</TooltipKeybind>
|
||||
|
|
@ -961,7 +978,7 @@ export default function Layout(props: ParentProps) {
|
|||
type="button"
|
||||
classList={{
|
||||
"flex items-center justify-center size-10 p-1 rounded-md border transition-colors cursor-default": true,
|
||||
"bg-surface-base-hover border-icon-strong-base": selected(),
|
||||
"bg-transparent border-icon-strong-base hover:bg-surface-base-hover": selected(),
|
||||
"bg-transparent border-transparent hover:bg-surface-base-hover hover:border-border-weak-base": !selected(),
|
||||
}}
|
||||
onClick={() => navigateToProject(props.project.worktree)}
|
||||
|
|
@ -973,9 +990,9 @@ export default function Layout(props: ParentProps) {
|
|||
return (
|
||||
// @ts-ignore
|
||||
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
|
||||
<HoverCard openDelay={0} closeDelay={0} placement="right-start" gutter={10} trigger={trigger}>
|
||||
<HoverCard openDelay={0} closeDelay={0} placement="right-start" gutter={8} trigger={trigger}>
|
||||
<div class="-m-3 flex flex-col w-72">
|
||||
<div class="px-3 py-2 text-12-medium text-text-strong">Recent sessions</div>
|
||||
<div class="px-3 py-2 text-12-medium text-text-weak">Recent sessions</div>
|
||||
<div class="px-2 pb-2 flex flex-col gap-2">
|
||||
<Show
|
||||
when={workspaceEnabled()}
|
||||
|
|
@ -999,7 +1016,7 @@ export default function Layout(props: ParentProps) {
|
|||
<div class="shrink-0 size-6 flex items-center justify-center">
|
||||
<Icon name="branch" size="small" class="text-icon-base" />
|
||||
</div>
|
||||
<span class="truncate text-14-medium text-text-strong">{label(directory)}</span>
|
||||
<span class="truncate text-14-medium text-text-base">{label(directory)}</span>
|
||||
</div>
|
||||
<For each={sessions(directory)}>
|
||||
{(session) => (
|
||||
|
|
@ -1011,18 +1028,20 @@ export default function Layout(props: ParentProps) {
|
|||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="px-2 py-2 border-t border-border-weak-base">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="flex w-full text-left justify-start text-text-base px-2"
|
||||
onClick={() => {
|
||||
layout.sidebar.open()
|
||||
navigateToProject(props.project.worktree)
|
||||
}}
|
||||
>
|
||||
View all sessions
|
||||
</Button>
|
||||
</div>
|
||||
<Show when={!selected()}>
|
||||
<div class="px-2 py-2 border-t border-border-weak-base">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="flex w-full text-left justify-start text-text-base px-2"
|
||||
onClick={() => {
|
||||
layout.sidebar.open()
|
||||
navigateToProject(props.project.worktree)
|
||||
}}
|
||||
>
|
||||
View all sessions
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</HoverCard>
|
||||
</div>
|
||||
|
|
@ -1104,7 +1123,7 @@ export default function Layout(props: ParentProps) {
|
|||
<div class="flex items-center justify-center shrink-0 size-6">
|
||||
<Icon name="branch" size="small" />
|
||||
</div>
|
||||
<span class="truncate text-14-medium text-text-strong">{title()}</span>
|
||||
<span class="truncate text-14-medium text-text-base">{title()}</span>
|
||||
<Icon
|
||||
name={open() ? "chevron-down" : "chevron-right"}
|
||||
size="small"
|
||||
|
|
@ -1113,17 +1132,20 @@ export default function Layout(props: ParentProps) {
|
|||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<div class="absolute right-1 top-1/2 -translate-y-1/2 hidden items-center gap-0.5 pointer-events-none group-hover/trigger:flex group-focus-within/trigger:flex">
|
||||
<Tooltip class="pointer-events-auto" value="More options" placement="top">
|
||||
<IconButton icon="dot-grid" variant="ghost" class="size-6 rounded-md" />
|
||||
</Tooltip>
|
||||
<Tooltip class="pointer-events-auto" value="New session" placement="top">
|
||||
<IconButton icon="dot-grid" variant="ghost" class="size-6 rounded-md pointer-events-auto" />
|
||||
<TooltipKeybind
|
||||
class="pointer-events-auto"
|
||||
placement="right"
|
||||
title="New session"
|
||||
keybind={command.keybind("session.new")}
|
||||
>
|
||||
<IconButton
|
||||
icon="plus-small"
|
||||
variant="ghost"
|
||||
class="size-6 rounded-md"
|
||||
onClick={() => navigate(`/${slug()}/session`)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1146,9 +1168,12 @@ export default function Layout(props: ParentProps) {
|
|||
<div class="relative w-full py-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="flex w-full text-left justify-start text-12-medium text-text-weak px-10"
|
||||
class="flex w-full text-left justify-start text-14-regular text-text-weak px-10"
|
||||
size="large"
|
||||
onClick={loadMore}
|
||||
onClick={(e: MouseEvent) => {
|
||||
loadMore()
|
||||
;(e.currentTarget as HTMLButtonElement).blur()
|
||||
}}
|
||||
>
|
||||
Load more
|
||||
</Button>
|
||||
|
|
@ -1191,9 +1216,12 @@ export default function Layout(props: ParentProps) {
|
|||
<div class="relative w-full py-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="flex w-full text-left justify-start text-12-medium text-text-weak px-10"
|
||||
class="flex w-full text-left justify-start text-14-regular text-text-weak px-10"
|
||||
size="large"
|
||||
onClick={loadMore}
|
||||
onClick={(e: MouseEvent) => {
|
||||
loadMore()
|
||||
;(e.currentTarget as HTMLButtonElement).blur()
|
||||
}}
|
||||
>
|
||||
Load more
|
||||
</Button>
|
||||
|
|
@ -1312,7 +1340,7 @@ export default function Layout(props: ParentProps) {
|
|||
{(p) => (
|
||||
<>
|
||||
<div class="shrink-0 px-2 py-1">
|
||||
<div class="flex items-start justify-between gap-2 p-2">
|
||||
<div class="group/project flex items-start justify-between gap-2 p-2 pr-1">
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span class="text-16-medium text-text-strong truncate">{projectName()}</span>
|
||||
<Tooltip placement="right" value={project()?.worktree} class="shrink-0">
|
||||
|
|
@ -1326,22 +1354,22 @@ export default function Layout(props: ParentProps) {
|
|||
as={IconButton}
|
||||
icon="dot-grid"
|
||||
variant="ghost"
|
||||
class="shrink-0 size-6 rounded-md"
|
||||
class="shrink-0 size-6 rounded-md opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100 data-[expanded]:bg-surface-base-active"
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Content class="mt-1">
|
||||
<DropdownMenu.Item onSelect={() => dialog.show(() => <DialogEditProject project={p} />)}>
|
||||
<DropdownMenu.ItemLabel>Edit project</DropdownMenu.ItemLabel>
|
||||
<DropdownMenu.ItemLabel>Edit</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onSelect={() => closeProject(p.worktree)}>
|
||||
<DropdownMenu.ItemLabel>Close project</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onSelect={() => layout.sidebar.toggleWorkspaces(p.worktree)}>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{layout.sidebar.workspaces(p.worktree)() ? "Disable workspaces" : "Enable workspaces"}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onSelect={() => closeProject(p.worktree)}>
|
||||
<DropdownMenu.ItemLabel>Close</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
|
|
|
|||
|
|
@ -223,7 +223,7 @@ async fn check_server_health(url: &str, password: Option<&str>) -> bool {
|
|||
pub fn run() {
|
||||
let updater_enabled = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[cfg(all(target_os = "macos", not(debug_assertions)))]
|
||||
let _ = std::process::Command::new("killall")
|
||||
.arg("opencode-cli")
|
||||
.output();
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import type { FilePart } from "@opencode-ai/sdk/v2"
|
|||
import { TuiEvent } from "../../event"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { formatDuration } from "@/util/format"
|
||||
import { createColors, createFrames } from "../../ui/spinner.ts"
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { DialogProvider as DialogProviderConnect } from "../dialog-provider"
|
||||
|
|
@ -1037,7 +1038,8 @@ export function Prompt(props: PromptProps) {
|
|||
if (!r) return ""
|
||||
const baseMessage = message()
|
||||
const truncatedHint = isTruncated() ? " (click to expand)" : ""
|
||||
const retryInfo = ` [retrying ${seconds() > 0 ? `in ${seconds()}s ` : ""}attempt #${r.attempt}]`
|
||||
const duration = formatDuration(seconds())
|
||||
const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]`
|
||||
return baseMessage + truncatedHint + retryInfo
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export function FormatError(input: unknown) {
|
|||
return `Directory "${input.data.dir}" in ${input.data.path} is not valid. Rename the directory to "${input.data.suggestion}" or remove it. This is a common typo.`
|
||||
}
|
||||
if (ConfigMarkdown.FrontmatterError.isInstance(input)) {
|
||||
return `Failed to parse frontmatter in ${input.data.path}:\n${input.data.message}`
|
||||
return input.data.message
|
||||
}
|
||||
if (Config.InvalidError.isInstance(input))
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ import { BunProc } from "@/bun"
|
|||
import { Installation } from "@/installation"
|
||||
import { ConfigMarkdown } from "./markdown"
|
||||
import { existsSync } from "fs"
|
||||
import { Bus } from "@/bus"
|
||||
import { Session } from "@/session"
|
||||
|
||||
export namespace Config {
|
||||
const log = Log.create({ service: "config" })
|
||||
|
|
@ -231,8 +233,15 @@ export namespace Config {
|
|||
dot: true,
|
||||
cwd: dir,
|
||||
})) {
|
||||
const md = await ConfigMarkdown.parse(item)
|
||||
if (!md.data) continue
|
||||
const md = await ConfigMarkdown.parse(item).catch((err) => {
|
||||
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
||||
? err.data.message
|
||||
: `Failed to parse command ${item}`
|
||||
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
||||
log.error("failed to load command", { command: item, err })
|
||||
return undefined
|
||||
})
|
||||
if (!md) continue
|
||||
|
||||
const patterns = ["/.opencode/command/", "/.opencode/commands/", "/command/", "/commands/"]
|
||||
const file = rel(item, patterns) ?? path.basename(item)
|
||||
|
|
@ -263,8 +272,15 @@ export namespace Config {
|
|||
dot: true,
|
||||
cwd: dir,
|
||||
})) {
|
||||
const md = await ConfigMarkdown.parse(item)
|
||||
if (!md.data) continue
|
||||
const md = await ConfigMarkdown.parse(item).catch((err) => {
|
||||
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
||||
? err.data.message
|
||||
: `Failed to parse agent ${item}`
|
||||
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
||||
log.error("failed to load agent", { agent: item, err })
|
||||
return undefined
|
||||
})
|
||||
if (!md) continue
|
||||
|
||||
const patterns = ["/.opencode/agent/", "/.opencode/agents/", "/agent/", "/agents/"]
|
||||
const file = rel(item, patterns) ?? path.basename(item)
|
||||
|
|
@ -294,8 +310,15 @@ export namespace Config {
|
|||
dot: true,
|
||||
cwd: dir,
|
||||
})) {
|
||||
const md = await ConfigMarkdown.parse(item)
|
||||
if (!md.data) continue
|
||||
const md = await ConfigMarkdown.parse(item).catch((err) => {
|
||||
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
||||
? err.data.message
|
||||
: `Failed to parse mode ${item}`
|
||||
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
||||
log.error("failed to load mode", { mode: item, err })
|
||||
return undefined
|
||||
})
|
||||
if (!md) continue
|
||||
|
||||
const config = {
|
||||
name: path.basename(item, ".md"),
|
||||
|
|
|
|||
|
|
@ -14,8 +14,60 @@ export namespace ConfigMarkdown {
|
|||
return Array.from(template.matchAll(SHELL_REGEX))
|
||||
}
|
||||
|
||||
export function preprocessFrontmatter(content: string): string {
|
||||
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
||||
if (!match) return content
|
||||
|
||||
const frontmatter = match[1]
|
||||
const lines = frontmatter.split("\n")
|
||||
const result: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
// skip comments and empty lines
|
||||
if (line.trim().startsWith("#") || line.trim() === "") {
|
||||
result.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
// skip lines that are continuations (indented)
|
||||
if (line.match(/^\s+/)) {
|
||||
result.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
// match key: value pattern
|
||||
const kvMatch = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)$/)
|
||||
if (!kvMatch) {
|
||||
result.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
const key = kvMatch[1]
|
||||
const value = kvMatch[2].trim()
|
||||
|
||||
// skip if value is empty, already quoted, or uses block scalar
|
||||
if (value === "" || value === ">" || value === "|" || value.startsWith('"') || value.startsWith("'")) {
|
||||
result.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
// if value contains a colon, convert to block scalar
|
||||
if (value.includes(":")) {
|
||||
result.push(`${key}: |`)
|
||||
result.push(` ${value}`)
|
||||
continue
|
||||
}
|
||||
|
||||
result.push(line)
|
||||
}
|
||||
|
||||
const processed = result.join("\n")
|
||||
return content.replace(frontmatter, () => processed)
|
||||
}
|
||||
|
||||
export async function parse(filePath: string) {
|
||||
const template = await Bun.file(filePath).text()
|
||||
const raw = await Bun.file(filePath).text()
|
||||
const template = preprocessFrontmatter(raw)
|
||||
|
||||
try {
|
||||
const md = matter(template)
|
||||
|
|
@ -24,7 +76,7 @@ export namespace ConfigMarkdown {
|
|||
throw new FrontmatterError(
|
||||
{
|
||||
path: filePath,
|
||||
message: `Failed to parse YAML frontmatter: ${err instanceof Error ? err.message : String(err)}`,
|
||||
message: `${filePath}: Failed to parse YAML frontmatter: ${err instanceof Error ? err.message : String(err)}`,
|
||||
},
|
||||
{ cause: err },
|
||||
)
|
||||
|
|
|
|||
|
|
@ -272,7 +272,11 @@ export namespace Project {
|
|||
|
||||
export async function list() {
|
||||
const keys = await Storage.list(["project"])
|
||||
return await Promise.all(keys.map((x) => Storage.read<Info>(x)))
|
||||
const projects = await Promise.all(keys.map((x) => Storage.read<Info>(x)))
|
||||
return projects.map((project) => ({
|
||||
...project,
|
||||
sandboxes: project.sandboxes?.filter((x) => existsSync(x)),
|
||||
}))
|
||||
}
|
||||
|
||||
export const update = fn(
|
||||
|
|
|
|||
|
|
@ -81,7 +81,11 @@ export namespace ModelsDev {
|
|||
const file = Bun.file(filepath)
|
||||
const result = await file.json().catch(() => {})
|
||||
if (result) return result as Record<string, Provider>
|
||||
const json = await data()
|
||||
if (typeof data === "function") {
|
||||
const json = await data()
|
||||
return JSON.parse(json) as Record<string, Provider>
|
||||
}
|
||||
const json = await fetch("https://models.dev/api.json").then((x) => x.text())
|
||||
return JSON.parse(json) as Record<string, Provider>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import z from "zod"
|
||||
import path from "path"
|
||||
import { Config } from "../config/config"
|
||||
import { Instance } from "../project/instance"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
|
|
@ -7,6 +8,9 @@ import { Log } from "../util/log"
|
|||
import { Global } from "@/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Bus } from "@/bus"
|
||||
import { TuiEvent } from "@/cli/cmd/tui/event"
|
||||
import { Session } from "@/session"
|
||||
|
||||
export namespace Skill {
|
||||
const log = Log.create({ service: "skill" })
|
||||
|
|
@ -42,10 +46,16 @@ export namespace Skill {
|
|||
const skills: Record<string, Info> = {}
|
||||
|
||||
const addSkill = async (match: string) => {
|
||||
const md = await ConfigMarkdown.parse(match)
|
||||
if (!md) {
|
||||
return
|
||||
}
|
||||
const md = await ConfigMarkdown.parse(match).catch((err) => {
|
||||
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
||||
? err.data.message
|
||||
: `Failed to parse skill ${match}`
|
||||
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
||||
log.error("failed to load skill", { skill: match, err })
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!md) return
|
||||
|
||||
const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
|
||||
if (!parsed.success) return
|
||||
|
|
|
|||
|
|
@ -37,7 +37,15 @@ export const GrepTool = Tool.define("grep", {
|
|||
await assertExternalDirectory(ctx, searchPath, { kind: "directory" })
|
||||
|
||||
const rgPath = await Ripgrep.filepath()
|
||||
const args = ["-nH", "--hidden", "--follow", "--field-match-separator=|", "--regexp", params.pattern]
|
||||
const args = [
|
||||
"-nH",
|
||||
"--hidden",
|
||||
"--follow",
|
||||
"--no-messages",
|
||||
"--field-match-separator=|",
|
||||
"--regexp",
|
||||
params.pattern,
|
||||
]
|
||||
if (params.include) {
|
||||
args.push("--glob", params.include)
|
||||
}
|
||||
|
|
@ -52,7 +60,10 @@ export const GrepTool = Tool.define("grep", {
|
|||
const errorOutput = await new Response(proc.stderr).text()
|
||||
const exitCode = await proc.exited
|
||||
|
||||
if (exitCode === 1) {
|
||||
// Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches)
|
||||
// With --no-messages, we suppress error output but still get exit code 2 for broken symlinks etc.
|
||||
// Only fail if exit code is 2 AND no output was produced
|
||||
if (exitCode === 1 || (exitCode === 2 && !output.trim())) {
|
||||
return {
|
||||
title: params.pattern,
|
||||
metadata: { matches: 0, truncated: false },
|
||||
|
|
@ -60,10 +71,12 @@ export const GrepTool = Tool.define("grep", {
|
|||
}
|
||||
}
|
||||
|
||||
if (exitCode !== 0) {
|
||||
if (exitCode !== 0 && exitCode !== 2) {
|
||||
throw new Error(`ripgrep failed: ${errorOutput}`)
|
||||
}
|
||||
|
||||
const hasErrors = exitCode === 2
|
||||
|
||||
// Handle both Unix (\n) and Windows (\r\n) line endings
|
||||
const lines = output.trim().split(/\r?\n/)
|
||||
const matches = []
|
||||
|
|
@ -124,6 +137,11 @@ export const GrepTool = Tool.define("grep", {
|
|||
outputLines.push("(Results are truncated. Consider using a more specific path or pattern.)")
|
||||
}
|
||||
|
||||
if (hasErrors) {
|
||||
outputLines.push("")
|
||||
outputLines.push("(Some paths were inaccessible and skipped)")
|
||||
}
|
||||
|
||||
return {
|
||||
title: params.pattern,
|
||||
metadata: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
export function formatDuration(secs: number) {
|
||||
if (secs <= 0) return ""
|
||||
if (secs < 60) return `${secs}s`
|
||||
if (secs < 3600) {
|
||||
const mins = Math.floor(secs / 60)
|
||||
const remaining = secs % 60
|
||||
return remaining > 0 ? `${mins}m ${remaining}s` : `${mins}m`
|
||||
}
|
||||
if (secs < 86400) {
|
||||
const hours = Math.floor(secs / 3600)
|
||||
const remaining = Math.floor((secs % 3600) / 60)
|
||||
return remaining > 0 ? `${hours}h ${remaining}m` : `${hours}h`
|
||||
}
|
||||
if (secs < 604800) {
|
||||
const days = Math.floor(secs / 86400)
|
||||
return days === 1 ? "~1 day" : `~${days} days`
|
||||
}
|
||||
const weeks = Math.floor(secs / 604800)
|
||||
return weeks === 1 ? "~1 week" : `~${weeks} weeks`
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
---
|
||||
---
|
||||
|
||||
Content
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
description: "This is a description wrapped in quotes"
|
||||
# field: this is a commented out field that should be ignored
|
||||
occupation: This man has the following occupation: Software Engineer
|
||||
title: 'Hello World'
|
||||
name: John "Doe"
|
||||
|
||||
family: He has no 'family'
|
||||
summary: >
|
||||
This is a summary
|
||||
url: https://example.com:8080/path?query=value
|
||||
time: The time is 12:30:00 PM
|
||||
nested: First: Second: Third: Fourth
|
||||
quoted_colon: "Already quoted: no change needed"
|
||||
single_quoted_colon: 'Single quoted: also fine'
|
||||
mixed: He said "hello: world" and then left
|
||||
empty:
|
||||
dollar: Use $' and $& for special patterns
|
||||
---
|
||||
|
||||
Content that should not be parsed:
|
||||
|
||||
fake_field: this is not yaml
|
||||
another: neither is this
|
||||
time: 10:30:00 AM
|
||||
url: https://should-not-be-parsed.com:3000
|
||||
|
||||
The above lines look like YAML but are just content.
|
||||
|
|
@ -0,0 +1 @@
|
|||
Content
|
||||
|
|
@ -1,89 +1,192 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { expect, test, describe } from "bun:test"
|
||||
import { ConfigMarkdown } from "../../src/config/markdown"
|
||||
|
||||
const template = `This is a @valid/path/to/a/file and it should also match at
|
||||
the beginning of a line:
|
||||
describe("ConfigMarkdown: normal template", () => {
|
||||
const template = `This is a @valid/path/to/a/file and it should also match at
|
||||
the beginning of a line:
|
||||
|
||||
@another-valid/path/to/a/file
|
||||
@another-valid/path/to/a/file
|
||||
|
||||
but this is not:
|
||||
but this is not:
|
||||
|
||||
- Adds a "Co-authored-by:" footer which clarifies which AI agent
|
||||
helped create this commit, using an appropriate \`noreply@...\`
|
||||
or \`noreply@anthropic.com\` email address.
|
||||
- Adds a "Co-authored-by:" footer which clarifies which AI agent
|
||||
helped create this commit, using an appropriate \`noreply@...\`
|
||||
or \`noreply@anthropic.com\` email address.
|
||||
|
||||
We also need to deal with files followed by @commas, ones
|
||||
with @file-extensions.md, even @multiple.extensions.bak,
|
||||
hidden directories like @.config/ or files like @.bashrc
|
||||
and ones at the end of a sentence like @foo.md.
|
||||
We also need to deal with files followed by @commas, ones
|
||||
with @file-extensions.md, even @multiple.extensions.bak,
|
||||
hidden directories like @.config/ or files like @.bashrc
|
||||
and ones at the end of a sentence like @foo.md.
|
||||
|
||||
Also shouldn't forget @/absolute/paths.txt with and @/without/extensions,
|
||||
as well as @~/home-files and @~/paths/under/home.txt.
|
||||
Also shouldn't forget @/absolute/paths.txt with and @/without/extensions,
|
||||
as well as @~/home-files and @~/paths/under/home.txt.
|
||||
|
||||
If the reference is \`@quoted/in/backticks\` then it shouldn't match at all.`
|
||||
If the reference is \`@quoted/in/backticks\` then it shouldn't match at all.`
|
||||
|
||||
const matches = ConfigMarkdown.files(template)
|
||||
const matches = ConfigMarkdown.files(template)
|
||||
|
||||
test("should extract exactly 12 file references", () => {
|
||||
expect(matches.length).toBe(12)
|
||||
test("should extract exactly 12 file references", () => {
|
||||
expect(matches.length).toBe(12)
|
||||
})
|
||||
|
||||
test("should extract valid/path/to/a/file", () => {
|
||||
expect(matches[0][1]).toBe("valid/path/to/a/file")
|
||||
})
|
||||
|
||||
test("should extract another-valid/path/to/a/file", () => {
|
||||
expect(matches[1][1]).toBe("another-valid/path/to/a/file")
|
||||
})
|
||||
|
||||
test("should extract paths ignoring comma after", () => {
|
||||
expect(matches[2][1]).toBe("commas")
|
||||
})
|
||||
|
||||
test("should extract a path with a file extension and comma after", () => {
|
||||
expect(matches[3][1]).toBe("file-extensions.md")
|
||||
})
|
||||
|
||||
test("should extract a path with multiple dots and comma after", () => {
|
||||
expect(matches[4][1]).toBe("multiple.extensions.bak")
|
||||
})
|
||||
|
||||
test("should extract hidden directory", () => {
|
||||
expect(matches[5][1]).toBe(".config/")
|
||||
})
|
||||
|
||||
test("should extract hidden file", () => {
|
||||
expect(matches[6][1]).toBe(".bashrc")
|
||||
})
|
||||
|
||||
test("should extract a file ignoring period at end of sentence", () => {
|
||||
expect(matches[7][1]).toBe("foo.md")
|
||||
})
|
||||
|
||||
test("should extract an absolute path with an extension", () => {
|
||||
expect(matches[8][1]).toBe("/absolute/paths.txt")
|
||||
})
|
||||
|
||||
test("should extract an absolute path without an extension", () => {
|
||||
expect(matches[9][1]).toBe("/without/extensions")
|
||||
})
|
||||
|
||||
test("should extract an absolute path in home directory", () => {
|
||||
expect(matches[10][1]).toBe("~/home-files")
|
||||
})
|
||||
|
||||
test("should extract an absolute path under home directory", () => {
|
||||
expect(matches[11][1]).toBe("~/paths/under/home.txt")
|
||||
})
|
||||
|
||||
test("should not match when preceded by backtick", () => {
|
||||
const backtickTest = "This `@should/not/match` should be ignored"
|
||||
const backtickMatches = ConfigMarkdown.files(backtickTest)
|
||||
expect(backtickMatches.length).toBe(0)
|
||||
})
|
||||
|
||||
test("should not match email addresses", () => {
|
||||
const emailTest = "Contact user@example.com for help"
|
||||
const emailMatches = ConfigMarkdown.files(emailTest)
|
||||
expect(emailMatches.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
test("should extract valid/path/to/a/file", () => {
|
||||
expect(matches[0][1]).toBe("valid/path/to/a/file")
|
||||
describe("ConfigMarkdown: frontmatter parsing", async () => {
|
||||
const parsed = await ConfigMarkdown.parse(import.meta.dir + "/fixtures/frontmatter.md")
|
||||
|
||||
test("should parse without throwing", () => {
|
||||
expect(parsed).toBeDefined()
|
||||
expect(parsed.data).toBeDefined()
|
||||
expect(parsed.content).toBeDefined()
|
||||
})
|
||||
|
||||
test("should extract description field", () => {
|
||||
expect(parsed.data.description).toBe("This is a description wrapped in quotes")
|
||||
})
|
||||
|
||||
test("should extract occupation field with colon in value", () => {
|
||||
expect(parsed.data.occupation).toBe("This man has the following occupation: Software Engineer\n")
|
||||
})
|
||||
|
||||
test("should extract title field with single quotes", () => {
|
||||
expect(parsed.data.title).toBe("Hello World")
|
||||
})
|
||||
|
||||
test("should extract name field with embedded quotes", () => {
|
||||
expect(parsed.data.name).toBe('John "Doe"')
|
||||
})
|
||||
|
||||
test("should extract family field with embedded single quotes", () => {
|
||||
expect(parsed.data.family).toBe("He has no 'family'")
|
||||
})
|
||||
|
||||
test("should extract multiline summary field", () => {
|
||||
expect(parsed.data.summary).toBe("This is a summary\n")
|
||||
})
|
||||
|
||||
test("should not include commented fields in data", () => {
|
||||
expect(parsed.data.field).toBeUndefined()
|
||||
})
|
||||
|
||||
test("should extract URL with port", () => {
|
||||
expect(parsed.data.url).toBe("https://example.com:8080/path?query=value\n")
|
||||
})
|
||||
|
||||
test("should extract time with colons", () => {
|
||||
expect(parsed.data.time).toBe("The time is 12:30:00 PM\n")
|
||||
})
|
||||
|
||||
test("should extract value with multiple colons", () => {
|
||||
expect(parsed.data.nested).toBe("First: Second: Third: Fourth\n")
|
||||
})
|
||||
|
||||
test("should preserve already double-quoted values with colons", () => {
|
||||
expect(parsed.data.quoted_colon).toBe("Already quoted: no change needed")
|
||||
})
|
||||
|
||||
test("should preserve already single-quoted values with colons", () => {
|
||||
expect(parsed.data.single_quoted_colon).toBe("Single quoted: also fine")
|
||||
})
|
||||
|
||||
test("should extract value with quotes and colons mixed", () => {
|
||||
expect(parsed.data.mixed).toBe('He said "hello: world" and then left\n')
|
||||
})
|
||||
|
||||
test("should handle empty values", () => {
|
||||
expect(parsed.data.empty).toBeNull()
|
||||
})
|
||||
|
||||
test("should handle dollar sign replacement patterns literally", () => {
|
||||
expect(parsed.data.dollar).toBe("Use $' and $& for special patterns")
|
||||
})
|
||||
|
||||
test("should not parse fake yaml from content", () => {
|
||||
expect(parsed.data.fake_field).toBeUndefined()
|
||||
expect(parsed.data.another).toBeUndefined()
|
||||
})
|
||||
|
||||
test("should extract content after frontmatter without modification", () => {
|
||||
expect(parsed.content).toContain("Content that should not be parsed:")
|
||||
expect(parsed.content).toContain("fake_field: this is not yaml")
|
||||
expect(parsed.content).toContain("url: https://should-not-be-parsed.com:3000")
|
||||
})
|
||||
})
|
||||
|
||||
test("should extract another-valid/path/to/a/file", () => {
|
||||
expect(matches[1][1]).toBe("another-valid/path/to/a/file")
|
||||
describe("ConfigMarkdown: frontmatter parsing w/ empty frontmatter", async () => {
|
||||
const result = await ConfigMarkdown.parse(import.meta.dir + "/fixtures/empty-frontmatter.md")
|
||||
|
||||
test("should parse without throwing", () => {
|
||||
expect(result).toBeDefined()
|
||||
expect(result.data).toEqual({})
|
||||
expect(result.content.trim()).toBe("Content")
|
||||
})
|
||||
})
|
||||
|
||||
test("should extract paths ignoring comma after", () => {
|
||||
expect(matches[2][1]).toBe("commas")
|
||||
})
|
||||
describe("ConfigMarkdown: frontmatter parsing w/ no frontmatter", async () => {
|
||||
const result = await ConfigMarkdown.parse(import.meta.dir + "/fixtures/no-frontmatter.md")
|
||||
|
||||
test("should extract a path with a file extension and comma after", () => {
|
||||
expect(matches[3][1]).toBe("file-extensions.md")
|
||||
})
|
||||
|
||||
test("should extract a path with multiple dots and comma after", () => {
|
||||
expect(matches[4][1]).toBe("multiple.extensions.bak")
|
||||
})
|
||||
|
||||
test("should extract hidden directory", () => {
|
||||
expect(matches[5][1]).toBe(".config/")
|
||||
})
|
||||
|
||||
test("should extract hidden file", () => {
|
||||
expect(matches[6][1]).toBe(".bashrc")
|
||||
})
|
||||
|
||||
test("should extract a file ignoring period at end of sentence", () => {
|
||||
expect(matches[7][1]).toBe("foo.md")
|
||||
})
|
||||
|
||||
test("should extract an absolute path with an extension", () => {
|
||||
expect(matches[8][1]).toBe("/absolute/paths.txt")
|
||||
})
|
||||
|
||||
test("should extract an absolute path without an extension", () => {
|
||||
expect(matches[9][1]).toBe("/without/extensions")
|
||||
})
|
||||
|
||||
test("should extract an absolute path in home directory", () => {
|
||||
expect(matches[10][1]).toBe("~/home-files")
|
||||
})
|
||||
|
||||
test("should extract an absolute path under home directory", () => {
|
||||
expect(matches[11][1]).toBe("~/paths/under/home.txt")
|
||||
})
|
||||
|
||||
test("should not match when preceded by backtick", () => {
|
||||
const backtickTest = "This `@should/not/match` should be ignored"
|
||||
const backtickMatches = ConfigMarkdown.files(backtickTest)
|
||||
expect(backtickMatches.length).toBe(0)
|
||||
})
|
||||
|
||||
test("should not match email addresses", () => {
|
||||
const emailTest = "Contact user@example.com for help"
|
||||
const emailMatches = ConfigMarkdown.files(emailTest)
|
||||
expect(emailMatches.length).toBe(0)
|
||||
test("should parse without throwing", () => {
|
||||
expect(result).toBeDefined()
|
||||
expect(result.data).toEqual({})
|
||||
expect(result.content.trim()).toBe("Content")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { formatDuration } from "../../src/util/format"
|
||||
|
||||
describe("util.format", () => {
|
||||
describe("formatDuration", () => {
|
||||
test("returns empty string for zero or negative values", () => {
|
||||
expect(formatDuration(0)).toBe("")
|
||||
expect(formatDuration(-1)).toBe("")
|
||||
expect(formatDuration(-100)).toBe("")
|
||||
})
|
||||
|
||||
test("formats seconds under a minute", () => {
|
||||
expect(formatDuration(1)).toBe("1s")
|
||||
expect(formatDuration(30)).toBe("30s")
|
||||
expect(formatDuration(59)).toBe("59s")
|
||||
})
|
||||
|
||||
test("formats minutes under an hour", () => {
|
||||
expect(formatDuration(60)).toBe("1m")
|
||||
expect(formatDuration(61)).toBe("1m 1s")
|
||||
expect(formatDuration(90)).toBe("1m 30s")
|
||||
expect(formatDuration(120)).toBe("2m")
|
||||
expect(formatDuration(330)).toBe("5m 30s")
|
||||
expect(formatDuration(3599)).toBe("59m 59s")
|
||||
})
|
||||
|
||||
test("formats hours under a day", () => {
|
||||
expect(formatDuration(3600)).toBe("1h")
|
||||
expect(formatDuration(3660)).toBe("1h 1m")
|
||||
expect(formatDuration(7200)).toBe("2h")
|
||||
expect(formatDuration(8100)).toBe("2h 15m")
|
||||
expect(formatDuration(86399)).toBe("23h 59m")
|
||||
})
|
||||
|
||||
test("formats days under a week", () => {
|
||||
expect(formatDuration(86400)).toBe("~1 day")
|
||||
expect(formatDuration(172800)).toBe("~2 days")
|
||||
expect(formatDuration(259200)).toBe("~3 days")
|
||||
expect(formatDuration(604799)).toBe("~6 days")
|
||||
})
|
||||
|
||||
test("formats weeks", () => {
|
||||
expect(formatDuration(604800)).toBe("~1 week")
|
||||
expect(formatDuration(1209600)).toBe("~2 weeks")
|
||||
expect(formatDuration(1609200)).toBe("~2 weeks")
|
||||
})
|
||||
|
||||
test("handles boundary values correctly", () => {
|
||||
expect(formatDuration(59)).toBe("59s")
|
||||
expect(formatDuration(60)).toBe("1m")
|
||||
expect(formatDuration(3599)).toBe("59m 59s")
|
||||
expect(formatDuration(3600)).toBe("1h")
|
||||
expect(formatDuration(86399)).toBe("23h 59m")
|
||||
expect(formatDuration(86400)).toBe("~1 day")
|
||||
expect(formatDuration(604799)).toBe("~6 days")
|
||||
expect(formatDuration(604800)).toBe("~1 week")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -48,10 +48,10 @@
|
|||
"@solid-primitives/resize-observer": "2.1.3",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"dompurify": "3.3.1",
|
||||
"fuzzysort": "catalog:",
|
||||
"katex": "0.16.27",
|
||||
"luxon": "catalog:",
|
||||
"dompurify": "catalog:",
|
||||
"marked": "catalog:",
|
||||
"marked-katex-extension": "5.1.6",
|
||||
"marked-shiki": "catalog:",
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@
|
|||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--surface-base);
|
||||
border: 1px solid var(--border-base);
|
||||
border: 1px solid var(--border-weak-base);
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
|
|
@ -416,7 +416,30 @@
|
|||
box-shadow: var(--shadow-xs-border-base);
|
||||
background-color: var(--surface-raised-base);
|
||||
overflow: visible;
|
||||
overflow-anchor: none;
|
||||
|
||||
& > *:first-child {
|
||||
border-top-left-radius: 6px;
|
||||
border-top-right-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
& > *:last-child {
|
||||
border-bottom-left-radius: 6px;
|
||||
border-bottom-right-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-component="collapsible"] {
|
||||
border: none;
|
||||
}
|
||||
|
||||
[data-component="card"] {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-permission="true"] {
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
|
|
@ -438,26 +461,11 @@
|
|||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
}
|
||||
|
||||
& > *:first-child {
|
||||
border-top-left-radius: 6px;
|
||||
border-top-right-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
& > *:last-child {
|
||||
border-bottom-left-radius: 6px;
|
||||
border-bottom-right-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-component="collapsible"] {
|
||||
border: none;
|
||||
}
|
||||
|
||||
[data-component="card"] {
|
||||
border: none;
|
||||
}
|
||||
&[data-question="true"] {
|
||||
background: var(--background-base);
|
||||
border: 1px solid var(--border-weak-base);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
[data-slot="tooltip-keybind"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
[data-slot="tooltip-keybind-key"] {
|
||||
|
|
@ -18,11 +18,11 @@
|
|||
[data-component="tooltip"] {
|
||||
z-index: 1000;
|
||||
max-width: 320px;
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: var(--radius-sm);
|
||||
background-color: var(--surface-float-base);
|
||||
color: var(--text-invert-strong);
|
||||
background: var(--surface-float-base);
|
||||
padding: 6px 12px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--border-weak-base, rgba(0, 0, 0, 0.07));
|
||||
|
||||
box-shadow: var(--shadow-md);
|
||||
|
|
|
|||
Loading…
Reference in New Issue