Skip to content
Open
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
147 changes: 147 additions & 0 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { PromptHistoryProvider } from "./component/prompt/history"
import { FrecencyProvider } from "./component/prompt/frecency"
import { PromptStashProvider } from "./component/prompt/stash"
import { DialogAlert } from "./ui/dialog-alert"
import { DialogPrompt } from "./ui/dialog-prompt"
import { ToastProvider, useToast } from "./ui/toast"
import { ExitProvider, useExit } from "./context/exit"
import { Session as SessionApi } from "@/session"
Expand All @@ -36,6 +37,7 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
import { writeHeapSnapshot } from "v8"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { Identifier } from "@/id/id"

async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
// can't set raw mode if not a TTY
Expand Down Expand Up @@ -317,6 +319,151 @@ function App() {
dialog.clear()
},
},
{
title: "Start Ralph loop",
value: "ralph-loop",
acceptsArgs: true,
category: "Session",
onSelect: async (dialog, source) => {
if (route.data.type !== "session") {
toast.show({ variant: "error", message: "Not in a session" })
dialog.clear()
return
}

const sessionID = route.data.sessionID

if (source && typeof source === "object" && "prompt" in source) {
const selectedModel = local.model.current()
if (!selectedModel) {
toast.show({ variant: "error", message: "No model selected" })
dialog.clear()
return
}
const argsText = source.prompt.args.join(" ")
const isCancel = source.prompt.args.includes("--cancel") || source.prompt.args.includes("-c")
try {
await sdk.client.session.command({
sessionID,
command: "ralph-loop",
arguments: argsText,
messageID: Identifier.ascending("message"),
agent: local.agent.current().name,
model: `${selectedModel.providerID}/${selectedModel.modelID}`,
variant: local.model.variant.current(),
})
toast.show({ variant: "success", message: isCancel ? "Ralph loop cancelled" : "Ralph loop started" })
} catch (err) {
toast.show({ variant: "error", message: err instanceof Error ? err.message : "Failed to start loop" })
}
dialog.clear()
return
}

const promptResult = await DialogPrompt.show(dialog, "Ralph Loop", {
placeholder: 'e.g., "Continue with your task..."',
})

if (!promptResult) {
dialog.clear()
return
}

if (promptResult.length < 3) {
toast.show({ variant: "warning", message: "Prompt too short" })
dialog.clear()
return
}

const completionPromise = await DialogPrompt.show(dialog, "Completion Promise", {
placeholder: 'e.g., "I_DIDNT_FIND_ANYTHING_TO_CHANGE_OR_IMPROVE"',
value: "I_DIDNT_FIND_ANYTHING_TO_CHANGE_OR_IMPROVE",
})

const iterationsResult = await DialogPrompt.show(dialog, "Max Iterations", {
placeholder: "e.g., 20",
value: "20",
})

const maxIterations = iterationsResult ? parseInt(iterationsResult, 10) : 20
if (iterationsResult && Number.isNaN(maxIterations)) {
toast.show({ variant: "warning", message: "Invalid max iterations; using default" })
}

const selectedModel = local.model.current()
if (selectedModel) {
const quoteArg = (value: string) => {
if (!value || !/\s/.test(value)) return value
if (!value.includes('"')) return `"${value}"`
if (!value.includes("'")) return `'${value}'`
return value
}
const args: string[] = []
if (!Number.isNaN(maxIterations)) {
args.push("--max-iterations", String(maxIterations))
}
if (completionPromise) {
args.push("--completion-promise", quoteArg(completionPromise))
}
args.push("--", quoteArg(promptResult))
try {
await sdk.client.session.command({
sessionID,
command: "ralph-loop",
arguments: args.join(" "),
messageID: Identifier.ascending("message"),
agent: local.agent.current().name,
model: `${selectedModel.providerID}/${selectedModel.modelID}`,
variant: local.model.variant.current(),
})
toast.show({ variant: "success", message: `Ralph loop started (${maxIterations} iterations)` })
} catch (err) {
toast.show({ variant: "error", message: err instanceof Error ? err.message : "Failed to start loop" })
}
} else {
toast.show({ variant: "error", message: "No model selected" })
}
dialog.clear()
},
},
{
title: "Cancel Ralph loop",
value: "ralph-loop.cancel",
category: "Session",
slash: {
name: "ralph-loop:cancel",
},
onSelect: async (dialog) => {
if (route.data.type !== "session") {
toast.show({ variant: "error", message: "Not in a session" })
dialog.clear()
return
}

const sessionID = route.data.sessionID
const selectedModel = local.model.current()
if (!selectedModel) {
toast.show({ variant: "error", message: "No model selected" })
dialog.clear()
return
}
try {
await sdk.client.session.command({
sessionID,
command: "ralph-cancel",
arguments: "",
messageID: Identifier.ascending("message"),
agent: local.agent.current().name,
model: `${selectedModel.providerID}/${selectedModel.modelID}`,
variant: local.model.variant.current(),
})
toast.show({ variant: "success", message: "Ralph loop cancelled" })
} catch (err) {
toast.show({ variant: "warning", message: err instanceof Error ? err.message : "No active loop to cancel" })
}
dialog.clear()
},
},
{
title: "Switch model",
value: "model.list",
Expand Down
11 changes: 10 additions & 1 deletion packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,11 @@ export type CommandOption = DialogSelectOption<string> & {
slash?: Slash
hidden?: boolean
enabled?: boolean
acceptsArgs?: boolean
}

export type CommandTrigger = undefined | "prompt" | { prompt: { args: string[] } }

function init() {
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
const [suspendCount, setSuspendCount] = createSignal(0)
Expand Down Expand Up @@ -72,13 +75,19 @@ function init() {
})

const result = {
trigger(name: string) {
trigger(name: string, source?: CommandTrigger) {
for (const option of entries()) {
if (option.value === name) {
if (!isEnabled(option)) return
option.onSelect?.(dialog)
return
}
if (option.acceptsArgs && name.startsWith(String(option.value) + " ")) {
const argsStr = name.slice(String(option.value).length + 1).trim()
const args = argsStr.split(" ").filter((x) => x)
option.onSelect?.(dialog, { prompt: { args } })
return
}
}
},
slashes() {
Expand Down
44 changes: 40 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 @@ -547,14 +547,44 @@ export function Prompt(props: PromptProps) {
inputText.startsWith("/") &&
iife(() => {
const command = inputText.split(" ")[0].slice(1)
console.log(command)
return sync.data.command.some((x) => x.name === command)
const isCommand = sync.data.command.some((x) => x.name === command)
const isRalphLoop = command === "ralph-loop"
const isRalphCancel = command === "ralph-cancel" || command === "ralph-loop:cancel"
return isCommand || isRalphLoop || isRalphCancel
})
) {
let [command, ...args] = inputText.split(" ")
const commandName = command.slice(1)
const normalizedCommand = commandName === "ralph-loop:cancel" ? "ralph-cancel" : commandName

// Route ralph-loop and ralph-cancel through server-side command handler
// Server registers loop state in its own context where chat.waiting hook runs
if (normalizedCommand === "ralph-cancel" || normalizedCommand === "ralph-loop") {
sdk.client.session.command({
sessionID,
command: normalizedCommand,
arguments: args.join(" "),
agent: local.agent.current().name,
model: `${selectedModel.providerID}/${selectedModel.modelID}`,
messageID,
variant,
parts: nonTextParts
.filter((x) => x.type === "file")
.map((x) => ({
id: Identifier.ascending("part"),
...x,
})),
})
input.extmarks.clear()
setStore("prompt", { input: "", parts: [] })
setStore("extmarkToPartIndex", new Map())
input.clear()
return
}

sdk.client.session.command({
sessionID,
command: command.slice(1),
command: commandName,
arguments: args.join(" "),
agent: local.agent.current().name,
model: `${selectedModel.providerID}/${selectedModel.modelID}`,
Expand All @@ -567,6 +597,11 @@ export function Prompt(props: PromptProps) {
...x,
})),
})
input.extmarks.clear()
setStore("prompt", { input: "", parts: [] })
setStore("extmarkToPartIndex", new Map())
input.clear()
return
} else {
sdk.client.session
.prompt({
Expand Down Expand Up @@ -603,13 +638,14 @@ export function Prompt(props: PromptProps) {
props.onSubmit?.()

// temporary hack to make sure the message is sent
if (!props.sessionID)
if (!props.sessionID) {
setTimeout(() => {
route.navigate({
type: "session",
sessionID,
})
}, 50)
}
input.clear()
}
const exit = useExit()
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export interface DialogSelectOption<T = any> {
disabled?: boolean
bg?: RGBA
gutter?: JSX.Element
onSelect?: (ctx: DialogContext) => void
onSelect?: (ctx: DialogContext, trigger?: "prompt" | { prompt: { args: string[] } }) => void
}

export type DialogSelectRef<T> = {
Expand Down
7 changes: 7 additions & 0 deletions packages/opencode/src/command/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export namespace Command {
export const Default = {
INIT: "init",
REVIEW: "review",
RALPH_LOOP: "ralph-loop",
} as const

const state = Instance.state(async () => {
Expand All @@ -76,6 +77,12 @@ export namespace Command {
subtask: true,
hints: hints(PROMPT_REVIEW),
},
[Default.RALPH_LOOP]: {
name: Default.RALPH_LOOP,
description: "Start Ralph-Loop [-m MAX_ITERATIONS|20] [-p PROMISE_TEXT|unset] PROMPT",
template: "", // Handled specially by loop logic
hints: ["$ARGUMENTS"],
},
}

for (const [name, command] of Object.entries(cfg.command ?? {})) {
Expand Down
10 changes: 9 additions & 1 deletion packages/opencode/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,22 @@ import { CodexAuthPlugin } from "./codex"
import { Session } from "../session"
import { NamedError } from "@opencode-ai/util/error"
import { CopilotAuthPlugin } from "./copilot"
import { RalphLoop } from "@opencode-ai/plugin/ralph-loop"

export namespace Plugin {
const log = Log.create({ service: "plugin" })

const BUILTIN = ["opencode-anthropic-auth@0.0.9", "@gitlab/opencode-gitlab-auth@1.3.0"]

// Built-in plugins that are directly imported (not installed from npm)
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin]
const INTERNAL_PLUGINS: PluginInstance[] = [
CodexAuthPlugin,
CopilotAuthPlugin,
async () => {
log.info("loading ralph-loop internal plugin")
return { ...RalphLoop, name: "ralph-loop" }
},
]

const state = Instance.state(async () => {
const client = createOpencodeClient({
Expand Down
Loading