Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .opencode/plans/1769458871477-misty-comet.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Plan: Advanced Context Features Implementation

This plan implements dynamic command output injection and YAML frontmatter `paths` support for project instructions, bringing opencode closer to Claude Code features.

## Proposed Changes

### 1. Dynamic Command Injection (`!command`) [COMPLETED]

- Implement a utility to parse and execute lines starting with `!` in rule files.
- Ensure commands go through the `PermissionNext` system for user approval.
- Integrate this into both initial system prompt generation and dynamic rule injection.

### 2. YAML Frontmatter `paths` for Project Instructions [COMPLETED]

- Consolidate instruction loading in `Rules` namespace.
- Support `paths` frontmatter in files specified in `config.instructions`.
- Filter `instructions` rules:
- Rules WITHOUT `paths` go into the initial system prompt.
- Rules WITH `paths` are dynamically injected when a matching file is read into context.

### 3. Code Refactoring [COMPLETED]

- `packages/opencode/src/config/rules.ts`: Add `loadInstructions` and `processInjections` methods.
- `packages/opencode/src/session/rules.ts`: Update `getMatchingRules()` to include scoped rules from `config.instructions`.
- `packages/opencode/src/session/system.ts`: Update `custom()` to use consolidated rule loading and handle `paths` filtering.

- `packages/opencode/src/session/rules.ts`: Update `getMatchingRules()` to include scoped rules from `config.instructions`.

## Critical Files

- `packages/opencode/src/config/rules.ts`
- `packages/opencode/src/session/rules.ts`
- `packages/opencode/src/session/system.ts`
- `packages/opencode/src/config/injection.ts` (New)

## Verification Plan

### Automated Tests

- Create `packages/opencode/test/config/injection.test.ts` to verify `!command` processing.
- Update `packages/opencode/test/config/rules.test.ts` to verify `paths` filtering in `instructions`.

### Manual Verification

1. Create a `.opencode/instructions/test.md` with:

```markdown
---
paths: ["**/test.ts"]
---

!echo "Injected from command"
```

