Skip to content

Commit 5802ad5

Browse files
committed
feat(tui): list instruction files in the sidebar
Replace the /context command with a sidebar section backed by a custom.instructions plugin.
1 parent 72eb359 commit 5802ad5

7 files changed

Lines changed: 180 additions & 1 deletion

File tree

‎PATCH.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,10 @@ Skills are mentioned and autocompleted as `/skill-id` (leading `/` menu in the T
109109

110110
Clicking a markdown link to a directory (trailing `/`, e.g. `[core](packages/core/)`) copies its absolute path and runs `smerge` on it; a missing path toasts an error. `make tests` runs the fork tests.
111111

112+
## Instruction Sidebar
113+
114+
Plugin (`custom.instructions` + TUI `custom.sidebar-instructions`): the sidebar lists ambient and session-injected instruction files for the current session. Two or fewer stay expanded; more collapse behind a click toggle.
115+
112116
## Instruction Filenames
113117

114118
Auto-loaded instruction filenames are `InstructionDiscovery.filenames` (`AGENTS.md`, `CLAUDE.local.md`, `AGENTS.local.md`, `PATCH.md`), shared by the startup walk and the read-time walk.

‎packages/core/src/plugin/custom/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export * as CustomPlugins from "./index.js"
22

33
import { CustomAutoLoadSkillsPlugin } from "./auto-load-skills.js"
4+
import { CustomInstructionsPlugin } from "./instructions.js"
45
import { CustomRetitlePlugin } from "./retitle.js"
56
import { CustomTaskResumePlugin } from "./task-resume.js"
67
import { CustomTimeoutsPlugin } from "./timeouts.js"
@@ -10,4 +11,5 @@ export const Plugins = [
1011
CustomTaskResumePlugin.Plugin,
1112
CustomRetitlePlugin.Plugin,
1213
CustomTimeoutsPlugin.Plugin,
14+
CustomInstructionsPlugin.Plugin,
1315
] as const
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
export * as CustomInstructionsPlugin from "./instructions.js"
2+
3+
import { relative } from "path"
4+
import { define } from "@opencode/plugin/effect/plugin"
5+
import { CustomInstructions } from "@opencode/schema/custom-instructions"
6+
import { FSUtil } from "@opencode/util/fs-util"
7+
import { Effect, Option, Schema } from "effect"
8+
import { InstructionDiscovery } from "../../instruction-discovery.js"
9+
import { Instructions } from "../../instructions/index.js"
10+
import { Location } from "../../location.js"
11+
import { SessionMessage } from "../../session/message.js"
12+
13+
// Backs the TUI sidebar's instruction file list. Calls route to the session's Location, so
14+
// ambient discovery is read from the same Location the session runs in.
15+
export const Plugin = define({
16+
id: "custom.instructions",
17+
effect: Effect.fn("CustomInstructionsPlugin")(function* (ctx) {
18+
const discovery = yield* InstructionDiscovery.Service
19+
const location = yield* Location.Service
20+
21+
yield* ctx.rpc
22+
.register(CustomInstructions.Contract, {
23+
list: (input) =>
24+
Effect.gen(function* () {
25+
const loaded = yield* discovery.list()
26+
const history = yield* ctx.session.context({ sessionID: input.sessionID })
27+
return instructionFiles(location.project.directory, loaded, history)
28+
}).pipe(Effect.orDie),
29+
})
30+
.pipe(Effect.orDie)
31+
}),
32+
})
33+
34+
const InjectedPaths = Schema.Struct({
35+
instruction: Schema.Struct({ paths: Schema.Array(Schema.String) }),
36+
})
37+
38+
export function instructionFiles(
39+
root: string,
40+
loaded: ReadonlyArray<InstructionDiscovery.File> | Instructions.Unavailable,
41+
history: ReadonlyArray<SessionMessage.Info>,
42+
) {
43+
const nested = history.flatMap((message) => {
44+
if (message.type !== "synthetic") return []
45+
return Option.getOrUndefined(Schema.decodeUnknownOption(InjectedPaths)(message.metadata))?.instruction.paths ?? []
46+
})
47+
const ambient = Array.isArray(loaded) ? loaded.map((file) => file.path) : []
48+
return {
49+
unavailable: !Array.isArray(loaded),
50+
files: [...new Set([...ambient, ...nested])].map((file) => (FSUtil.contains(root, file) ? relative(root, file) : file)),
51+
}
52+
}

‎packages/core/test/plugin/custom.test.ts‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
import { expect } from "bun:test"
22
import { Agent } from "@opencode/core/agent"
33
import { Plugin } from "@opencode/core/plugin"
4+
import { InstructionDiscovery } from "@opencode/core/instruction-discovery"
5+
import { Instructions } from "@opencode/core/instructions/index"
46
import { CustomAutoLoadSkillsPlugin } from "@opencode/core/plugin/custom/auto-load-skills"
7+
import { CustomInstructionsPlugin } from "@opencode/core/plugin/custom/instructions"
58
import { CustomTaskResumePlugin } from "@opencode/core/plugin/custom/task-resume"
69
import { CustomTimeoutsPlugin } from "@opencode/core/plugin/custom/timeouts"
710
import { PluginHooks } from "@opencode/core/plugin/hooks"
811
import { PluginHost } from "@opencode/core/plugin/host"
12+
import { AbsolutePath } from "@opencode/core/schema"
913
import { Session } from "@opencode/core/session"
1014
import { Tool } from "@opencode/schema/tool"
1115
import { SessionMessage } from "@opencode/schema/session-message"
12-
import { Effect } from "effect"
16+
import { DateTime, Effect } from "effect"
1317
import { testEffect } from "../lib/effect"
1418
import { PluginTestLayer } from "./fixture"
1519

@@ -60,6 +64,31 @@ it.effect("disables the default shell timeout until a session opts in", () =>
6064
}),
6165
)
6266

