diff --git a/bun.lock b/bun.lock index 3ad292a97a..9fa7717dca 100644 --- a/bun.lock +++ b/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=="], diff --git a/nix/hashes.json b/nix/hashes.json index bb063979de..c89b60ef97 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -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=" } } diff --git a/package.json b/package.json index 9aa069d52c..f1d6c4fead 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 62d29c9e3f..4ba5413dfd 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -54,14 +54,17 @@ export function SessionHeader() { - + +
+ +
+
@@ -1104,7 +1123,7 @@ export default function Layout(props: ParentProps) {
- {title()} + {title()} @@ -1146,9 +1168,12 @@ export default function Layout(props: ParentProps) {
@@ -1191,9 +1216,12 @@ export default function Layout(props: ParentProps) {
@@ -1312,7 +1340,7 @@ export default function Layout(props: ParentProps) { {(p) => ( <>
-
+
{projectName()} @@ -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" /> - + dialog.show(() => )}> - Edit project + Edit - closeProject(p.worktree)}> - Close project - - layout.sidebar.toggleWorkspaces(p.worktree)}> {layout.sidebar.workspaces(p.worktree)() ? "Disable workspaces" : "Enable workspaces"} + + closeProject(p.worktree)}> + Close + diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 484c4a8667..3cfa8a9be5 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -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(); diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 9ad85d08f0..96b9e8ffd5 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -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 } diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts index 569b186d55..d7120aa5e9 100644 --- a/packages/opencode/src/cli/error.ts +++ b/packages/opencode/src/cli/error.ts @@ -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 [ diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 06803879f3..322ce273ab 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -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"), diff --git a/packages/opencode/src/config/markdown.ts b/packages/opencode/src/config/markdown.ts index f20842c41a..d1eeeac382 100644 --- a/packages/opencode/src/config/markdown.ts +++ b/packages/opencode/src/config/markdown.ts @@ -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 }, ) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 35fdd4717b..72201636b7 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -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(x))) + const projects = await Promise.all(keys.map((x) => Storage.read(x))) + return projects.map((project) => ({ + ...project, + sandboxes: project.sandboxes?.filter((x) => existsSync(x)), + })) } export const update = fn( diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 5aedce505c..c5465f9880 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -81,7 +81,11 @@ export namespace ModelsDev { const file = Bun.file(filepath) const result = await file.json().catch(() => {}) if (result) return result as Record - const json = await data() + if (typeof data === "function") { + const json = await data() + return JSON.parse(json) as Record + } + const json = await fetch("https://models.dev/api.json").then((x) => x.text()) return JSON.parse(json) as Record } diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index 1cc3afee92..6ae0e9fe88 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -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 = {} 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 diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index ad62621e07..097dedf4aa 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -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: { diff --git a/packages/opencode/src/util/format.ts b/packages/opencode/src/util/format.ts new file mode 100644 index 0000000000..4ae62eac45 --- /dev/null +++ b/packages/opencode/src/util/format.ts @@ -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` +} diff --git a/packages/opencode/test/config/fixtures/empty-frontmatter.md b/packages/opencode/test/config/fixtures/empty-frontmatter.md new file mode 100644 index 0000000000..95d5a80ed1 --- /dev/null +++ b/packages/opencode/test/config/fixtures/empty-frontmatter.md @@ -0,0 +1,4 @@ +--- +--- + +Content diff --git a/packages/opencode/test/config/fixtures/frontmatter.md b/packages/opencode/test/config/fixtures/frontmatter.md new file mode 100644 index 0000000000..27822d6218 --- /dev/null +++ b/packages/opencode/test/config/fixtures/frontmatter.md @@ -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. diff --git a/packages/opencode/test/config/fixtures/no-frontmatter.md b/packages/opencode/test/config/fixtures/no-frontmatter.md new file mode 100644 index 0000000000..39c9f3681a --- /dev/null +++ b/packages/opencode/test/config/fixtures/no-frontmatter.md @@ -0,0 +1 @@ +Content diff --git a/packages/opencode/test/config/markdown.test.ts b/packages/opencode/test/config/markdown.test.ts index 90a997d1f9..b4263ee6b5 100644 --- a/packages/opencode/test/config/markdown.test.ts +++ b/packages/opencode/test/config/markdown.test.ts @@ -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") + }) }) diff --git a/packages/opencode/test/util/format.test.ts b/packages/opencode/test/util/format.test.ts new file mode 100644 index 0000000000..5b346e7f6b --- /dev/null +++ b/packages/opencode/test/util/format.test.ts @@ -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") + }) + }) +}) diff --git a/packages/ui/package.json b/packages/ui/package.json index 143945e408..b2d7a414ec 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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:", diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 71d33de318..4a249ec4f4 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -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); } } diff --git a/packages/ui/src/components/tooltip.css b/packages/ui/src/components/tooltip.css index 91096e211a..30da72f6d6 100644 --- a/packages/ui/src/components/tooltip.css +++ b/packages/ui/src/components/tooltip.css @@ -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);