Skip to content
Merged
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
82 changes: 30 additions & 52 deletions bun.lock

Large diffs are not rendered by default.

20 changes: 15 additions & 5 deletions dist/server.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -1194,11 +1194,21 @@ async function setupV2(context) {
};
if (registerCommand) {
registrations.push(await context.command.transform((draft) => {
if (draft.get(commandName))
return;
draft.update(commandName, (command) => {
command.description = "Run an instruction on a recurring interval while this session is idle";
command.template = loopCommandTemplate(commandName, minIntervalSeconds);
draft.add({
name: commandName,
description: "Run an instruction on a recurring interval while this session is idle",
execute: async (input) => {
const stripMention = ({ mention: _mention, ...attachment }) => attachment;
await context.session.prompt({
...input.prompt,
files: input.prompt.files?.map(stripMention),
agents: input.prompt.agents?.map(stripMention),
skills: input.prompt.skills?.map(stripMention),
sessionID: input.sessionID,
text: loopCommandTemplate(commandName, minIntervalSeconds).replaceAll("$ARGUMENTS", () => input.prompt.text.trim()),
delivery: input.delivery
});
}
});
}));
}
Expand Down
8 changes: 4 additions & 4 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -54,16 +54,16 @@
},
"dependencies": {
"@opencode-ai/plugin": "^1.17.1",
"@opentui/solid": "^0.4.0",
"@opentui/solid": ">=0.4.0 <0.6.0",
"effect": "^3.21.2",
"solid-js": "1.9.12",
"zod": "4.1.8"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@opencode-ai/plugin-v2": "npm:@opencode-ai/plugin@0.0.0-next-17055",
"@opencode-ai/theme": "0.0.0-next-17055",
"@opentui/core": "^0.4.0",
"@opencode-ai/plugin-v2": "npm:@opencode-ai/plugin@0.0.0-beta-18593",
"@opencode-ai/theme": "0.0.0-beta-18593",
"@opentui/core": "0.5.9",
"@types/bun": "^1.3.13",
"eslint": "^10.3.0",
"typescript": "^6.0.3",
Expand Down
22 changes: 18 additions & 4 deletions src/server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -771,10 +771,24 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise<PluginV2.Plugi
if (registerCommand) {
registrations.push(
await context.command.transform((draft) => {
if (draft.get(commandName)) return
draft.update(commandName, (command) => {
command.description = "Run an instruction on a recurring interval while this session is idle"
command.template = loopCommandTemplate(commandName, minIntervalSeconds)
draft.add({
name: commandName,
description: "Run an instruction on a recurring interval while this session is idle",
execute: async (input) => {
const stripMention = <T extends { mention?: unknown }>({ mention: _mention, ...attachment }: T) => attachment
await context.session.prompt({
...input.prompt,
files: input.prompt.files?.map(stripMention),
agents: input.prompt.agents?.map(stripMention),
skills: input.prompt.skills?.map(stripMention),
sessionID: input.sessionID,
text: loopCommandTemplate(commandName, minIntervalSeconds).replaceAll(
"$ARGUMENTS",
() => input.prompt.text.trim(),
),
delivery: input.delivery,
})
},
})
}),
)
Expand Down
7 changes: 5 additions & 2 deletions src/tui.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -493,8 +493,11 @@ function LoopKeymapLayerV2(api: TuiPluginV2.Context) {
}

export function setupTuiV2(context: TuiPluginV2.Context): TuiPluginV2.Cleanup {
const offSidebar = context.ui.slot("sidebar.content", (props) => LoopSidebarV2(context, props.sessionID))
const offApp = context.ui.slot("app", () => LoopKeymapLayerV2(context))
const offSidebar = context.ui.slot({
append: "sidebar.content",
render: (props) => LoopSidebarV2(context, props.sessionID),
})
const offApp = context.ui.slot({ append: "app", render: () => LoopKeymapLayerV2(context) })
return () => {
offSidebar()
offApp()
Expand Down
79 changes: 59 additions & 20 deletions test/server-v2.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,9 +26,24 @@ type ToolDraft = {
}): void
}

type MockMention = { start: number; end: number; text: string }
type MockPrompt = {
text: string
files?: Array<{ uri: string; mention?: MockMention }>
agents?: Array<{ name: string; mention?: MockMention }>
skills?: Array<{ id: string; mention?: MockMention }>
}

type MockCommandDraft = {
get(name: string): { name: string; template: string } | undefined
update(name: string, update: (command: { description?: string; template: string }) => void): void
add(command: {
name: string
description?: string
execute: (input: {
sessionID: string
prompt: MockPrompt
delivery: "steer" | "queue"
}) => Promise<void>
}): void
}

type Registration = { dispose: () => Promise<void> }
Expand DownExpand Up@@ -66,9 +81,12 @@ function controlledStream() {
type MockContext = {
options: Record<string, unknown>
activeSessions: Set<string>
promptCalls: Array<{ sessionID: string; text: string; agents?: Array<{ name: string }> }>
promptCalls: Array<{
sessionID: string
delivery?: "steer" | "queue"
} & MockPrompt>
tools: Array<ToolDraft["add"] extends (tool: infer T) => void ? T : never>
commandDraft: MockCommandDraft
commands: Array<MockCommandDraft["add"] extends (command: infer T) => void ? T : never>
hooks: Record<string, (input: unknown) => void>
stream: ReturnType<typeof controlledStream>
disposals: string[]
Expand All@@ -81,7 +99,10 @@ type MockContext = {
session: {
hook: (name: string, callback: (input: unknown) => void) => Promise<Registration>
active: () => Promise<Record<string, { type: "running" }>>
prompt: (input: { sessionID: string; text: string; agents?: Array<{ name: string }> }) => Promise<unknown>
prompt: (input: {
sessionID: string
delivery?: "steer" | "queue"
} & MockPrompt) => Promise<unknown>
}
event: {
subscribe: (options?: { signal?: AbortSignal }) => AsyncIterable<unknown>
Expand All@@ -90,19 +111,12 @@ type MockContext = {

function makeMockContext(options: Record<string, unknown> = {}): MockContext {
const tools: MockContext["tools"] = []
const commands: MockContext["commands"] = []
const hooks: MockContext["hooks"] = {}
const activeSessions = new Set<string>()
const promptCalls: MockContext["promptCalls"] = []
const disposals: string[] = []
const stream = controlledStream()
const commandDraft: MockCommandDraft = {
get: () => undefined,
update: (name, update) => {
const command = { name, template: "" }
update(command)
commandDraft.get = () => command
},
}
const registration = (name: string): Registration => ({
dispose: async () => {
disposals.push(name)
Expand All@@ -113,13 +127,13 @@ function makeMockContext(options: Record<string, unknown> = {}): MockContext {
activeSessions,
promptCalls,
tools,
commandDraft,
commands,
hooks,
stream,
disposals,
command: {
transform: async (callback) => {
callback(commandDraft)
callback({ add: (command) => commands.push(command) })
return registration("command.transform")
},
},
Expand DownExpand Up@@ -228,11 +242,36 @@ test("V2 setup registers the /loop command via command transform", async () => {
const mock = makeMockContext()
const cleanup = await plugin.setup(mock as never)

const command = mock.commandDraft.get("loop")
const command = mock.commands.find((candidate) => candidate.name === "loop")
expect(command).toBeDefined()
expect(command?.template).toContain('OpenCode loop mode command "/loop" was invoked')
expect(command?.template).toContain("$ARGUMENTS")
expect(command?.template).toContain("create_loop")
expect(command?.description).toBe("Run an instruction on a recurring interval while this session is idle")

await command?.execute({
sessionID: "ses_command",
prompt: {
text: "5m check $& and $ARGUMENTS",
files: [{ uri: "file:///tmp/context.txt", mention: { start: 9, end: 11, text: "$&" } }],
agents: [{ name: "build", mention: { start: 9, end: 11, text: "$&" } }],
skills: [{ id: "review", mention: { start: 9, end: 11, text: "$&" } }],
},
delivery: "queue",
})
expect(mock.promptCalls).toHaveLength(1)
expect(mock.promptCalls[0]).toMatchObject({
sessionID: "ses_command",
delivery: "queue",
})
expect(mock.promptCalls[0]?.files).toEqual([{ uri: "file:///tmp/context.txt" }])
expect(mock.promptCalls[0]?.agents).toEqual([{ name: "build" }])
expect(mock.promptCalls[0]?.skills).toEqual([{ id: "review" }])
expect(mock.promptCalls[0]?.text).toContain('OpenCode loop mode command "/loop" was invoked')
expect(mock.promptCalls[0]?.text).toContain("5m check $& and $ARGUMENTS")
expect(mock.promptCalls[0]?.text).toContain("create_loop")
expect(mock.promptCalls[0]?.text.match(/\$ARGUMENTS/g)).toHaveLength(1)

await command?.execute({ sessionID: "ses_empty", prompt: { text: "" }, delivery: "steer" })
expect(mock.promptCalls[1]).toMatchObject({ sessionID: "ses_empty", delivery: "steer" })
expect(mock.promptCalls[1]?.text).toContain('If the arguments are empty, "list", or "status", call list_loops')

mock.stream.end()
await cleanup()
Expand All@@ -242,7 +281,7 @@ test("V2 setup skips command registration when register_command is false", async
const mock = makeMockContext({ register_command: false })
const cleanup = await plugin.setup(mock as never)

expect(mock.commandDraft.get("loop")).toBeUndefined()
expect(mock.commands).toHaveLength(0)
expect(mock.disposals).not.toContain("command.transform")

mock.stream.end()
Expand Down
9 changes: 6 additions & 3 deletions test/tui-v2.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -111,9 +111,12 @@ function mockContext() {
current: () => route,
},
tabs: undefined,
slot(name: string, render: (props: { sessionID: string }) => unknown) {
slots.set(name, render)
return () => disposed.push(name)
slot(claim: {
append: string
render: (props: { sessionID: string }) => unknown
}) {
slots.set(claim.append, claim.render)
return () => disposed.push(claim.append)
},
},
}
Expand Down