67+
it.effect("collects instruction files without duplicates and flags unavailable discovery", () =>
68+
Effect.sync(() => {
69+
const project = AbsolutePath.make("/repo")
70+
const history = [
71+
SessionMessage.Synthetic.make({
72+
id: SessionMessage.ID.make("msg_nested"),
73+
type: "synthetic",
74+
text: "nested",
75+
metadata: { instruction: { paths: [`${project}/AGENTS.md`, `${project}/packages/web/AGENTS.md`] } },
76+
time: { created: DateTime.makeUnsafe(0) },
77+
}),
78+
]
79+
const ambient = [new InstructionDiscovery.File({ path: AbsolutePath.make(`${project}/AGENTS.md`), content: "root" })]
80+
expect(CustomInstructionsPlugin.instructionFiles(project, ambient, history)).toEqual({
81+
unavailable: false,
82+
files: ["AGENTS.md", "packages/web/AGENTS.md"],
83+
})
84+
expect(CustomInstructionsPlugin.instructionFiles(project, Instructions.unavailable, history)).toEqual({
85+
unavailable: true,
86+
files: ["AGENTS.md", "packages/web/AGENTS.md"],
87+
})
88+
expect(CustomInstructionsPlugin.instructionFiles(project, [], [])).toEqual({ unavailable: false, files: [] })
89+
}),
90+
)
91+
6392
it.effect("parses the auto-load skill list", () =>
6493
Effect.sync(() => {
6594
expect(CustomAutoLoadSkillsPlugin.parse(" Foo, bar ,,")).toEqual(["foo", "bar"])
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
export * as CustomInstructions from "./custom-instructions.js"
2+
3+
import { Schema } from "effect"
4+
import { Rpc } from "./rpc.js"
5+
import { Session } from "./session.js"
6+
7+
const Input = Schema.toStandardSchemaV1(Schema.Struct({ sessionID: Session.ID }))
8+
const Output = Schema.toStandardSchemaV1(
9+
Schema.Struct({ files: Schema.Array(Schema.String), unavailable: Schema.Boolean }),
10+
)
11+
12+
export const Contract = Rpc.define({
13+
id: "custom-instructions",
14+
methods: {
15+
list: { input: Input, output: Output },
16+
},
17+
events: {},
18+
})
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { Plugin } from "@opencode/plugin/tui"
2+
import { CustomInstructions } from "@opencode/schema/custom-instructions"
3+
import { createMemo, createResource, For, Show } from "solid-js"
4+
5+
export function SidebarInstructions(props: { context: Plugin.Context; sessionID: string }) {
6+
const [view, updateView] = props.context.storage.store("view", { initial: { open: true } })
7+
const theme = props.context.theme
8+
const session = createMemo(() => props.context.data.session.get(props.sessionID))
9+
const directory = createMemo(() => (session()?.location ?? props.context.location)?.directory)
10+
// Edited instructions and nested loads both land in the transcript as system or synthetic messages.
11+
const revision = createMemo(
12+
() =>
13+
(props.context.data.session.message.list(props.sessionID) ?? []).filter(
14+
(message) => message.type === "synthetic" || message.type === "system",
15+
).length,
16+
)
17+
const [result] = createResource(
18+
() => ({ sessionID: props.sessionID, directory: directory(), revision: revision() }),
19+
(source) =>
20+
props.context.client
21+
.rpc(CustomInstructions.Contract)
22+
.list({ sessionID: source.sessionID }, { location: { directory: source.directory } })
23+
.catch(() => undefined),
24+
)
25+
const files = createMemo(() => result()?.files ?? [])
26+
27+
return (
28+
<Show when={files().length > 0}>
29+
<box>
30+
<box
31+
flexDirection="row"
32+
gap={1}
33+
onMouseDown={() => {
34+
if (files().length <= 2) return
35+
void updateView((draft) => {
36+
draft.open = !draft.open
37+
}).catch((error) => console.error("Failed to persist instructions sidebar state", error))
38+
}}
39+
>
40+
<Show when={files().length > 2}>
41+
<text fg={theme.text.base}>{view.open ? "▼" : "▶"}</text>
42+
</Show>
43+
<text fg={theme.text.base}>
44+
<b>Instructions</b>
45+
<Show when={files().length > 2 && !view.open}>
46+
<span style={{ fg: theme.text.muted }}> ({files().length})</span>
47+
</Show>
48+
</text>
49+
</box>
50+
<Show when={files().length <= 2 || view.open}>
51+
<For each={files()}>
52+
{(file) => (
53+
<text fg={theme.text.muted} wrapMode="none" truncate>
54+
{file}
55+
</text>
56+
)}
57+
</For>
58+
</Show>
59+
</box>
60+
</Show>
61+
)
62+
}
63+
64+
export default Plugin.define({
65+
id: "custom.sidebar.instructions",
66+
setup(context) {
67+
context.ui.slot({
68+
append: "sidebar.content",
69+
render: (props) => <SidebarInstructions context={context} sessionID={props.sessionID} />,
70+
})
71+
},
72+
})

‎packages/tui/src/plugin/builtins.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import Notifications from "../feature-plugins/system/notifications"
99
import Plugins from "../feature-plugins/system/plugins"
1010
import Storybook from "../feature-plugins/system/storybook"
1111
import Stats from "../feature-plugins/system/stats"
12+
import CustomSidebarInstructions from "../feature-plugins/custom/sidebar-instructions"
1213
import CustomTimeouts from "../feature-plugins/custom/timeouts"
1314
import Latex from "@opencode/latex/plugin"
1415
import Merman from "@opencode/merman/plugin"
@@ -19,6 +20,7 @@ export const builtins = [
1920
PromptBtw,
2021
SidebarContext,
2122
SidebarMcp,
23+
CustomSidebarInstructions,
2224
SidebarFooter,
2325
Notifications,
2426
Plugins,

0 commit comments

Comments
 (0)