mirror of
https://github.com/anomalyco/opencode.git
synced 2026-03-12 01:24:42 +00:00
Compare commits
3 Commits
add-dynami
...
release-no
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98ed41332c | ||
|
|
0f26e19d38 | ||
|
|
6c6e81884f |
BIN
packages/app/public/release/release-example.mp4
Executable file
BIN
packages/app/public/release/release-example.mp4
Executable file
Binary file not shown.
BIN
packages/app/public/release/release-share.png
Normal file
BIN
packages/app/public/release/release-share.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
310
packages/app/src/components/dialog-release-notes.tsx
Normal file
310
packages/app/src/components/dialog-release-notes.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
import { createSignal, createEffect, onMount, onCleanup } from "solid-js"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { markReleaseNotesSeen } from "@/lib/release-notes"
|
||||
|
||||
const CHANGELOG_URL = "https://opencode.ai/changelog.json"
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
function getText(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
const text = value.trim()
|
||||
return text.length > 0 ? text : undefined
|
||||
}
|
||||
|
||||
if (!Array.isArray(value)) return
|
||||
const parts = value.map((item) => (typeof item === "string" ? item.trim() : "")).filter((item) => item.length > 0)
|
||||
if (parts.length === 0) return
|
||||
return parts.join(" ")
|
||||
}
|
||||
|
||||
function normalizeRemoteUrl(url: string): string {
|
||||
if (url.startsWith("https://") || url.startsWith("http://")) return url
|
||||
if (url.startsWith("/")) return `https://opencode.ai${url}`
|
||||
return `https://opencode.ai/${url}`
|
||||
}
|
||||
|
||||
function parseMedia(value: unknown): ReleaseFeature["media"] | undefined {
|
||||
if (!isRecord(value)) return
|
||||
|
||||
const type = getText(value.type)?.toLowerCase()
|
||||
const src = getText(value.src)
|
||||
if (!src) return
|
||||
if (type !== "image" && type !== "video") return
|
||||
|
||||
return {
|
||||
type,
|
||||
src: normalizeRemoteUrl(src),
|
||||
alt: getText(value.alt),
|
||||
}
|
||||
}
|
||||
|
||||
function parseFeature(value: unknown): ReleaseFeature | undefined {
|
||||
if (!isRecord(value)) return
|
||||
|
||||
const title = getText(value.title) ?? getText(value.name) ?? getText(value.heading)
|
||||
const description = getText(value.description) ?? getText(value.body) ?? getText(value.text)
|
||||
|
||||
if (!title) return
|
||||
if (!description) return
|
||||
|
||||
const tag = getText(value.tag) ?? getText(value.label) ?? "New"
|
||||
|
||||
const media = (() => {
|
||||
const parsed = parseMedia(value.media)
|
||||
if (parsed) return parsed
|
||||
|
||||
const alt = getText(value.alt)
|
||||
const image = getText(value.image)
|
||||
if (image) return { type: "image" as const, src: normalizeRemoteUrl(image), alt }
|
||||
|
||||
const video = getText(value.video)
|
||||
if (video) return { type: "video" as const, src: normalizeRemoteUrl(video), alt }
|
||||
})()
|
||||
|
||||
return { title, description, tag, media }
|
||||
}
|
||||
|
||||
function parseChangelog(value: unknown): ReleaseNote | undefined {
|
||||
const releases = (() => {
|
||||
if (Array.isArray(value)) return value
|
||||
if (!isRecord(value)) return
|
||||
if (Array.isArray(value.releases)) return value.releases
|
||||
if (Array.isArray(value.versions)) return value.versions
|
||||
if (Array.isArray(value.changelog)) return value.changelog
|
||||
})()
|
||||
|
||||
if (!releases) {
|
||||
if (!isRecord(value)) return
|
||||
if (!Array.isArray(value.highlights)) return
|
||||
const features = value.highlights.map(parseFeature).filter((item): item is ReleaseFeature => item !== undefined)
|
||||
if (features.length === 0) return
|
||||
return { version: CURRENT_RELEASE.version, features: features.slice(0, 3) }
|
||||
}
|
||||
|
||||
const version = (() => {
|
||||
const head = releases[0]
|
||||
if (!isRecord(head)) return
|
||||
return getText(head.version) ?? getText(head.tag_name) ?? getText(head.tag) ?? getText(head.name)
|
||||
})()
|
||||
|
||||
const features = releases
|
||||
.flatMap((item) => {
|
||||
if (!isRecord(item)) return []
|
||||
const highlights = item.highlights
|
||||
if (!Array.isArray(highlights)) return []
|
||||
return highlights.map(parseFeature).filter((feature): feature is ReleaseFeature => feature !== undefined)
|
||||
})
|
||||
.slice(0, 3)
|
||||
|
||||
if (features.length === 0) return
|
||||
return { version: version ?? CURRENT_RELEASE.version, features }
|
||||
}
|
||||
|
||||
export interface ReleaseFeature {
|
||||
title: string
|
||||
description: string
|
||||
tag?: string
|
||||
media?: {
|
||||
type: "image" | "video"
|
||||
src: string
|
||||
alt?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface ReleaseNote {
|
||||
version: string
|
||||
features: ReleaseFeature[]
|
||||
}
|
||||
|
||||
// Current release notes - update this with each release
|
||||
export const CURRENT_RELEASE: ReleaseNote = {
|
||||
version: "1.0.0",
|
||||
features: [
|
||||
{
|
||||
title: "Cleaner tab experience",
|
||||
description: "Chat is now fixed to the side of your tabs, and review is now available as a dedicated tab. ",
|
||||
tag: "New",
|
||||
media: {
|
||||
type: "video",
|
||||
src: "/release/release-example.mp4",
|
||||
alt: "Cleaner tab experience",
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Share with control",
|
||||
description: "Keep your sessions private by default, or publish them to the web with a shareable URL.",
|
||||
tag: "New",
|
||||
media: {
|
||||
type: "image",
|
||||
src: "/release/release-share.png",
|
||||
alt: "Share with control",
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Improved attachment management",
|
||||
description: "Upload and manage attachments more easily, to help build and maintain context.",
|
||||
tag: "New",
|
||||
media: {
|
||||
type: "video",
|
||||
src: "/release/release-example.mp4",
|
||||
alt: "Improved attachment management",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export function DialogReleaseNotes(props: { release?: ReleaseNote }) {
|
||||
const dialog = useDialog()
|
||||
const [note, setNote] = createSignal(props.release ?? CURRENT_RELEASE)
|
||||
const [index, setIndex] = createSignal(0)
|
||||
|
||||
const feature = () => note().features[index()] ?? note().features[0] ?? CURRENT_RELEASE.features[0]!
|
||||
const total = () => note().features.length
|
||||
const isFirst = () => index() === 0
|
||||
const isLast = () => index() === total() - 1
|
||||
|
||||
function handleNext() {
|
||||
if (!isLast()) setIndex(index() + 1)
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
if (!isFirst()) setIndex(index() - 1)
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
markReleaseNotesSeen()
|
||||
dialog.close()
|
||||
}
|
||||
|
||||
let focusTrap: HTMLDivElement | undefined
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "ArrowLeft" && !isFirst()) {
|
||||
e.preventDefault()
|
||||
setIndex(index() - 1)
|
||||
}
|
||||
if (e.key === "ArrowRight" && !isLast()) {
|
||||
e.preventDefault()
|
||||
setIndex(index() + 1)
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
focusTrap?.focus()
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
onCleanup(() => document.removeEventListener("keydown", handleKeyDown))
|
||||
|
||||
const controller = new AbortController()
|
||||
fetch(CHANGELOG_URL, {
|
||||
signal: controller.signal,
|
||||
headers: { Accept: "application/json" },
|
||||
})
|
||||
.then((response) => (response.ok ? (response.json() as Promise<unknown>) : undefined))
|
||||
.then((json) => {
|
||||
if (!json) return
|
||||
const parsed = parseChangelog(json)
|
||||
if (!parsed) return
|
||||
setNote({
|
||||
version: parsed.version,
|
||||
features: parsed.features,
|
||||
})
|
||||
setIndex(0)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
|
||||
onCleanup(() => controller.abort())
|
||||
})
|
||||
|
||||
// Refocus the trap when index changes to ensure escape always works
|
||||
createEffect(() => {
|
||||
index() // track index
|
||||
focusTrap?.focus()
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog class="dialog-release-notes">
|
||||
{/* Hidden element to capture initial focus and handle escape */}
|
||||
<div ref={focusTrap} tabindex="0" class="absolute opacity-0 pointer-events-none" />
|
||||
{/* Left side - Text content */}
|
||||
<div class="flex flex-col flex-1 min-w-0 p-8">
|
||||
{/* Top section - feature content (fixed position from top) */}
|
||||
<div class="flex flex-col gap-2 pt-22">
|
||||
<div class="flex items-center gap-2">
|
||||
<h1 class="text-16-medium text-text-strong">{feature().title}</h1>
|
||||
{feature().tag && (
|
||||
<span
|
||||
class="text-12-medium text-text-weak px-1.5 py-0.5 bg-surface-base rounded-sm border border-border-weak-base"
|
||||
style={{ "border-width": "0.5px" }}
|
||||
>
|
||||
{feature().tag}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p class="text-14-regular text-text-base">{feature().description}</p>
|
||||
</div>
|
||||
|
||||
{/* Spacer to push buttons to bottom */}
|
||||
<div class="flex-1" />
|
||||
|
||||
{/* Bottom section - buttons and indicators (fixed position) */}
|
||||
<div class="flex flex-col gap-12">
|
||||
<div class="flex items-center gap-3">
|
||||
{isLast() ? (
|
||||
<Button variant="primary" size="large" onClick={handleClose}>
|
||||
Get started
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="secondary" size="large" onClick={handleNext}>
|
||||
Next
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{total() > 1 && (
|
||||
<div class="flex items-center gap-1.5 -my-2.5">
|
||||
{note().features.map((_, i) => (
|
||||
<button
|
||||
type="button"
|
||||
class="h-6 flex items-center cursor-pointer bg-transparent border-none p-0 transition-all duration-200"
|
||||
classList={{
|
||||
"w-8": i === index(),
|
||||
"w-3": i !== index(),
|
||||
}}
|
||||
onClick={() => setIndex(i)}
|
||||
>
|
||||
<div
|
||||
class="w-full h-0.5 rounded-[1px] transition-colors duration-200"
|
||||
classList={{
|
||||
"bg-icon-strong-base": i === index(),
|
||||
"bg-icon-weak-base": i !== index(),
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Media content (edge to edge) */}
|
||||
{feature().media && (
|
||||
<div class="flex-1 min-w-0 bg-surface-base overflow-hidden rounded-r-xl">
|
||||
{feature().media!.type === "image" ? (
|
||||
<img
|
||||
src={feature().media!.src}
|
||||
alt={feature().media!.alt ?? "Release preview"}
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<video src={feature().media!.src} autoplay loop muted playsinline class="w-full h-full object-cover" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
31
packages/app/src/components/release-notes-handler.tsx
Normal file
31
packages/app/src/components/release-notes-handler.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { onMount } from "solid-js"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { DialogReleaseNotes } from "./dialog-release-notes"
|
||||
import { shouldShowReleaseNotes, markReleaseNotesSeen } from "@/lib/release-notes"
|
||||
|
||||
/**
|
||||
* Component that handles showing release notes modal on app startup.
|
||||
* Shows the modal if:
|
||||
* - DEV_ALWAYS_SHOW_RELEASE_NOTES is true in lib/release-notes.ts
|
||||
* - OR the user hasn't seen the current version's release notes yet
|
||||
*
|
||||
* To disable the dev mode behavior, set DEV_ALWAYS_SHOW_RELEASE_NOTES to false
|
||||
* in packages/app/src/lib/release-notes.ts
|
||||
*/
|
||||
export function ReleaseNotesHandler() {
|
||||
const dialog = useDialog()
|
||||
|
||||
onMount(() => {
|
||||
// Small delay to ensure app is fully loaded before showing modal
|
||||
setTimeout(() => {
|
||||
if (shouldShowReleaseNotes()) {
|
||||
dialog.show(
|
||||
() => <DialogReleaseNotes />,
|
||||
() => markReleaseNotesSeen(),
|
||||
)
|
||||
}
|
||||
}, 500)
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -55,3 +55,30 @@
|
||||
scrollbar-width: thin !important;
|
||||
scrollbar-color: var(--border-weak-base) transparent !important;
|
||||
}
|
||||
|
||||
/* Wider dialog variant for release notes modal */
|
||||
[data-component="dialog"]:has(.dialog-release-notes) {
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
|
||||
[data-slot="dialog-container"] {
|
||||
width: min(100%, 720px);
|
||||
height: min(100%, 400px);
|
||||
margin-top: -80px;
|
||||
|
||||
[data-slot="dialog-content"] {
|
||||
min-height: auto;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
border: none;
|
||||
box-shadow: var(--shadow-lg-border-base);
|
||||
}
|
||||
|
||||
[data-slot="dialog-body"] {
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
53
packages/app/src/lib/release-notes.ts
Normal file
53
packages/app/src/lib/release-notes.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { CURRENT_RELEASE } from "@/components/dialog-release-notes"
|
||||
|
||||
const STORAGE_KEY = "opencode:last-seen-version"
|
||||
|
||||
// ============================================================================
|
||||
// DEV MODE: Set this to true to always show the release notes modal on startup
|
||||
// Set to false for production behavior (only shows after updates)
|
||||
// ============================================================================
|
||||
const DEV_ALWAYS_SHOW_RELEASE_NOTES = true
|
||||
|
||||
/**
|
||||
* Check if release notes should be shown
|
||||
* Returns true if:
|
||||
* - DEV_ALWAYS_SHOW_RELEASE_NOTES is true (for development)
|
||||
* - OR the current version is newer than the last seen version
|
||||
*/
|
||||
export function shouldShowReleaseNotes(): boolean {
|
||||
if (DEV_ALWAYS_SHOW_RELEASE_NOTES) {
|
||||
console.log("[ReleaseNotes] DEV mode: always showing release notes")
|
||||
return true
|
||||
}
|
||||
|
||||
const lastSeen = localStorage.getItem(STORAGE_KEY)
|
||||
if (!lastSeen) {
|
||||
// First time user - show release notes
|
||||
return true
|
||||
}
|
||||
|
||||
// Compare versions - show if current is newer
|
||||
return CURRENT_RELEASE.version !== lastSeen
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the current release notes as seen
|
||||
* Call this when the user closes the release notes modal
|
||||
*/
|
||||
export function markReleaseNotesSeen(): void {
|
||||
localStorage.setItem(STORAGE_KEY, CURRENT_RELEASE.version)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current version
|
||||
*/
|
||||
export function getCurrentVersion(): string {
|
||||
return CURRENT_RELEASE.version
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the seen status (useful for testing)
|
||||
*/
|
||||
export function resetReleaseNotesSeen(): void {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
@@ -68,6 +68,7 @@ import { ConstrainDragXAxis } from "@/utils/solid-dnd"
|
||||
import { navStart } from "@/utils/perf"
|
||||
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
|
||||
import { DialogEditProject } from "@/components/dialog-edit-project"
|
||||
import { ReleaseNotesHandler } from "@/components/release-notes-handler"
|
||||
import { Titlebar } from "@/components/titlebar"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useLanguage, type Locale } from "@/context/language"
|
||||
@@ -1412,6 +1413,11 @@ export default function Layout(props: ParentProps) {
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48
|
||||
document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const project = currentProject()
|
||||
if (!project) return
|
||||
@@ -2623,6 +2629,7 @@ export default function Layout(props: ParentProps) {
|
||||
</main>
|
||||
</div>
|
||||
<Toast.Region />
|
||||
<ReleaseNotesHandler />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -153,7 +153,6 @@ async function createToolContext(agent: Agent.Info) {
|
||||
callID: Identifier.ascending("part"),
|
||||
agent: agent.name,
|
||||
abort: new AbortController().signal,
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
async ask(req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) {
|
||||
for (const pattern of req.patterns) {
|
||||
|
||||
@@ -1705,29 +1705,10 @@ function Glob(props: ToolProps<typeof GlobTool>) {
|
||||
}
|
||||
|
||||
function Read(props: ToolProps<typeof ReadTool>) {
|
||||
const { theme } = useTheme()
|
||||
const loaded = createMemo(() => {
|
||||
if (props.part.state.status !== "completed") return []
|
||||
if (props.part.state.time.compacted) return []
|
||||
const value = props.metadata.loaded
|
||||
if (!value || !Array.isArray(value)) return []
|
||||
return value.filter((p): p is string => typeof p === "string")
|
||||
})
|
||||
return (
|
||||
<>
|
||||
<InlineTool icon="→" pending="Reading file..." complete={props.input.filePath} part={props.part}>
|
||||
Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])}
|
||||
</InlineTool>
|
||||
<For each={loaded()}>
|
||||
{(filepath) => (
|
||||
<box paddingLeft={3}>
|
||||
<text paddingLeft={3} fg={theme.textMuted}>
|
||||
↳ Loaded {normalizePath(filepath)}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</>
|
||||
<InlineTool icon="→" pending="Reading file..." complete={props.input.filePath} part={props.part}>
|
||||
Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])}
|
||||
</InlineTool>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { Global } from "../global"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Config } from "../config/config"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Log } from "../util/log"
|
||||
import type { MessageV2 } from "./message-v2"
|
||||
|
||||
const log = Log.create({ service: "instruction" })
|
||||
|
||||
const FILES = [
|
||||
"AGENTS.md",
|
||||
"CLAUDE.md",
|
||||
"CONTEXT.md", // deprecated
|
||||
]
|
||||
|
||||
function globalFiles() {
|
||||
const files = [path.join(Global.Path.config, "AGENTS.md")]
|
||||
if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) {
|
||||
files.push(path.join(os.homedir(), ".claude", "CLAUDE.md"))
|
||||
}
|
||||
if (Flag.OPENCODE_CONFIG_DIR) {
|
||||
files.push(path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md"))
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
async function resolveRelative(instruction: string): Promise<string[]> {
|
||||
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
||||
return Filesystem.globUp(instruction, Instance.directory, Instance.worktree).catch(() => [])
|
||||
}
|
||||
if (!Flag.OPENCODE_CONFIG_DIR) {
|
||||
log.warn(
|
||||
`Skipping relative instruction "${instruction}" - no OPENCODE_CONFIG_DIR set while project config is disabled`,
|
||||
)
|
||||
return []
|
||||
}
|
||||
return Filesystem.globUp(instruction, Flag.OPENCODE_CONFIG_DIR, Flag.OPENCODE_CONFIG_DIR).catch(() => [])
|
||||
}
|
||||
|
||||
export namespace InstructionPrompt {
|
||||
export async function systemPaths() {
|
||||
const config = await Config.get()
|
||||
const paths = new Set<string>()
|
||||
|
||||
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
||||
for (const file of FILES) {
|
||||
const matches = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
|
||||
if (matches.length > 0) {
|
||||
matches.forEach((p) => paths.add(path.resolve(p)))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of globalFiles()) {
|
||||
if (await Bun.file(file).exists()) {
|
||||
paths.add(path.resolve(file))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (config.instructions) {
|
||||
for (let instruction of config.instructions) {
|
||||
if (instruction.startsWith("https://") || instruction.startsWith("http://")) continue
|
||||
if (instruction.startsWith("~/")) {
|
||||
instruction = path.join(os.homedir(), instruction.slice(2))
|
||||
}
|
||||
const matches = path.isAbsolute(instruction)
|
||||
? await Array.fromAsync(
|
||||
new Bun.Glob(path.basename(instruction)).scan({
|
||||
cwd: path.dirname(instruction),
|
||||
absolute: true,
|
||||
onlyFiles: true,
|
||||
}),
|
||||
).catch(() => [])
|
||||
: await resolveRelative(instruction)
|
||||
matches.forEach((p) => paths.add(path.resolve(p)))
|
||||
}
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
export async function system() {
|
||||
const config = await Config.get()
|
||||
const paths = await systemPaths()
|
||||
|
||||
const files = Array.from(paths).map(async (p) => {
|
||||
const content = await Bun.file(p)
|
||||
.text()
|
||||
.catch(() => "")
|
||||
return content ? "Instructions from: " + p + "\n" + content : ""
|
||||
})
|
||||
|
||||
const urls: string[] = []
|
||||
if (config.instructions) {
|
||||
for (const instruction of config.instructions) {
|
||||
if (instruction.startsWith("https://") || instruction.startsWith("http://")) {
|
||||
urls.push(instruction)
|
||||
}
|
||||
}
|
||||
}
|
||||
const fetches = urls.map((url) =>
|
||||
fetch(url, { signal: AbortSignal.timeout(5000) })
|
||||
.then((res) => (res.ok ? res.text() : ""))
|
||||
.catch(() => "")
|
||||
.then((x) => (x ? "Instructions from: " + url + "\n" + x : "")),
|
||||
)
|
||||
|
||||
return Promise.all([...files, ...fetches]).then((result) => result.filter(Boolean))
|
||||
}
|
||||
|
||||
export function loaded(messages: MessageV2.WithParts[]) {
|
||||
const paths = new Set<string>()
|
||||
for (const msg of messages) {
|
||||
for (const part of msg.parts) {
|
||||
if (part.type === "tool" && part.tool === "read" && part.state.status === "completed") {
|
||||
if (part.state.time.compacted) continue
|
||||
const loaded = part.state.metadata?.loaded
|
||||
if (!loaded || !Array.isArray(loaded)) continue
|
||||
for (const p of loaded) {
|
||||
if (typeof p === "string") paths.add(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
export async function find(dir: string) {
|
||||
for (const file of FILES) {
|
||||
const filepath = path.resolve(path.join(dir, file))
|
||||
if (await Bun.file(filepath).exists()) return filepath
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolve(messages: MessageV2.WithParts[], filepath: string) {
|
||||
const system = await systemPaths()
|
||||
const already = loaded(messages)
|
||||
const results: { filepath: string; content: string }[] = []
|
||||
|
||||
let current = path.dirname(path.resolve(filepath))
|
||||
const root = path.resolve(Instance.directory)
|
||||
|
||||
while (current.startsWith(root)) {
|
||||
const found = await find(current)
|
||||
if (found && !system.has(found) && !already.has(found)) {
|
||||
const content = await Bun.file(found)
|
||||
.text()
|
||||
.catch(() => undefined)
|
||||
if (content) {
|
||||
results.push({ filepath: found, content: "Instructions from: " + found + "\n" + content })
|
||||
}
|
||||
}
|
||||
if (current === root) break
|
||||
current = path.dirname(current)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
}
|
||||
@@ -631,7 +631,7 @@ export namespace MessageV2 {
|
||||
sessionID: Identifier.schema("session"),
|
||||
messageID: Identifier.schema("message"),
|
||||
}),
|
||||
async (input): Promise<WithParts> => {
|
||||
async (input) => {
|
||||
return {
|
||||
info: await Storage.read<MessageV2.Info>(["message", input.sessionID, input.messageID]),
|
||||
parts: await parts(input.messageID),
|
||||
|
||||
@@ -15,7 +15,6 @@ import { Instance } from "../project/instance"
|
||||
import { Bus } from "../bus"
|
||||
import { ProviderTransform } from "../provider/transform"
|
||||
import { SystemPrompt } from "./system"
|
||||
import { InstructionPrompt } from "./instruction"
|
||||
import { Plugin } from "../plugin"
|
||||
import PROMPT_PLAN from "../session/prompt/plan.txt"
|
||||
import BUILD_SWITCH from "../session/prompt/build-switch.txt"
|
||||
@@ -387,7 +386,6 @@ export namespace SessionPrompt {
|
||||
abort,
|
||||
callID: part.callID,
|
||||
extra: { bypassAgentCheck: true },
|
||||
messages: msgs,
|
||||
async metadata(input) {
|
||||
await Session.updatePart({
|
||||
...part,
|
||||
@@ -563,7 +561,6 @@ export namespace SessionPrompt {
|
||||
tools: lastUser.tools,
|
||||
processor,
|
||||
bypassAgentCheck,
|
||||
messages: msgs,
|
||||
})
|
||||
|
||||
if (step === 1) {
|
||||
@@ -601,7 +598,7 @@ export namespace SessionPrompt {
|
||||
agent,
|
||||
abort,
|
||||
sessionID,
|
||||
system: [...(await SystemPrompt.environment(model)), ...(await InstructionPrompt.system())],
|
||||
system: [...(await SystemPrompt.environment(model)), ...(await SystemPrompt.custom())],
|
||||
messages: [
|
||||
...MessageV2.toModelMessages(sessionMessages, model),
|
||||
...(isLastStep
|
||||
@@ -653,7 +650,6 @@ export namespace SessionPrompt {
|
||||
tools?: Record<string, boolean>
|
||||
processor: SessionProcessor.Info
|
||||
bypassAgentCheck: boolean
|
||||
messages: MessageV2.WithParts[]
|
||||
}) {
|
||||
using _ = log.time("resolveTools")
|
||||
const tools: Record<string, AITool> = {}
|
||||
@@ -665,7 +661,6 @@ export namespace SessionPrompt {
|
||||
callID: options.toolCallId,
|
||||
extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck },
|
||||
agent: input.agent.name,
|
||||
messages: input.messages,
|
||||
metadata: async (val: { title?: string; metadata?: any }) => {
|
||||
const match = input.processor.partFromToolCall(options.toolCallId)
|
||||
if (match && match.state.status === "running") {
|
||||
@@ -1013,7 +1008,6 @@ export namespace SessionPrompt {
|
||||
agent: input.agent!,
|
||||
messageID: info.id,
|
||||
extra: { bypassCwdCheck: true, model },
|
||||
messages: [],
|
||||
metadata: async () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
@@ -1075,7 +1069,6 @@ export namespace SessionPrompt {
|
||||
agent: input.agent!,
|
||||
messageID: info.id,
|
||||
extra: { bypassCwdCheck: true },
|
||||
messages: [],
|
||||
metadata: async () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
@@ -1,14 +1,37 @@
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { Global } from "../global"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Config } from "../config/config"
|
||||
import { Log } from "../util/log"
|
||||
|
||||
import { Instance } from "../project/instance"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
|
||||
import PROMPT_ANTHROPIC from "./prompt/anthropic.txt"
|
||||
import PROMPT_ANTHROPIC_WITHOUT_TODO from "./prompt/qwen.txt"
|
||||
import PROMPT_BEAST from "./prompt/beast.txt"
|
||||
import PROMPT_GEMINI from "./prompt/gemini.txt"
|
||||
import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt"
|
||||
|
||||
import PROMPT_CODEX from "./prompt/codex_header.txt"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { Flag } from "@/flag/flag"
|
||||
|
||||
const log = Log.create({ service: "system-prompt" })
|
||||
|
||||
async function resolveRelativeInstruction(instruction: string): Promise<string[]> {
|
||||
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
||||
return Filesystem.globUp(instruction, Instance.directory, Instance.worktree).catch(() => [])
|
||||
}
|
||||
if (!Flag.OPENCODE_CONFIG_DIR) {
|
||||
log.warn(
|
||||
`Skipping relative instruction "${instruction}" - no OPENCODE_CONFIG_DIR set while project config is disabled`,
|
||||
)
|
||||
return []
|
||||
}
|
||||
return Filesystem.globUp(instruction, Flag.OPENCODE_CONFIG_DIR, Flag.OPENCODE_CONFIG_DIR).catch(() => [])
|
||||
}
|
||||
|
||||
export namespace SystemPrompt {
|
||||
export function instructions() {
|
||||
@@ -49,4 +72,81 @@ export namespace SystemPrompt {
|
||||
].join("\n"),
|
||||
]
|
||||
}
|
||||
|
||||
const LOCAL_RULE_FILES = [
|
||||
"AGENTS.md",
|
||||
"CLAUDE.md",
|
||||
"CONTEXT.md", // deprecated
|
||||
]
|
||||
const GLOBAL_RULE_FILES = [path.join(Global.Path.config, "AGENTS.md")]
|
||||
if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) {
|
||||
GLOBAL_RULE_FILES.push(path.join(os.homedir(), ".claude", "CLAUDE.md"))
|
||||
}
|
||||
|
||||
if (Flag.OPENCODE_CONFIG_DIR) {
|
||||
GLOBAL_RULE_FILES.push(path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md"))
|
||||
}
|
||||
|
||||
export async function custom() {
|
||||
const config = await Config.get()
|
||||
const paths = new Set<string>()
|
||||
|
||||
// Only scan local rule files when project discovery is enabled
|
||||
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
||||
for (const localRuleFile of LOCAL_RULE_FILES) {
|
||||
const matches = await Filesystem.findUp(localRuleFile, Instance.directory, Instance.worktree)
|
||||
if (matches.length > 0) {
|
||||
matches.forEach((path) => paths.add(path))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const globalRuleFile of GLOBAL_RULE_FILES) {
|
||||
if (await Bun.file(globalRuleFile).exists()) {
|
||||
paths.add(globalRuleFile)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const urls: string[] = []
|
||||
if (config.instructions) {
|
||||
for (let instruction of config.instructions) {
|
||||
if (instruction.startsWith("https://") || instruction.startsWith("http://")) {
|
||||
urls.push(instruction)
|
||||
continue
|
||||
}
|
||||
if (instruction.startsWith("~/")) {
|
||||
instruction = path.join(os.homedir(), instruction.slice(2))
|
||||
}
|
||||
let matches: string[] = []
|
||||
if (path.isAbsolute(instruction)) {
|
||||
matches = await Array.fromAsync(
|
||||
new Bun.Glob(path.basename(instruction)).scan({
|
||||
cwd: path.dirname(instruction),
|
||||
absolute: true,
|
||||
onlyFiles: true,
|
||||
}),
|
||||
).catch(() => [])
|
||||
} else {
|
||||
matches = await resolveRelativeInstruction(instruction)
|
||||
}
|
||||
matches.forEach((path) => paths.add(path))
|
||||
}
|
||||
}
|
||||
|
||||
const foundFiles = Array.from(paths).map((p) =>
|
||||
Bun.file(p)
|
||||
.text()
|
||||
.catch(() => "")
|
||||
.then((x) => "Instructions from: " + p + "\n" + x),
|
||||
)
|
||||
const foundUrls = urls.map((url) =>
|
||||
fetch(url, { signal: AbortSignal.timeout(5000) })
|
||||
.then((res) => (res.ok ? res.text() : ""))
|
||||
.catch(() => "")
|
||||
.then((x) => (x ? "Instructions from: " + url + "\n" + x : "")),
|
||||
)
|
||||
return Promise.all([...foundFiles, ...foundUrls]).then((result) => result.filter(Boolean))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import DESCRIPTION from "./read.txt"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Identifier } from "../id/id"
|
||||
import { assertExternalDirectory } from "./external-directory"
|
||||
import { InstructionPrompt } from "../session/instruction"
|
||||
|
||||
const DEFAULT_READ_LIMIT = 2000
|
||||
const MAX_LINE_LENGTH = 2000
|
||||
@@ -60,8 +59,6 @@ export const ReadTool = Tool.define("read", {
|
||||
throw new Error(`File not found: ${filepath}`)
|
||||
}
|
||||
|
||||
const instructions = await InstructionPrompt.resolve(ctx.messages, filepath)
|
||||
|
||||
// Exclude SVG (XML-based) and vnd.fastbidsheet (.fbs extension, commonly FlatBuffers schema files)
|
||||
const isImage =
|
||||
file.type.startsWith("image/") && file.type !== "image/svg+xml" && file.type !== "image/vnd.fastbidsheet"
|
||||
@@ -75,7 +72,6 @@ export const ReadTool = Tool.define("read", {
|
||||
metadata: {
|
||||
preview: msg,
|
||||
truncated: false,
|
||||
...(instructions.length > 0 && { loaded: instructions.map((i) => i.filepath) }),
|
||||
},
|
||||
attachments: [
|
||||
{
|
||||
@@ -137,17 +133,12 @@ export const ReadTool = Tool.define("read", {
|
||||
LSP.touchFile(filepath, false)
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
if (instructions.length > 0) {
|
||||
output += `\n\n<system-reminder>\n${instructions.map((i) => i.content).join("\n\n")}\n</system-reminder>`
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
output,
|
||||
metadata: {
|
||||
preview,
|
||||
truncated,
|
||||
...(instructions.length > 0 && { loaded: instructions.map((i) => i.filepath) }),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -20,7 +20,6 @@ export namespace Tool {
|
||||
abort: AbortSignal
|
||||
callID?: string
|
||||
extra?: { [key: string]: any }
|
||||
messages: MessageV2.WithParts[]
|
||||
metadata(input: { title?: string; metadata?: M }): void
|
||||
ask(input: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">): Promise<void>
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { InstructionPrompt } from "../../src/session/instruction"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
describe("InstructionPrompt.resolve", () => {
|
||||
test("returns empty when AGENTS.md is at project root (already in systemPaths)", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "AGENTS.md"), "# Root Instructions")
|
||||
await Bun.write(path.join(dir, "src", "file.ts"), "const x = 1")
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const system = await InstructionPrompt.systemPaths()
|
||||
expect(system.has(path.join(tmp.path, "AGENTS.md"))).toBe(true)
|
||||
|
||||
const results = await InstructionPrompt.resolve([], path.join(tmp.path, "src", "file.ts"))
|
||||
expect(results).toEqual([])
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("returns AGENTS.md from subdirectory (not in systemPaths)", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions")
|
||||
await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1")
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const system = await InstructionPrompt.systemPaths()
|
||||
expect(system.has(path.join(tmp.path, "subdir", "AGENTS.md"))).toBe(false)
|
||||
|
||||
const results = await InstructionPrompt.resolve([], path.join(tmp.path, "subdir", "nested", "file.ts"))
|
||||
expect(results.length).toBe(1)
|
||||
expect(results[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -11,7 +11,6 @@ const baseCtx = {
|
||||
callID: "",
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ const ctx = {
|
||||
callID: "",
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ const baseCtx: Omit<Tool.Context, "ask"> = {
|
||||
callID: "",
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ const ctx = {
|
||||
callID: "",
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ const ctx = {
|
||||
callID: "test-call",
|
||||
agent: "test-agent",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ const ctx = {
|
||||
callID: "",
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
@@ -331,26 +330,3 @@ root_type Monster;`
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("tool.read loaded instructions", () => {
|
||||
test("loads AGENTS.md from parent directory and includes in metadata", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Test Instructions\nDo something special.")
|
||||
await Bun.write(path.join(dir, "subdir", "nested", "test.txt"), "test content")
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const read = await ReadTool.init()
|
||||
const result = await read.execute({ filePath: path.join(tmp.path, "subdir", "nested", "test.txt") }, ctx)
|
||||
expect(result.output).toContain("test content")
|
||||
expect(result.output).toContain("system-reminder")
|
||||
expect(result.output).toContain("Test Instructions")
|
||||
expect(result.metadata.loaded).toBeDefined()
|
||||
expect(result.metadata.loaded).toContain(path.join(tmp.path, "subdir", "AGENTS.md"))
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -105,6 +105,24 @@
|
||||
color: var(--icon-active);
|
||||
}
|
||||
}
|
||||
|
||||
> [data-component="icon-button"] {
|
||||
background-color: transparent;
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&:focus:not(:disabled),
|
||||
&:active:not(:disabled) {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) [data-slot="icon-svg"] {
|
||||
color: var(--icon-hover);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) [data-slot="icon-svg"] {
|
||||
color: var(--icon-active);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="list-scroll"] {
|
||||
|
||||
@@ -88,7 +88,7 @@ export OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1 # Disable only .claude/skills
|
||||
|
||||
When opencode starts, it looks for rule files in this order:
|
||||
|
||||
1. **Local files** by traversing up from the current directory (`AGENTS.md`, `CLAUDE.md`)
|
||||
1. **Local files** by traversing up from the current directory (`AGENTS.md`, `CLAUDE.md`, or `CONTEXT.md`)
|
||||
2. **Global file** at `~/.config/opencode/AGENTS.md`
|
||||
3. **Claude Code file** at `~/.claude/CLAUDE.md` (unless disabled)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user