From de142d22d23ee13ce7ae2e741dff0e493adf33f2 Mon Sep 17 00:00:00 2001 From: sachnun Date: Fri, 12 Dec 2025 09:30:44 +0700 Subject: [PATCH] feat: add rules mode to save instructions to AGENTS.md --- .../cli/cmd/tui/component/prompt/index.tsx | 39 +++++- packages/opencode/src/server/server.ts | 32 +++++ packages/opencode/src/session/prompt.ts | 23 ++++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 39 ++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 128 ++++++++++++------ 5 files changed, 212 insertions(+), 49 deletions(-) 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 669ed189795..86ea7cc50c4 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -203,6 +203,10 @@ export function Prompt(props: PromptProps) { setStore("mode", "normal") return } + if (store.mode === "rules") { + setStore("mode", "normal") + return + } if (!props.sessionID) return setStore("interrupt", store.interrupt + 1) @@ -318,7 +322,7 @@ export function Prompt(props: PromptProps) { const [store, setStore] = createStore<{ prompt: PromptInfo - mode: "normal" | "shell" + mode: "normal" | "shell" | "rules" extmarkToPartIndex: Map interrupt: number placeholder: number @@ -502,6 +506,12 @@ export function Prompt(props: PromptProps) { command: inputText, }) setStore("mode", "normal") + } else if (store.mode === "rules") { + sdk.client.session.rules({ + sessionID, + content: inputText, + }) + setStore("mode", "normal") } else if ( inputText.startsWith("/") && iife(() => { @@ -640,9 +650,15 @@ export function Prompt(props: PromptProps) { const highlight = createMemo(() => { if (keybind.leader) return theme.border if (store.mode === "shell") return theme.primary + if (store.mode === "rules") return theme.success return local.agent.color(local.agent.current().name) }) + const modeLabel = createMemo(() => { + if (store.mode === "shell") return "Shell" + if (store.mode === "rules") return "Rules" + return Locale.titlecase(local.agent.current().name) + }) const spinnerDef = createMemo(() => { const color = local.agent.color(local.agent.current().name) return { @@ -740,6 +756,11 @@ export function Prompt(props: PromptProps) { e.preventDefault() return } + if (e.name === "#" && input.visualCursor.offset === 0) { + setStore("mode", "rules") + e.preventDefault() + return + } if (store.mode === "shell") { if ((e.name === "backspace" && input.visualCursor.offset === 0) || e.name === "escape") { setStore("mode", "normal") @@ -747,6 +768,13 @@ export function Prompt(props: PromptProps) { return } } + if (store.mode === "rules") { + if ((e.name === "backspace" && input.visualCursor.offset === 0) || e.name === "escape") { + setStore("mode", "normal") + e.preventDefault() + return + } + } if (store.mode === "normal") autocomplete.onKeyDown(e) if (!autocomplete.visible) { if ( @@ -845,9 +873,7 @@ export function Prompt(props: PromptProps) { syntaxStyle={syntax()} /> - - {store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "} - + {modeLabel()} @@ -979,6 +1005,11 @@ export function Prompt(props: PromptProps) { esc exit shell mode + + + esc exit rules mode + + diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index ac7077bf788..90f4a29b5dc 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1308,6 +1308,38 @@ export namespace Server { return c.json(msg) }, ) + .post( + "/session/:sessionID/rules", + describeRoute({ + summary: "Save rules", + description: "Append content to project AGENTS.md file.", + operationId: "session.rules", + responses: { + 200: { + description: "Content saved", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: z.string().meta({ description: "Session ID" }), + }), + ), + validator("json", SessionPrompt.RulesInput.omit({ sessionID: true })), + async (c) => { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") + await SessionPrompt.rules({ ...body, sessionID }) + return c.json(true) + }, + ) .post( "/session/:sessionID/revert", describeRoute({ diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 7f1b03c94a8..0b3023c5976 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -25,6 +25,7 @@ import { Bus } from "../bus" import { ProviderTransform } from "../provider/transform" import { SystemPrompt } from "./system" import { Plugin } from "../plugin" +import { TuiEvent } from "@/cli/cmd/tui/event" import PROMPT_PLAN from "../session/prompt/plan.txt" import BUILD_SWITCH from "../session/prompt/build-switch.txt" @@ -1353,6 +1354,28 @@ export namespace SessionPrompt { return { info: msg, parts: [part] } } + export const RulesInput = z.object({ + sessionID: Identifier.schema("session"), + content: z.string(), + }) + export type RulesInput = z.infer + export async function rules(input: RulesInput) { + const agentsPath = path.join(Instance.worktree, "AGENTS.md") + const file = Bun.file(agentsPath) + const exists = await file.exists() + + const content = exists ? await file.text() : "" + const newContent = content === "" ? input.content + "\n" : content.trimEnd() + "\n\n" + input.content + "\n" + + await Bun.write(agentsPath, newContent) + + await Bus.publish(TuiEvent.ToastShow, { + message: "Saved to AGENTS.md", + variant: "success", + duration: 3000, + }) + } + export const CommandInput = z.object({ messageID: Identifier.schema("message").optional(), sessionID: Identifier.schema("session"), diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 90df76c2234..98bb5c6309a 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -99,6 +99,8 @@ import type { SessionPromptResponses, SessionRevertErrors, SessionRevertResponses, + SessionRulesErrors, + SessionRulesResponses, SessionShareErrors, SessionShareResponses, SessionShellErrors, @@ -1416,6 +1418,43 @@ export class Session extends HeyApiClient { }) } + /** + * Save rules + * + * Append content to project AGENTS.md file. + */ + public rules( + parameters: { + sessionID: string + directory?: string + content?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "body", key: "content" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/rules", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * Revert message * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index f8890d9fb70..41263bee9fd 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -511,6 +511,48 @@ export type EventSessionCompacted = { } } +export type EventTuiPromptAppend = { + type: "tui.prompt.append" + properties: { + text: string + } +} + +export type EventTuiCommandExecute = { + type: "tui.command.execute" + properties: { + command: + | "session.list" + | "session.new" + | "session.share" + | "session.interrupt" + | "session.compact" + | "session.page.up" + | "session.page.down" + | "session.half.page.up" + | "session.half.page.down" + | "session.first" + | "session.last" + | "prompt.clear" + | "prompt.submit" + | "agent.cycle" + | string + } +} + +export type EventTuiToastShow = { + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + /** + * Duration in milliseconds + */ + duration?: number + } +} + export type EventFileEdited = { type: "file.edited" properties: { @@ -637,48 +679,6 @@ export type EventVcsBranchUpdated = { } } -export type EventTuiPromptAppend = { - type: "tui.prompt.append" - properties: { - text: string - } -} - -export type EventTuiCommandExecute = { - type: "tui.command.execute" - properties: { - command: - | "session.list" - | "session.new" - | "session.share" - | "session.interrupt" - | "session.compact" - | "session.page.up" - | "session.page.down" - | "session.half.page.up" - | "session.half.page.down" - | "session.first" - | "session.last" - | "prompt.clear" - | "prompt.submit" - | "agent.cycle" - | string - } -} - -export type EventTuiToastShow = { - type: "tui.toast.show" - properties: { - title?: string - message: string - variant: "info" | "success" | "warning" | "error" - /** - * Duration in milliseconds - */ - duration?: number - } -} - export type Pty = { id: string title: string @@ -748,6 +748,9 @@ export type Event = | EventSessionStatus | EventSessionIdle | EventSessionCompacted + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow | EventFileEdited | EventTodoUpdated | EventCommandExecuted @@ -758,9 +761,6 @@ export type Event = | EventSessionError | EventFileWatcherUpdated | EventVcsBranchUpdated - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow | EventPtyCreated | EventPtyUpdated | EventPtyExited @@ -3020,6 +3020,44 @@ export type SessionShellResponses = { export type SessionShellResponse = SessionShellResponses[keyof SessionShellResponses] +export type SessionRulesData = { + body?: { + content: string + } + path: { + /** + * Session ID + */ + sessionID: string + } + query?: { + directory?: string + } + url: "/session/{sessionID}/rules" +} + +export type SessionRulesErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionRulesError = SessionRulesErrors[keyof SessionRulesErrors] + +export type SessionRulesResponses = { + /** + * Content saved + */ + 200: boolean +} + +export type SessionRulesResponse = SessionRulesResponses[keyof SessionRulesResponses] + export type SessionRevertData = { body?: { messageID: string