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 941b383e649..9f3a2de4511 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 { @@ -744,6 +760,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") @@ -751,6 +772,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 ( @@ -849,9 +877,7 @@ export function Prompt(props: PromptProps) { syntaxStyle={syntax()} /> - - {store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "} - + {modeLabel()} @@ -983,6 +1009,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 f1485ec0150..62d0f8268c8 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1310,6 +1310,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 c9e24f8ca04..fdcf989e150 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" @@ -1389,6 +1390,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 9d0bbcc92cd..0f7fc3f0aaa 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