2. Configure `opencode.json` with `"instructions": [".opencode/instructions/*.md"]`.
3. Verify that reading a non-matching file does NOT include the instruction.
4. Verify that reading `test.ts` prompts for `bash` permission for `echo` and includes the output.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"description": "AI-powered development tool",
"private": true,
"type": "module",
"packageManager": "bun@1.3.5",
"packageManager": "bun@^1.3.5",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"typecheck": "bun turbo typecheck",
Expand Down
26 changes: 25 additions & 1 deletion packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,15 @@ import {
RGBA,
} from "@opentui/core"
import { Prompt, type PromptRef } from "@tui/component/prompt"
import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk/v2"
import type {
AssistantMessage,
Part,
ToolPart,
UserMessage,
TextPart,
ReasoningPart,
RulesPart,
} from "@opencode-ai/sdk/v2"
import { useLocal } from "@tui/context/local"
import { Locale } from "@/util/locale"
import type { Tool } from "@/tool/tool"
Expand Down Expand Up @@ -1150,6 +1158,7 @@ function UserMessage(props: {
const local = useLocal()
const text = createMemo(() => props.parts.flatMap((x) => (x.type === "text" && !x.synthetic ? [x] : []))[0])
const files = createMemo(() => props.parts.flatMap((x) => (x.type === "file" ? [x] : [])))
const rules = createMemo(() => props.parts.filter((x) => x.type === "rules") as RulesPart[])
const sync = useSync()
const { theme } = useTheme()
const [hover, setHover] = createSignal(false)
Expand Down Expand Up @@ -1203,6 +1212,7 @@ function UserMessage(props: {
</For>
</box>
</Show>
<For each={rules()}>{(part) => <RulesPart part={part} message={props.message} last={false} />}</For>
<Show
when={queued()}
fallback={
Expand Down Expand Up @@ -1318,6 +1328,20 @@ const PART_MAPPING = {
text: TextPart,
tool: ToolPart,
reasoning: ReasoningPart,
rules: RulesPart,
}

function RulesPart(props: { last: boolean; part: RulesPart; message: UserMessage | AssistantMessage }) {
const { theme } = useTheme()
const paths = () => (props.part.files ?? []).map((f) => normalizePath(f)).join(", ")

return (
<box paddingLeft={3} marginTop={1}>
<text fg={theme.textMuted}>
<span style={{ bold: true }}>✱</span> rules applied ({paths()})
</text>
</box>
)
}

function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) {
Expand Down
37 changes: 37 additions & 0 deletions packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ import { useDirectory } from "../../context/directory"
import { useKV } from "../../context/kv"
import { TodoItem } from "../../component/todo-item"

function normalizePath(input?: string) {
if (!input) return ""
if (path.isAbsolute(input)) {
return path.relative(process.cwd(), input) || "."
}
return input
}

export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const sync = useSync()
const { theme } = useTheme()
Expand All @@ -25,6 +33,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
diff: true,
todo: true,
lsp: true,
rules: true,
})

// Sort MCP servers alphabetically for consistent display order
Expand Down Expand Up @@ -257,6 +266,34 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
</Show>
</box>
</Show>
<Show when={session().rules && session().rules!.length > 0}>
<box>
<box
flexDirection="row"
gap={1}
onMouseDown={() => (session().rules?.length ?? 0) > 2 && setExpanded("rules", !expanded.rules)}
>
<Show when={(session().rules?.length ?? 0) > 2}>
<text fg={theme.text}>{expanded.rules ? "▼" : "▶"}</text>
</Show>
<text fg={theme.text}>
<b>Rules</b>
</text>
</box>
<Show when={(session().rules?.length ?? 0) <= 2 || expanded.rules}>
<For each={session().rules}>
{(item) => (
<text fg={theme.textMuted} wrapMode="none">
•{" "}
{item.startsWith(sync.data.path.directory)
? item.slice(sync.data.path.directory.length).replace(/^\//, "")
: item}
</text>
)}
</For>
</Show>
</box>
</Show>
</box>
</scrollbox>

Expand Down
16 changes: 16 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -888,6 +888,22 @@ export namespace Config {
.record(z.string(), Command)
.optional()
.describe("Command configuration, see https://opencode.ai/docs/commands"),
subdirectoryRules: z
.object({
enabled: z.boolean().optional().default(false),
patterns: z.string().array().optional().describe("List of glob patterns to match rule files"),
exact: z
.boolean()
.optional()
.default(false)
.describe("If true, only load rules from the exact file directory"),
})
.strict()
.meta({
ref: "SubdirectoryRulesConfig",
})
.optional()
.describe("Configuration for subdirectory rule discovery"),
watcher: z
.object({
ignore: z.array(z.string()).optional(),
Expand Down
41 changes: 41 additions & 0 deletions packages/opencode/src/config/injection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { $ } from "bun"
import { PermissionNext } from "../permission/next"
import { Instance } from "../project/instance"

export namespace Injection {
const SHELL_REGEX = /!`([^`]+)`/g

export async function process(content: string, sessionID?: string): Promise<string> {
const matches = Array.from(content.matchAll(SHELL_REGEX))
if (matches.length === 0) return content

let result = content
for (const match of matches) {
const fullMatch = match[0]
const command = match[1]

if (sessionID) {
await PermissionNext.ask({
permission: "bash",
patterns: [command],
always: [],
sessionID,
metadata: {
description: `Executing command for context injection: ${command}`,
},
ruleset: [],
})
}

try {
const output = await $`${{ raw: command }}`.cwd(Instance.directory).quiet().text()
result = result.replace(fullMatch, output.trim())
} catch (error) {
const errorMessage = `Error executing command "${command}": ${error instanceof Error ? error.message : String(error)}`
result = result.replace(fullMatch, errorMessage)
}
}

return result
}
}
Loading