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
39 changes: 35 additions & 4 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -318,7 +322,7 @@ export function Prompt(props: PromptProps) {

const [store, setStore] = createStore<{
prompt: PromptInfo
mode: "normal" | "shell"
mode: "normal" | "shell" | "rules"
extmarkToPartIndex: Map<number, number>
interrupt: number
placeholder: number
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -744,13 +760,25 @@ 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")
e.preventDefault()
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 (
Expand Down Expand Up @@ -849,9 +877,7 @@ export function Prompt(props: PromptProps) {
syntaxStyle={syntax()}
/>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1}>
<text fg={highlight()}>
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
</text>
<text fg={highlight()}>{modeLabel()} </text>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
Expand Down Expand Up @@ -983,6 +1009,11 @@ export function Prompt(props: PromptProps) {
esc <span style={{ fg: theme.textMuted }}>exit shell mode</span>
</text>
</Match>
<Match when={store.mode === "rules"}>
<text fg={theme.text}>
esc <span style={{ fg: theme.textMuted }}>exit rules mode</span>
</text>
</Match>
</Switch>
</box>
</Show>
Expand Down
32 changes: 32 additions & 0 deletions packages/opencode/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
23 changes: 23 additions & 0 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<typeof RulesInput>
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"),
Expand Down
39 changes: 39 additions & 0 deletions packages/sdk/js/src/v2/gen/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ import type {
SessionPromptResponses,
SessionRevertErrors,
SessionRevertResponses,
SessionRulesErrors,
SessionRulesResponses,
SessionShareErrors,
SessionShareResponses,
SessionShellErrors,
Expand Down Expand Up @@ -1416,6 +1418,43 @@ export class Session extends HeyApiClient {
})
}

/**
* Save rules
*
* Append content to project AGENTS.md file.
*/
public rules<ThrowOnError extends boolean = false>(
parameters: {
sessionID: string
directory?: string
content?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "sessionID" },
{ in: "query", key: "directory" },
{ in: "body", key: "content" },
],
},
],
)
return (options?.client ?? this.client).post<SessionRulesResponses, SessionRulesErrors, ThrowOnError>({
url: "/session/{sessionID}/rules",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}

/**
* Revert message
*
Expand Down
128 changes: 83 additions & 45 deletions packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -748,6 +748,9 @@ export type Event =
| EventSessionStatus
| EventSessionIdle
| EventSessionCompacted
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow
| EventFileEdited
| EventTodoUpdated
| EventCommandExecuted
Expand All @@ -758,9 +761,6 @@ export type Event =
| EventSessionError
| EventFileWatcherUpdated
| EventVcsBranchUpdated
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow
| EventPtyCreated
| EventPtyUpdated
| EventPtyExited
Expand Down Expand Up @@ -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
Expand Down
Loading