Compare commits

..

12 Commits

Author SHA1 Message Date
Aiden Cline
ac3d0cb5a3 review cleanup 2026-01-18 00:24:11 -06:00
Aiden Cline
06d69ab609 cleanup 2026-01-18 00:17:13 -06:00
Aiden Cline
c2cc486c7d exclude write tool too 2026-01-17 23:46:23 -06:00
Aiden Cline
8a6b8e5339 tweak wording to say Patched for ui rendered tool parts 2026-01-17 23:25:07 -06:00
Aiden Cline
cfd6a7ae96 add apply patch to desktop app 2026-01-17 22:47:35 -06:00
Aiden Cline
4173ee0e0b add lsp diagnostics to apply patch 2026-01-17 22:47:26 -06:00
Aiden Cline
22b5d7e570 rm assertion for deletes 2026-01-17 22:19:02 -06:00
Aiden Cline
f1ec28176f wip - ui 2026-01-17 22:03:47 -06:00
Aiden Cline
ab78a46396 wip 2026-01-17 21:15:27 -06:00
Aiden Cline
2ed18ea1fe wip 2026-01-17 20:48:09 -06:00
Aiden Cline
40eddce435 wip 2026-01-17 15:13:57 -06:00
Aiden Cline
78f8cc9418 wip 2026-01-17 14:25:29 -06:00
44 changed files with 1412 additions and 1107 deletions

View File

@@ -91,10 +91,8 @@ This will walk you through installing the GitHub app, creating the workflow, and
uses: anomalyco/opencode/github@latest
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
model: anthropic/claude-sonnet-4-20250514
use_github_token: true
```
3. Store the API keys in secrets. In your organization or project **settings**, expand **Secrets and variables** on the left and select **Actions**. Add the required API keys.

View File

@@ -29,7 +29,7 @@ import { Suspense } from "solid-js"
const Home = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
const Loading = () => <div class="size-full" />
const Loading = () => <div class="size-full flex items-center justify-center text-text-weak">Loading...</div>
declare global {
interface Window {

View File

@@ -149,7 +149,7 @@ export function DialogSelectFile() {
<Show
when={item.type === "command"}
fallback={
<div class="w-full flex items-center justify-between rounded-md pl-1">
<div class="w-full flex items-center justify-between rounded-md">
<div class="flex items-center gap-x-3 grow min-w-0">
<FileIcon node={{ path: item.path ?? "", type: "file" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular">

View File

@@ -1018,7 +1018,7 @@ export default function Layout(props: ParentProps) {
const notifications = createMemo(() => notification.project.unseen(props.project.worktree))
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
const mask = "radial-gradient(circle 5px at calc(100% - 4px) 4px, transparent 5px, black 5.5px)"
const mask = "radial-gradient(circle 6px at calc(100% - 3px) 3px, transparent 6px, black 6.5px)"
const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
return (
@@ -1039,7 +1039,7 @@ export default function Layout(props: ParentProps) {
<Show when={notifications().length > 0 && props.notify}>
<div
classList={{
"absolute top-px right-px size-1.5 rounded-full z-10": true,
"absolute -top-px -right-px size-2 rounded-full z-10": true,
"bg-icon-critical-base": hasError(),
"bg-text-interactive-base": !hasError(),
}}
@@ -1089,39 +1089,33 @@ export default function Layout(props: ParentProps) {
class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3
hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
>
<A
href={`${props.slug}/session/${props.session.id}`}
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
onMouseEnter={() => prefetchSession(props.session, "high")}
onFocus={() => prefetchSession(props.session, "high")}
>
<div class="flex items-center gap-1 w-full">
<div
class="shrink-0 size-6 flex items-center justify-center"
style={{ color: tint() ?? "var(--icon-interactive-base)" }}
>
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
<Match when={isWorking()}>
<Spinner class="size-[15px]" />
</Match>
<Match when={hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={notifications().length > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
</div>
<Tooltip
placement="top-start"
value={props.session.title}
gutter={0}
openDelay={3000}
class="grow-1 min-w-0"
>
<Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={16} openDelay={1000}>
<A
href={`${props.slug}/session/${props.session.id}`}
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
onMouseEnter={() => prefetchSession(props.session, "high")}
onFocus={() => prefetchSession(props.session, "high")}
>
<div class="flex items-center gap-1 w-full">
<div
class="shrink-0 size-6 flex items-center justify-center"
style={{ color: tint() ?? "var(--icon-interactive-base)" }}
>
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
<Match when={isWorking()}>
<Spinner class="size-[15px]" />
</Match>
<Match when={hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={notifications().length > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
</div>
<InlineEditor
id={`session:${props.session.id}`}
value={() => props.session.title}
@@ -1130,16 +1124,16 @@ export default function Layout(props: ParentProps) {
displayClass="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"
stopPropagation
/>
</Tooltip>
<Show when={props.session.summary}>
{(summary) => (
<div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
<DiffChanges changes={summary()} />
</div>
)}
</Show>
</div>
</A>
<Show when={props.session.summary}>
{(summary) => (
<div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
<DiffChanges changes={summary()} />
</div>
)}
</Show>
</div>
</A>
</Tooltip>
<div
class={`hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"}`}
>
@@ -1344,8 +1338,6 @@ export default function Layout(props: ParentProps) {
const workspaces = createMemo(() => workspaceIds(props.project).slice(0, 2))
const workspaceEnabled = createMemo(() => layout.sidebar.workspaces(props.project.worktree)())
const [open, setOpen] = createSignal(false)
const label = (directory: string) => {
const [data] = globalSync.child(directory)
const kind = directory === props.project.worktree ? "local" : "sandbox"
@@ -1378,8 +1370,7 @@ export default function Layout(props: ParentProps) {
"flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": selected(),
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
!selected() && !open(),
"bg-surface-base-hover border border-border-weak-base": !selected() && open(),
!selected(),
}}
onClick={() => navigateToProject(props.project.worktree)}
>
@@ -1390,17 +1381,9 @@ export default function Layout(props: ParentProps) {
return (
// @ts-ignore
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
<HoverCard
openDelay={0}
closeDelay={0}
placement="right-start"
gutter={6}
trigger={trigger}
onOpenChange={setOpen}
>
<HoverCard openDelay={0} closeDelay={0} placement="right-start" gutter={6} trigger={trigger}>
<div class="-m-3 flex flex-col w-72">
<div class="px-4 pt-2 pb-1 text-14-medium text-text-strong truncate">{displayName(props.project)}</div>
<div class="px-4 pb-2 text-12-medium text-text-weak">Recent sessions</div>
<div class="px-3 py-2 text-12-medium text-text-weak">Recent sessions</div>
<div class="px-2 pb-2 flex flex-col gap-2">
<Show
when={workspaceEnabled()}
@@ -1627,16 +1610,7 @@ export default function Layout(props: ParentProps) {
stopPropagation
/>
<Tooltip
placement={sidebarProps.mobile ? "bottom" : "top"}
gutter={2}
value={project()?.worktree}
class="shrink-0"
contentStyle={{
"max-width": "640px",
transform: "translate3d(52px, 0, 0)",
}}
>
<Tooltip placement="right" value={project()?.worktree} class="shrink-0">
<span class="text-12-regular text-text-base truncate">
{project()?.worktree.replace(homedir(), "~")}
</span>
@@ -1678,7 +1652,7 @@ export default function Layout(props: ParentProps) {
<Button
size="large"
icon="plus-small"
class="w-full"
class="w-full max-w-[256px]"
onClick={() => {
navigate(`/${base64Encode(p.worktree)}/session`)
layout.mobileSidebar.hide()
@@ -1695,7 +1669,7 @@ export default function Layout(props: ParentProps) {
>
<>
<div class="py-4 px-3">
<Button size="large" icon="plus-small" class="w-full" onClick={createWorkspace}>
<Button size="large" icon="plus-small" class="w-full max-w-[256px]" onClick={createWorkspace}>
New workspace
</Button>
</div>

View File

@@ -1091,7 +1091,7 @@ export default function Page() {
file.load(path)
}}
classes={{
root: "pb-[calc(var(--prompt-height,8rem)+24px)]",
root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
header: "px-4",
container: "px-4",
}}
@@ -1237,7 +1237,7 @@ export default function Page() {
{/* Prompt input */}
<div
ref={(el) => (promptDock = el)}
class="absolute inset-x-0 bottom-0 pt-12 pb-4 md:pb-6 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none"
class="absolute inset-x-0 bottom-0 pt-12 pb-4 md:pb-8 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none"
>
<div
classList={{

View File

@@ -12,7 +12,7 @@ import { relaunch } from "@tauri-apps/plugin-process"
import { AsyncStorage } from "@solid-primitives/storage"
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
import { Store } from "@tauri-apps/plugin-store"
import { Splash } from "@opencode-ai/ui/logo"
import { Logo } from "@opencode-ai/ui/logo"
import { createSignal, Show, Accessor, JSX, createResource, onMount, onCleanup } from "solid-js"
import { UPDATER_ENABLED } from "./updater"
@@ -357,7 +357,8 @@ function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.
when={serverData.state !== "pending" && serverData()}
fallback={
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
<Logo class="w-xl opacity-12 animate-pulse" />
<div class="mt-8 text-14-regular text-text-weak">Initializing...</div>
</div>
}
>

View File

@@ -70,8 +70,8 @@ export const AgentCommand = cmd({
})
async function getAvailableTools(agent: Agent.Info) {
const providerID = agent.model?.providerID ?? (await Provider.defaultModel()).providerID
return ToolRegistry.tools(providerID, agent)
const model = agent.model ?? (await Provider.defaultModel())
return ToolRegistry.tools(model, agent)
}
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {

View File

@@ -288,10 +288,6 @@ function App() {
keybind: "session_list",
category: "Session",
suggested: sync.data.session.length > 0,
slash: {
name: "sessions",
aliases: ["resume", "continue"],
},
onSelect: () => {
dialog.replace(() => <DialogSessionList />)
},
@@ -302,10 +298,6 @@ function App() {
value: "session.new",
keybind: "session_new",
category: "Session",
slash: {
name: "new",
aliases: ["clear"],
},
onSelect: () => {
const current = promptRef.current
// Don't require focus - if there's any text, preserve it
@@ -323,29 +315,26 @@ function App() {
keybind: "model_list",
suggested: true,
category: "Agent",
slash: {
name: "models",
},
onSelect: () => {
dialog.replace(() => <DialogModel />)
},
},
{
title: "Model cycle",
disabled: true,
value: "model.cycle_recent",
keybind: "model_cycle_recent",
category: "Agent",
hidden: true,
onSelect: () => {
local.model.cycle(1)
},
},
{
title: "Model cycle reverse",
disabled: true,
value: "model.cycle_recent_reverse",
keybind: "model_cycle_recent_reverse",
category: "Agent",
hidden: true,
onSelect: () => {
local.model.cycle(-1)
},
@@ -355,7 +344,6 @@ function App() {
value: "model.cycle_favorite",
keybind: "model_cycle_favorite",
category: "Agent",
hidden: true,
onSelect: () => {
local.model.cycleFavorite(1)
},
@@ -365,7 +353,6 @@ function App() {
value: "model.cycle_favorite_reverse",
keybind: "model_cycle_favorite_reverse",
category: "Agent",
hidden: true,
onSelect: () => {
local.model.cycleFavorite(-1)
},
@@ -375,9 +362,6 @@ function App() {
value: "agent.list",
keybind: "agent_list",
category: "Agent",
slash: {
name: "agents",
},
onSelect: () => {
dialog.replace(() => <DialogAgent />)
},
@@ -386,9 +370,6 @@ function App() {
title: "Toggle MCPs",
value: "mcp.list",
category: "Agent",
slash: {
name: "mcps",
},
onSelect: () => {
dialog.replace(() => <DialogMcp />)
},
@@ -398,7 +379,7 @@ function App() {
value: "agent.cycle",
keybind: "agent_cycle",
category: "Agent",
hidden: true,
disabled: true,
onSelect: () => {
local.agent.move(1)
},
@@ -408,7 +389,6 @@ function App() {
value: "variant.cycle",
keybind: "variant_cycle",
category: "Agent",
hidden: true,
onSelect: () => {
local.model.variant.cycle()
},
@@ -418,7 +398,7 @@ function App() {
value: "agent.cycle.reverse",
keybind: "agent_cycle_reverse",
category: "Agent",
hidden: true,
disabled: true,
onSelect: () => {
local.agent.move(-1)
},
@@ -427,9 +407,6 @@ function App() {
title: "Connect provider",
value: "provider.connect",
suggested: !connected(),
slash: {
name: "connect",
},
onSelect: () => {
dialog.replace(() => <DialogProviderList />)
},
@@ -439,9 +416,6 @@ function App() {
title: "View status",
keybind: "status_view",
value: "opencode.status",
slash: {
name: "status",
},
onSelect: () => {
dialog.replace(() => <DialogStatus />)
},
@@ -451,9 +425,6 @@ function App() {
title: "Switch theme",
value: "theme.switch",
keybind: "theme_list",
slash: {
name: "themes",
},
onSelect: () => {
dialog.replace(() => <DialogThemeList />)
},
@@ -471,9 +442,6 @@ function App() {
{
title: "Help",
value: "help.show",
slash: {
name: "help",
},
onSelect: () => {
dialog.replace(() => <DialogHelp />)
},
@@ -500,10 +468,6 @@ function App() {
{
title: "Exit the app",
value: "app.exit",
slash: {
name: "exit",
aliases: ["quit", "q"],
},
onSelect: () => exit(),
category: "System",
},
@@ -544,7 +508,6 @@ function App() {
value: "terminal.suspend",
keybind: "terminal_suspend",
category: "System",
hidden: true,
onSelect: () => {
process.once("SIGCONT", () => {
renderer.resume()

View File

@@ -16,17 +16,9 @@ import type { KeybindsConfig } from "@opencode-ai/sdk/v2"
type Context = ReturnType<typeof init>
const ctx = createContext<Context>()
export type Slash = {
name: string
aliases?: string[]
}
export type CommandOption = DialogSelectOption<string> & {
export type CommandOption = DialogSelectOption & {
keybind?: keyof KeybindsConfig
suggested?: boolean
slash?: Slash
hidden?: boolean
enabled?: boolean
}
function init() {
@@ -34,35 +26,27 @@ function init() {
const [suspendCount, setSuspendCount] = createSignal(0)
const dialog = useDialog()
const keybind = useKeybind()
const entries = createMemo(() => {
const options = createMemo(() => {
const all = registrations().flatMap((x) => x())
return all.map((x) => ({
const suggested = all.filter((x) => x.suggested)
return [
...suggested.map((x) => ({
...x,
category: "Suggested",
value: "suggested." + x.value,
})),
...all,
].map((x) => ({
...x,
footer: x.keybind ? keybind.print(x.keybind) : undefined,
}))
})
const isEnabled = (option: CommandOption) => option.enabled !== false
const isVisible = (option: CommandOption) => isEnabled(option) && !option.hidden
const visibleOptions = createMemo(() => entries().filter((option) => isVisible(option)))
const suggestedOptions = createMemo(() =>
visibleOptions()
.filter((option) => option.suggested)
.map((option) => ({
...option,
value: `suggested:${option.value}`,
category: "Suggested",
})),
)
const suspended = () => suspendCount() > 0
useKeyboard((evt) => {
if (suspended()) return
if (dialog.stack.length > 0) return
for (const option of entries()) {
if (!isEnabled(option)) continue
for (const option of options()) {
if (option.keybind && keybind.match(option.keybind, evt)) {
evt.preventDefault()
option.onSelect?.(dialog)
@@ -72,33 +56,20 @@ function init() {
})
const result = {
trigger(name: string) {
for (const option of entries()) {
trigger(name: string, source?: "prompt") {
for (const option of options()) {
if (option.value === name) {
if (!isEnabled(option)) return
option.onSelect?.(dialog)
option.onSelect?.(dialog, source)
return
}
}
},
slashes() {
return visibleOptions().flatMap((option) => {
const slash = option.slash
if (!slash) return []
return {
display: "/" + slash.name,
description: option.description ?? option.title,
aliases: slash.aliases?.map((alias) => "/" + alias),
onSelect: () => result.trigger(option.value),
}
})
},
keybinds(enabled: boolean) {
setSuspendCount((count) => count + (enabled ? -1 : 1))
},
suspended,
show() {
dialog.replace(() => <DialogCommand options={visibleOptions()} suggestedOptions={suggestedOptions()} />)
dialog.replace(() => <DialogCommand options={options()} />)
},
register(cb: () => CommandOption[]) {
const results = createMemo(cb)
@@ -107,6 +78,9 @@ function init() {
setRegistrations((arr) => arr.filter((x) => x !== results))
})
},
get options() {
return options()
},
}
return result
}
@@ -130,7 +104,7 @@ export function CommandProvider(props: ParentProps) {
if (evt.defaultPrevented) return
if (keybind.match("command_list", evt)) {
evt.preventDefault()
value.show()
dialog.replace(() => <DialogCommand options={value.options} />)
return
}
})
@@ -138,11 +112,13 @@ export function CommandProvider(props: ParentProps) {
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
}
function DialogCommand(props: { options: CommandOption[]; suggestedOptions: CommandOption[] }) {
function DialogCommand(props: { options: CommandOption[] }) {
let ref: DialogSelectRef<string>
const list = () => {
if (ref?.filter) return props.options
return [...props.suggestedOptions, ...props.options]
}
return <DialogSelect ref={(r) => (ref = r)} title="Commands" options={list()} />
return (
<DialogSelect
ref={(r) => (ref = r)}
title="Commands"
options={props.options.filter((x) => !ref?.filter || !x.value.startsWith("suggested."))}
/>
)
}

View File

@@ -332,15 +332,16 @@ export function Autocomplete(props: {
)
})
const session = createMemo(() => (props.sessionID ? sync.session.get(props.sessionID) : undefined))
const commands = createMemo((): AutocompleteOption[] => {
const results: AutocompleteOption[] = [...command.slashes()]
for (const serverCommand of sync.data.command) {
const results: AutocompleteOption[] = []
const s = session()
for (const command of sync.data.command) {
results.push({
display: "/" + serverCommand.name + (serverCommand.mcp ? " (MCP)" : ""),
description: serverCommand.description,
display: "/" + command.name + (command.mcp ? " (MCP)" : ""),
description: command.description,
onSelect: () => {
const newText = "/" + serverCommand.name + " "
const newText = "/" + command.name + " "
const cursor = props.input().logicalCursor
props.input().deleteRange(0, 0, cursor.row, cursor.col)
props.input().insertText(newText)
@@ -348,9 +349,138 @@ export function Autocomplete(props: {
},
})
}
if (s) {
results.push(
{
display: "/undo",
description: "undo the last message",
onSelect: () => {
command.trigger("session.undo")
},
},
{
display: "/redo",
description: "redo the last message",
onSelect: () => command.trigger("session.redo"),
},
{
display: "/compact",
aliases: ["/summarize"],
description: "compact the session",
onSelect: () => command.trigger("session.compact"),
},
{
display: "/unshare",
disabled: !s.share,
description: "unshare a session",
onSelect: () => command.trigger("session.unshare"),
},
{
display: "/rename",
description: "rename session",
onSelect: () => command.trigger("session.rename"),
},
{
display: "/copy",
description: "copy session transcript to clipboard",
onSelect: () => command.trigger("session.copy"),
},
{
display: "/export",
description: "export session transcript to file",
onSelect: () => command.trigger("session.export"),
},
{
display: "/timeline",
description: "jump to message",
onSelect: () => command.trigger("session.timeline"),
},
{
display: "/fork",
description: "fork from message",
onSelect: () => command.trigger("session.fork"),
},
{
display: "/thinking",
description: "toggle thinking visibility",
onSelect: () => command.trigger("session.toggle.thinking"),
},
)
if (sync.data.config.share !== "disabled") {
results.push({
display: "/share",
disabled: !!s.share?.url,
description: "share a session",
onSelect: () => command.trigger("session.share"),
})
}
}
results.sort((a, b) => a.display.localeCompare(b.display))
results.push(
{
display: "/new",
aliases: ["/clear"],
description: "create a new session",
onSelect: () => command.trigger("session.new"),
},
{
display: "/models",
description: "list models",
onSelect: () => command.trigger("model.list"),
},
{
display: "/agents",
description: "list agents",
onSelect: () => command.trigger("agent.list"),
},
{
display: "/session",
aliases: ["/resume", "/continue"],
description: "list sessions",
onSelect: () => command.trigger("session.list"),
},
{
display: "/status",
description: "show status",
onSelect: () => command.trigger("opencode.status"),
},
{
display: "/mcp",
description: "toggle MCPs",
onSelect: () => command.trigger("mcp.list"),
},
{
display: "/theme",
description: "toggle theme",
onSelect: () => command.trigger("theme.switch"),
},
{
display: "/editor",
description: "open editor",
onSelect: () => command.trigger("prompt.editor", "prompt"),
},
{
display: "/connect",
description: "connect to a provider",
onSelect: () => command.trigger("provider.connect"),
},
{
display: "/help",
description: "show help",
onSelect: () => command.trigger("help.show"),
},
{
display: "/commands",
description: "show all commands",
onSelect: () => command.show(),
},
{
display: "/exit",
aliases: ["/quit", "/q"],
description: "exit the app",
onSelect: () => command.trigger("app.exit"),
},
)
const max = firstBy(results, [(x) => x.display.length, "desc"])?.display.length
if (!max) return results
return results.map((item) => ({
@@ -364,8 +494,9 @@ export function Autocomplete(props: {
const agentsValue = agents()
const commandsValue = commands()
const mixed: AutocompleteOption[] =
const mixed: AutocompleteOption[] = (
store.visible === "@" ? [...agentsValue, ...(filesValue || []), ...mcpResources()] : [...commandsValue]
).filter((x) => x.disabled !== true)
const currentFilter = filter()

View File

@@ -157,7 +157,7 @@ export function Prompt(props: PromptProps) {
title: "Clear prompt",
value: "prompt.clear",
category: "Prompt",
hidden: true,
disabled: true,
onSelect: (dialog) => {
input.extmarks.clear()
input.clear()
@@ -167,9 +167,9 @@ export function Prompt(props: PromptProps) {
{
title: "Submit prompt",
value: "prompt.submit",
disabled: true,
keybind: "input_submit",
category: "Prompt",
hidden: true,
onSelect: (dialog) => {
if (!input.focused) return
submit()
@@ -179,9 +179,9 @@ export function Prompt(props: PromptProps) {
{
title: "Paste",
value: "prompt.paste",
disabled: true,
keybind: "input_paste",
category: "Prompt",
hidden: true,
onSelect: async () => {
const content = await Clipboard.read()
if (content?.mime.startsWith("image/")) {
@@ -197,9 +197,8 @@ export function Prompt(props: PromptProps) {
title: "Interrupt session",
value: "session.interrupt",
keybind: "session_interrupt",
disabled: status().type === "idle",
category: "Session",
hidden: true,
enabled: status().type !== "idle",
onSelect: (dialog) => {
if (autocomplete.visible) return
if (!input.focused) return
@@ -230,10 +229,7 @@ export function Prompt(props: PromptProps) {
category: "Session",
keybind: "editor_open",
value: "prompt.editor",
slash: {
name: "editor",
},
onSelect: async (dialog) => {
onSelect: async (dialog, trigger) => {
dialog.clear()
// replace summarized text parts with the actual text
@@ -246,7 +242,7 @@ export function Prompt(props: PromptProps) {
const nonTextParts = store.prompt.parts.filter((p) => p.type !== "text")
const value = text
const value = trigger === "prompt" ? "" : text
const content = await Editor.open({ value, renderer })
if (!content) return
@@ -436,7 +432,7 @@ export function Prompt(props: PromptProps) {
title: "Stash prompt",
value: "prompt.stash",
category: "Prompt",
enabled: !!store.prompt.input,
disabled: !store.prompt.input,
onSelect: (dialog) => {
if (!store.prompt.input) return
stash.push({
@@ -454,7 +450,7 @@ export function Prompt(props: PromptProps) {
title: "Stash pop",
value: "prompt.stash.pop",
category: "Prompt",
enabled: stash.list().length > 0,
disabled: stash.list().length === 0,
onSelect: (dialog) => {
const entry = stash.pop()
if (entry) {
@@ -470,7 +466,7 @@ export function Prompt(props: PromptProps) {
title: "Stash list",
value: "prompt.stash.list",
category: "Prompt",
enabled: stash.list().length > 0,
disabled: stash.list().length === 0,
onSelect: (dialog) => {
dialog.replace(() => (
<DialogStash
@@ -1069,11 +1065,9 @@ export function Prompt(props: PromptProps) {
<box gap={2} flexDirection="row">
<Switch>
<Match when={store.mode === "normal"}>
<Show when={local.model.variant.list().length > 0}>
<text fg={theme.text}>
{keybind.print("variant_cycle")} <span style={{ fg: theme.textMuted }}>variants</span>
</text>
</Show>
<text fg={theme.text}>
{keybind.print("variant_cycle")} <span style={{ fg: theme.textMuted }}>variants</span>
</text>
<text fg={theme.text}>
{keybind.print("agent_cycle")} <span style={{ fg: theme.textMuted }}>agents</span>
</text>

View File

@@ -113,16 +113,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
const file = Bun.file(path.join(Global.Path.state, "model.json"))
const state = {
pending: false,
}
function save() {
if (!modelStore.ready) {
state.pending = true
return
}
state.pending = false
Bun.write(
file,
JSON.stringify({
@@ -143,7 +135,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
.catch(() => {})
.finally(() => {
setModelStore("ready", true)
if (state.pending) save()
})
const args = useArgs()

View File

@@ -16,8 +16,6 @@ export const TuiEvent = {
"session.compact",
"session.page.up",
"session.page.down",
"session.line.up",
"session.line.down",
"session.half.page.up",
"session.half.page.down",
"session.first",

View File

@@ -39,7 +39,7 @@ import { TodoWriteTool } from "@/tool/todo"
import type { GrepTool } from "@/tool/grep"
import type { ListTool } from "@/tool/ls"
import type { EditTool } from "@/tool/edit"
import type { PatchTool } from "@/tool/patch"
import type { ApplyPatchTool } from "@/tool/apply_patch"
import type { WebFetchTool } from "@/tool/webfetch"
import type { TaskTool } from "@/tool/task"
import type { QuestionTool } from "@/tool/question"
@@ -295,39 +295,37 @@ export function Session() {
const command = useCommandDialog()
command.register(() => [
{
title: "Share session",
value: "session.share",
suggested: route.type === "session",
keybind: "session_share",
category: "Session",
enabled: sync.data.config.share !== "disabled" && !session()?.share?.url,
slash: {
name: "share",
},
onSelect: async (dialog) => {
await sdk.client.session
.share({
sessionID: route.sessionID,
})
.then((res) =>
Clipboard.copy(res.data!.share!.url).catch(() =>
toast.show({ message: "Failed to copy URL to clipboard", variant: "error" }),
),
)
.then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" }))
.catch(() => toast.show({ message: "Failed to share session", variant: "error" }))
dialog.clear()
},
},
...(sync.data.config.share !== "disabled"
? [
{
title: "Share session",
value: "session.share",
suggested: route.type === "session",
keybind: "session_share" as const,
disabled: !!session()?.share?.url,
category: "Session",
onSelect: async (dialog: any) => {
await sdk.client.session
.share({
sessionID: route.sessionID,
})
.then((res) =>
Clipboard.copy(res.data!.share!.url).catch(() =>
toast.show({ message: "Failed to copy URL to clipboard", variant: "error" }),
),
)
.then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" }))
.catch(() => toast.show({ message: "Failed to share session", variant: "error" }))
dialog.clear()
},
},
]
: []),
{
title: "Rename session",
value: "session.rename",
keybind: "session_rename",
category: "Session",
slash: {
name: "rename",
},
onSelect: (dialog) => {
dialog.replace(() => <DialogSessionRename session={route.sessionID} />)
},
@@ -337,9 +335,6 @@ export function Session() {
value: "session.timeline",
keybind: "session_timeline",
category: "Session",
slash: {
name: "timeline",
},
onSelect: (dialog) => {
dialog.replace(() => (
<DialogTimeline
@@ -360,9 +355,6 @@ export function Session() {
value: "session.fork",
keybind: "session_fork",
category: "Session",
slash: {
name: "fork",
},
onSelect: (dialog) => {
dialog.replace(() => (
<DialogForkFromTimeline
@@ -382,10 +374,6 @@ export function Session() {
value: "session.compact",
keybind: "session_compact",
category: "Session",
slash: {
name: "compact",
aliases: ["summarize"],
},
onSelect: (dialog) => {
const selectedModel = local.model.current()
if (!selectedModel) {
@@ -408,11 +396,8 @@ export function Session() {
title: "Unshare session",
value: "session.unshare",
keybind: "session_unshare",
disabled: !session()?.share?.url,
category: "Session",
enabled: !!session()?.share?.url,
slash: {
name: "unshare",
},
onSelect: async (dialog) => {
await sdk.client.session
.unshare({
@@ -428,9 +413,6 @@ export function Session() {
value: "session.undo",
keybind: "messages_undo",
category: "Session",
slash: {
name: "undo",
},
onSelect: async (dialog) => {
const status = sync.data.session_status?.[route.sessionID]
if (status?.type !== "idle") await sdk.client.session.abort({ sessionID: route.sessionID }).catch(() => {})
@@ -465,11 +447,8 @@ export function Session() {
title: "Redo",
value: "session.redo",
keybind: "messages_redo",
disabled: !session()?.revert?.messageID,
category: "Session",
enabled: !!session()?.revert?.messageID,
slash: {
name: "redo",
},
onSelect: (dialog) => {
dialog.clear()
const messageID = session()?.revert?.messageID
@@ -516,10 +495,6 @@ export function Session() {
title: showTimestamps() ? "Hide timestamps" : "Show timestamps",
value: "session.toggle.timestamps",
category: "Session",
slash: {
name: "timestamps",
aliases: ["toggle-timestamps"],
},
onSelect: (dialog) => {
setTimestamps((prev) => (prev === "show" ? "hide" : "show"))
dialog.clear()
@@ -529,10 +504,6 @@ export function Session() {
title: showThinking() ? "Hide thinking" : "Show thinking",
value: "session.toggle.thinking",
category: "Session",
slash: {
name: "thinking",
aliases: ["toggle-thinking"],
},
onSelect: (dialog) => {
setShowThinking((prev) => !prev)
dialog.clear()
@@ -542,9 +513,6 @@ export function Session() {
title: "Toggle diff wrapping",
value: "session.toggle.diffwrap",
category: "Session",
slash: {
name: "diffwrap",
},
onSelect: (dialog) => {
setDiffWrapMode((prev) => (prev === "word" ? "none" : "word"))
dialog.clear()
@@ -584,7 +552,7 @@ export function Session() {
value: "session.page.up",
keybind: "messages_page_up",
category: "Session",
hidden: true,
disabled: true,
onSelect: (dialog) => {
scroll.scrollBy(-scroll.height / 2)
dialog.clear()
@@ -595,40 +563,18 @@ export function Session() {
value: "session.page.down",
keybind: "messages_page_down",
category: "Session",
hidden: true,
disabled: true,
onSelect: (dialog) => {
scroll.scrollBy(scroll.height / 2)
dialog.clear()
},
},
{
title: "Line up",
value: "session.line.up",
keybind: "messages_line_up",
category: "Session",
disabled: true,
onSelect: (dialog) => {
scroll.scrollBy(-1)
dialog.clear()
},
},
{
title: "Line down",
value: "session.line.down",
keybind: "messages_line_down",
category: "Session",
disabled: true,
onSelect: (dialog) => {
scroll.scrollBy(1)
dialog.clear()
},
},
{
title: "Half page up",
value: "session.half.page.up",
keybind: "messages_half_page_up",
category: "Session",
hidden: true,
disabled: true,
onSelect: (dialog) => {
scroll.scrollBy(-scroll.height / 4)
dialog.clear()
@@ -639,7 +585,7 @@ export function Session() {
value: "session.half.page.down",
keybind: "messages_half_page_down",
category: "Session",
hidden: true,
disabled: true,
onSelect: (dialog) => {
scroll.scrollBy(scroll.height / 4)
dialog.clear()
@@ -650,7 +596,7 @@ export function Session() {
value: "session.first",
keybind: "messages_first",
category: "Session",
hidden: true,
disabled: true,
onSelect: (dialog) => {
scroll.scrollTo(0)
dialog.clear()
@@ -661,7 +607,7 @@ export function Session() {
value: "session.last",
keybind: "messages_last",
category: "Session",
hidden: true,
disabled: true,
onSelect: (dialog) => {
scroll.scrollTo(scroll.scrollHeight)
dialog.clear()
@@ -672,7 +618,6 @@ export function Session() {
value: "session.messages_last_user",
keybind: "messages_last_user",
category: "Session",
hidden: true,
onSelect: () => {
const messages = sync.data.message[route.sessionID]
if (!messages || !messages.length) return
@@ -704,7 +649,7 @@ export function Session() {
value: "session.message.next",
keybind: "messages_next",
category: "Session",
hidden: true,
disabled: true,
onSelect: (dialog) => scrollToMessage("next", dialog),
},
{
@@ -712,7 +657,7 @@ export function Session() {
value: "session.message.previous",
keybind: "messages_previous",
category: "Session",
hidden: true,
disabled: true,
onSelect: (dialog) => scrollToMessage("prev", dialog),
},
{
@@ -761,10 +706,8 @@ export function Session() {
{
title: "Copy session transcript",
value: "session.copy",
keybind: "session_copy",
category: "Session",
slash: {
name: "copy",
},
onSelect: async (dialog) => {
try {
const sessionData = session()
@@ -792,9 +735,6 @@ export function Session() {
value: "session.export",
keybind: "session_export",
category: "Session",
slash: {
name: "export",
},
onSelect: async (dialog) => {
try {
const sessionData = session()
@@ -853,7 +793,7 @@ export function Session() {
value: "session.child.next",
keybind: "session_child_cycle",
category: "Session",
hidden: true,
disabled: true,
onSelect: (dialog) => {
moveChild(1)
dialog.clear()
@@ -864,7 +804,7 @@ export function Session() {
value: "session.child.previous",
keybind: "session_child_cycle_reverse",
category: "Session",
hidden: true,
disabled: true,
onSelect: (dialog) => {
moveChild(-1)
dialog.clear()
@@ -875,7 +815,7 @@ export function Session() {
value: "session.parent",
keybind: "session_parent",
category: "Session",
hidden: true,
disabled: true,
onSelect: (dialog) => {
const parentID = session()?.parentID
if (parentID) {
@@ -1445,8 +1385,8 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
<Match when={props.part.tool === "task"}>
<Task {...toolprops} />
</Match>
<Match when={props.part.tool === "patch"}>
<Patch {...toolprops} />
<Match when={props.part.tool === "apply_patch"}>
<ApplyPatch {...toolprops} />
</Match>
<Match when={props.part.tool === "todowrite"}>
<TodoWrite {...toolprops} />
@@ -1895,20 +1835,74 @@ function Edit(props: ToolProps<typeof EditTool>) {
)
}
function Patch(props: ToolProps<typeof PatchTool>) {
const { theme } = useTheme()
function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
const ctx = use()
const { theme, syntax } = useTheme()
const files = createMemo(() => props.metadata.files ?? [])
const view = createMemo(() => {
const diffStyle = ctx.sync.data.config.tui?.diff_style
if (diffStyle === "stacked") return "unified"
return ctx.width > 120 ? "split" : "unified"
})
function Diff(p: { diff: string; filePath: string }) {
return (
<box paddingLeft={1}>
<diff
diff={p.diff}
view={view()}
filetype={filetype(p.filePath)}
syntaxStyle={syntax()}
showLineNumbers={true}
width="100%"
wrapMode={ctx.diffWrapMode()}
fg={theme.text}
addedBg={theme.diffAddedBg}
removedBg={theme.diffRemovedBg}
contextBg={theme.diffContextBg}
addedSignColor={theme.diffHighlightAdded}
removedSignColor={theme.diffHighlightRemoved}
lineNumberFg={theme.diffLineNumber}
lineNumberBg={theme.diffContextBg}
addedLineNumberBg={theme.diffAddedLineNumberBg}
removedLineNumberBg={theme.diffRemovedLineNumberBg}
/>
</box>
)
}
function title(file: { type: string; relativePath: string; filePath: string; deletions: number }) {
if (file.type === "delete") return "# Deleted " + file.relativePath
if (file.type === "add") return "# Created " + file.relativePath
if (file.type === "move") return "# Moved " + normalizePath(file.filePath) + " → " + file.relativePath
return "← Patched " + file.relativePath
}
return (
<Switch>
<Match when={props.output !== undefined}>
<BlockTool title="# Patch" part={props.part}>
<box>
<text fg={theme.text}>{props.output?.trim()}</text>
</box>
</BlockTool>
<Match when={files().length > 0}>
<For each={files()}>
{(file) => (
<BlockTool title={title(file)} part={props.part}>
<Show
when={file.type !== "delete"}
fallback={
<text fg={theme.diffRemoved}>
-{file.deletions} line{file.deletions !== 1 ? "s" : ""}
</text>
}
>
<Diff diff={file.diff} filePath={file.filePath} />
</Show>
</BlockTool>
)}
</For>
</Match>
<Match when={true}>
<InlineTool icon="%" pending="Preparing patch..." complete={false} part={props.part}>
Patch
<InlineTool icon="%" pending="Preparing apply_patch..." complete={false} part={props.part}>
apply_patch
</InlineTool>
</Match>
</Switch>

View File

@@ -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") => void
}
export type DialogSelectRef<T> = {

View File

@@ -651,14 +651,8 @@ export namespace Config {
session_unshare: z.string().optional().default("none").describe("Unshare current session"),
session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"),
session_compact: z.string().optional().default("<leader>c").describe("Compact the session"),
messages_page_up: z.string().optional().default("pageup,ctrl+alt+b").describe("Scroll messages up by one page"),
messages_page_down: z
.string()
.optional()
.default("pagedown,ctrl+alt+f")
.describe("Scroll messages down by one page"),
messages_line_up: z.string().optional().default("ctrl+alt+y").describe("Scroll messages up by one line"),
messages_line_down: z.string().optional().default("ctrl+alt+e").describe("Scroll messages down by one line"),
messages_page_up: z.string().optional().default("pageup").describe("Scroll messages up by one page"),
messages_page_down: z.string().optional().default("pagedown").describe("Scroll messages down by one page"),
messages_half_page_up: z.string().optional().default("ctrl+alt+u").describe("Scroll messages up by half page"),
messages_half_page_down: z
.string()
@@ -1121,7 +1115,6 @@ export namespace Config {
}
async function load(text: string, configFilepath: string) {
const original = text
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
return process.env[varName] || ""
})
@@ -1191,9 +1184,7 @@ export namespace Config {
if (parsed.success) {
if (!parsed.data.$schema) {
parsed.data.$schema = "https://opencode.ai/config.json"
// Write the $schema to the original text to preserve variables like {env:VAR}
const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",')
await Bun.write(configFilepath, updated).catch(() => {})
await Bun.write(configFilepath, JSON.stringify(parsed.data, null, 2)).catch(() => {})
}
const data = parsed.data
if (data.plugin) {

View File

@@ -177,8 +177,18 @@ export namespace Patch {
return { content, nextIdx: i }
}
function stripHeredoc(input: string): string {
// Match heredoc patterns like: cat <<'EOF'\n...\nEOF or <<EOF\n...\nEOF
const heredocMatch = input.match(/^(?:cat\s+)?<<['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\1\s*$/)
if (heredocMatch) {
return heredocMatch[2]
}
return input
}
export function parsePatch(patchText: string): { hunks: Hunk[] } {
const lines = patchText.split("\n")
const cleaned = stripHeredoc(patchText.trim())
const lines = cleaned.split("\n")
const hunks: Hunk[] = []
let i = 0
@@ -363,7 +373,7 @@ export namespace Patch {
// Try to match old lines in the file
let pattern = chunk.old_lines
let newSlice = chunk.new_lines
let found = seekSequence(originalLines, pattern, lineIndex)
let found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file)
// Retry without trailing empty line if not found
if (found === -1 && pattern.length > 0 && pattern[pattern.length - 1] === "") {
@@ -371,7 +381,7 @@ export namespace Patch {
if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") {
newSlice = newSlice.slice(0, -1)
}
found = seekSequence(originalLines, pattern, lineIndex)
found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file)
}
if (found !== -1) {
@@ -407,28 +417,75 @@ export namespace Patch {
return result
}
function seekSequence(lines: string[], pattern: string[], startIndex: number): number {
if (pattern.length === 0) return -1
// Normalize Unicode punctuation to ASCII equivalents (like Rust's normalize_unicode)
function normalizeUnicode(str: string): string {
return str
.replace(/[\u2018\u2019\u201A\u201B]/g, "'") // single quotes
.replace(/[\u201C\u201D\u201E\u201F]/g, '"') // double quotes
.replace(/[\u2010\u2011\u2012\u2013\u2014\u2015]/g, "-") // dashes
.replace(/\u2026/g, "...") // ellipsis
.replace(/\u00A0/g, " ") // non-breaking space
}
// Simple substring search implementation
type Comparator = (a: string, b: string) => boolean
function tryMatch(lines: string[], pattern: string[], startIndex: number, compare: Comparator, eof: boolean): number {
// If EOF anchor, try matching from end of file first
if (eof) {
const fromEnd = lines.length - pattern.length
if (fromEnd >= startIndex) {
let matches = true
for (let j = 0; j < pattern.length; j++) {
if (!compare(lines[fromEnd + j], pattern[j])) {
matches = false
break
}
}
if (matches) return fromEnd
}
}
// Forward search from startIndex
for (let i = startIndex; i <= lines.length - pattern.length; i++) {
let matches = true
for (let j = 0; j < pattern.length; j++) {
if (lines[i + j] !== pattern[j]) {
if (!compare(lines[i + j], pattern[j])) {
matches = false
break
}
}
if (matches) {
return i
}
if (matches) return i
}
return -1
}
function seekSequence(lines: string[], pattern: string[], startIndex: number, eof = false): number {
if (pattern.length === 0) return -1
// Pass 1: exact match
const exact = tryMatch(lines, pattern, startIndex, (a, b) => a === b, eof)
if (exact !== -1) return exact
// Pass 2: rstrip (trim trailing whitespace)
const rstrip = tryMatch(lines, pattern, startIndex, (a, b) => a.trimEnd() === b.trimEnd(), eof)
if (rstrip !== -1) return rstrip
// Pass 3: trim (both ends)
const trim = tryMatch(lines, pattern, startIndex, (a, b) => a.trim() === b.trim(), eof)
if (trim !== -1) return trim
// Pass 4: normalized (Unicode punctuation to ASCII)
const normalized = tryMatch(
lines,
pattern,
startIndex,
(a, b) => normalizeUnicode(a.trim()) === normalizeUnicode(b.trim()),
eof,
)
return normalized
}
function generateUnifiedDiff(oldContent: string, newContent: string): string {
const oldLines = oldContent.split("\n")
const newLines = newContent.split("\n")

View File

@@ -11,8 +11,6 @@ import { Instance } from "./instance"
import { Vcs } from "./vcs"
import { Log } from "@/util/log"
import { ShareNext } from "@/share/share-next"
import { Snapshot } from "../snapshot"
import { Truncate } from "../tool/truncation"
export async function InstanceBootstrap() {
Log.Default.info("bootstrapping", { directory: Instance.directory })
@@ -24,8 +22,6 @@ export async function InstanceBootstrap() {
FileWatcher.init()
File.init()
Vcs.init()
Snapshot.init()
Truncate.init()
Bus.subscribe(Command.Event.Executed, async (payload) => {
if (payload.properties.name === Command.Default.INIT) {

View File

@@ -1,61 +0,0 @@
import { Instance } from "../project/instance"
import { Log } from "../util/log"
export namespace Scheduler {
const log = Log.create({ service: "scheduler" })
export type Task = {
id: string
interval: number
run: () => Promise<void>
scope?: "instance" | "global"
}
type Timer = ReturnType<typeof setInterval>
type Entry = {
tasks: Map<string, Task>
timers: Map<string, Timer>
}
const create = (): Entry => {
const tasks = new Map<string, Task>()
const timers = new Map<string, Timer>()
return { tasks, timers }
}
const shared = create()
const state = Instance.state(
() => create(),
async (entry) => {
for (const timer of entry.timers.values()) {
clearInterval(timer)
}
entry.tasks.clear()
entry.timers.clear()
},
)
export function register(task: Task) {
const scope = task.scope ?? "instance"
const entry = scope === "global" ? shared : state()
const current = entry.timers.get(task.id)
if (current && scope === "global") return
if (current) clearInterval(current)
entry.tasks.set(task.id, task)
void run(task)
const timer = setInterval(() => {
void run(task)
}, task.interval)
timer.unref()
entry.timers.set(task.id, timer)
}
async function run(task: Task) {
log.info("run", { id: task.id })
await task.run().catch((error) => {
log.error("run failed", { id: task.id, error })
})
}
}

View File

@@ -74,8 +74,8 @@ export const ExperimentalRoutes = lazy(() =>
}),
),
async (c) => {
const { provider } = c.req.valid("query")
const tools = await ToolRegistry.tools(provider)
const { provider, model } = c.req.valid("query")
const tools = await ToolRegistry.tools({ providerID: provider, modelID: model })
return c.json(
tools.map((t) => ({
id: t.id,

View File

@@ -275,8 +275,6 @@ export const TuiRoutes = lazy(() =>
session_compact: "session.compact",
messages_page_up: "session.page.up",
messages_page_down: "session.page.down",
messages_line_up: "session.line.up",
messages_line_down: "session.line.down",
messages_half_page_up: "session.half.page.up",
messages_half_page_down: "session.half.page.down",
messages_first: "session.first",

View File

@@ -685,7 +685,10 @@ export namespace SessionPrompt {
},
})
for (const item of await ToolRegistry.tools(input.model.providerID, input.agent)) {
for (const item of await ToolRegistry.tools(
{ modelID: input.model.api.id, providerID: input.model.providerID },
input.agent,
)) {
const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
tools[item.id] = tool({
id: item.id as any,

View File

@@ -5,6 +5,7 @@ You are an interactive CLI tool that helps users with software engineering tasks
## Editing constraints
- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.
- Only add comments if they are necessary to make a non-obvious block easier to understand.
- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).
## Tool usage
- Prefer specialized tools over shell for file operations:

View File

@@ -6,46 +6,9 @@ import { Global } from "../global"
import z from "zod"
import { Config } from "../config/config"
import { Instance } from "../project/instance"
import { Scheduler } from "../scheduler"
export namespace Snapshot {
const log = Log.create({ service: "snapshot" })
const hour = 60 * 60 * 1000
const prune = "7.days"
export function init() {
Scheduler.register({
id: "snapshot.cleanup",
interval: hour,
run: cleanup,
scope: "instance",
})
}
export async function cleanup() {
if (Instance.project.vcs !== "git") return
const cfg = await Config.get()
if (cfg.snapshot === false) return
const git = gitdir()
const exists = await fs
.stat(git)
.then(() => true)
.catch(() => false)
if (!exists) return
const result = await $`git --git-dir ${git} --work-tree ${Instance.worktree} gc --prune=${prune}`
.quiet()
.cwd(Instance.directory)
.nothrow()
if (result.exitCode !== 0) {
log.warn("cleanup failed", {
exitCode: result.exitCode,
stderr: result.stderr.toString(),
stdout: result.stdout.toString(),
})
return
}
log.info("cleanup", { prune })
}
export async function track() {
if (Instance.project.vcs !== "git") return

View File

@@ -0,0 +1,277 @@
import z from "zod"
import * as path from "path"
import * as fs from "fs/promises"
import { Tool } from "./tool"
import { FileTime } from "../file/time"
import { Bus } from "../bus"
import { FileWatcher } from "../file/watcher"
import { Instance } from "../project/instance"
import { Patch } from "../patch"
import { createTwoFilesPatch, diffLines } from "diff"
import { assertExternalDirectory } from "./external-directory"
import { trimDiff } from "./edit"
import { LSP } from "../lsp"
import { Filesystem } from "../util/filesystem"
const PatchParams = z.object({
patchText: z.string().describe("The full patch text that describes all changes to be made"),
})
export const ApplyPatchTool = Tool.define("apply_patch", {
description: "Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.",
parameters: PatchParams,
async execute(params, ctx) {
if (!params.patchText) {
throw new Error("patchText is required")
}
// Parse the patch to get hunks
let hunks: Patch.Hunk[]
try {
const parseResult = Patch.parsePatch(params.patchText)
hunks = parseResult.hunks
} catch (error) {
throw new Error(`apply_patch verification failed: ${error}`)
}
if (hunks.length === 0) {
const normalized = params.patchText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim()
if (normalized === "*** Begin Patch\n*** End Patch") {
throw new Error("patch rejected: empty patch")
}
throw new Error("apply_patch verification failed: no hunks found")
}
// Validate file paths and check permissions
const fileChanges: Array<{
filePath: string
oldContent: string
newContent: string
type: "add" | "update" | "delete" | "move"
movePath?: string
diff: string
additions: number
deletions: number
}> = []
let totalDiff = ""
for (const hunk of hunks) {
const filePath = path.resolve(Instance.directory, hunk.path)
await assertExternalDirectory(ctx, filePath)
switch (hunk.type) {
case "add": {
const oldContent = ""
const newContent =
hunk.contents.length === 0 || hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n`
const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent))
let additions = 0
let deletions = 0
for (const change of diffLines(oldContent, newContent)) {
if (change.added) additions += change.count || 0
if (change.removed) deletions += change.count || 0
}
fileChanges.push({
filePath,
oldContent,
newContent,
type: "add",
diff,
additions,
deletions,
})
totalDiff += diff + "\n"
break
}
case "update": {
// Check if file exists for update
const stats = await fs.stat(filePath).catch(() => null)
if (!stats || stats.isDirectory()) {
throw new Error(`apply_patch verification failed: Failed to read file to update: ${filePath}`)
}
// Read file and update time tracking (like edit tool does)
await FileTime.assert(ctx.sessionID, filePath)
const oldContent = await fs.readFile(filePath, "utf-8")
let newContent = oldContent
// Apply the update chunks to get new content
try {
const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks)
newContent = fileUpdate.content
} catch (error) {
throw new Error(`apply_patch verification failed: ${error}`)
}
const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent))
let additions = 0
let deletions = 0
for (const change of diffLines(oldContent, newContent)) {
if (change.added) additions += change.count || 0
if (change.removed) deletions += change.count || 0
}
const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined
await assertExternalDirectory(ctx, movePath)
fileChanges.push({
filePath,
oldContent,
newContent,
type: hunk.move_path ? "move" : "update",
movePath,
diff,
additions,
deletions,
})
totalDiff += diff + "\n"
break
}
case "delete": {
const contentToDelete = await fs.readFile(filePath, "utf-8").catch((error) => {
throw new Error(`apply_patch verification failed: ${error}`)
})
const deleteDiff = trimDiff(createTwoFilesPatch(filePath, filePath, contentToDelete, ""))
const deletions = contentToDelete.split("\n").length
fileChanges.push({
filePath,
oldContent: contentToDelete,
newContent: "",
type: "delete",
diff: deleteDiff,
additions: 0,
deletions,
})
totalDiff += deleteDiff + "\n"
break
}
}
}
// Check permissions if needed
await ctx.ask({
permission: "edit",
patterns: fileChanges.map((c) => path.relative(Instance.worktree, c.filePath)),
always: ["*"],
metadata: {
diff: totalDiff,
},
})
// Apply the changes
const changedFiles: string[] = []
for (const change of fileChanges) {
switch (change.type) {
case "add":
// Create parent directories (recursive: true is safe on existing/root dirs)
await fs.mkdir(path.dirname(change.filePath), { recursive: true })
await fs.writeFile(change.filePath, change.newContent, "utf-8")
changedFiles.push(change.filePath)
break
case "update":
await fs.writeFile(change.filePath, change.newContent, "utf-8")
changedFiles.push(change.filePath)
break
case "move":
if (change.movePath) {
// Create parent directories (recursive: true is safe on existing/root dirs)
await fs.mkdir(path.dirname(change.movePath), { recursive: true })
await fs.writeFile(change.movePath, change.newContent, "utf-8")
await fs.unlink(change.filePath)
changedFiles.push(change.movePath)
}
break
case "delete":
await fs.unlink(change.filePath)
changedFiles.push(change.filePath)
break
}
// Update file time tracking
FileTime.read(ctx.sessionID, change.filePath)
if (change.movePath) {
FileTime.read(ctx.sessionID, change.movePath)
}
}
// Publish file change events
for (const filePath of changedFiles) {
await Bus.publish(FileWatcher.Event.Updated, { file: filePath, event: "change" })
}
// Notify LSP of file changes and collect diagnostics
for (const change of fileChanges) {
if (change.type === "delete") continue
const target = change.movePath ?? change.filePath
await LSP.touchFile(target, true)
}
const diagnostics = await LSP.diagnostics()
// Generate output summary
const summaryLines = fileChanges.map((change) => {
if (change.type === "add") {
return `A ${path.relative(Instance.worktree, change.filePath)}`
}
if (change.type === "delete") {
return `D ${path.relative(Instance.worktree, change.filePath)}`
}
const target = change.movePath ?? change.filePath
return `M ${path.relative(Instance.worktree, target)}`
})
let output = `Success. Updated the following files:\n${summaryLines.join("\n")}`
// Report LSP errors for changed files
const MAX_DIAGNOSTICS_PER_FILE = 20
for (const change of fileChanges) {
if (change.type === "delete") continue
const target = change.movePath ?? change.filePath
const normalized = Filesystem.normalizePath(target)
const issues = diagnostics[normalized] ?? []
const errors = issues.filter((item) => item.severity === 1)
if (errors.length > 0) {
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
const suffix =
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
output += `\n\nLSP errors detected in ${path.relative(Instance.worktree, target)}, please fix:\n<diagnostics file="${target}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
}
}
// Build per-file metadata for UI rendering
const files = fileChanges.map((change) => ({
filePath: change.filePath,
relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath),
type: change.type,
diff: change.diff,
before: change.oldContent,
after: change.newContent,
additions: change.additions,
deletions: change.deletions,
movePath: change.movePath,
}))
return {
title: output,
metadata: {
diff: totalDiff,
files,
diagnostics,
},
output,
}
},
})

View File

@@ -0,0 +1 @@
Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.

View File

@@ -37,7 +37,7 @@ export const BatchTool = Tool.define("batch", async () => {
const discardedCalls = params.tool_calls.slice(10)
const { ToolRegistry } = await import("./registry")
const availableTools = await ToolRegistry.tools("")
const availableTools = await ToolRegistry.tools({ modelID: "", providerID: "" })
const toolMap = new Map(availableTools.map((t) => [t.id, t]))
const executeCall = async (call: (typeof toolCalls)[0]) => {

View File

@@ -1,201 +0,0 @@
import z from "zod"
import * as path from "path"
import * as fs from "fs/promises"
import { Tool } from "./tool"
import { FileTime } from "../file/time"
import { Bus } from "../bus"
import { FileWatcher } from "../file/watcher"
import { Instance } from "../project/instance"
import { Patch } from "../patch"
import { createTwoFilesPatch } from "diff"
import { assertExternalDirectory } from "./external-directory"
const PatchParams = z.object({
patchText: z.string().describe("The full patch text that describes all changes to be made"),
})
export const PatchTool = Tool.define("patch", {
description:
"Apply a patch to modify multiple files. Supports adding, updating, and deleting files with context-aware changes.",
parameters: PatchParams,
async execute(params, ctx) {
if (!params.patchText) {
throw new Error("patchText is required")
}
// Parse the patch to get hunks
let hunks: Patch.Hunk[]
try {
const parseResult = Patch.parsePatch(params.patchText)
hunks = parseResult.hunks
} catch (error) {
throw new Error(`Failed to parse patch: ${error}`)
}
if (hunks.length === 0) {
throw new Error("No file changes found in patch")
}
// Validate file paths and check permissions
const fileChanges: Array<{
filePath: string
oldContent: string
newContent: string
type: "add" | "update" | "delete" | "move"
movePath?: string
}> = []
let totalDiff = ""
for (const hunk of hunks) {
const filePath = path.resolve(Instance.directory, hunk.path)
await assertExternalDirectory(ctx, filePath)
switch (hunk.type) {
case "add":
if (hunk.type === "add") {
const oldContent = ""
const newContent = hunk.contents
const diff = createTwoFilesPatch(filePath, filePath, oldContent, newContent)
fileChanges.push({
filePath,
oldContent,
newContent,
type: "add",
})
totalDiff += diff + "\n"
}
break
case "update":
// Check if file exists for update
const stats = await fs.stat(filePath).catch(() => null)
if (!stats || stats.isDirectory()) {
throw new Error(`File not found or is directory: ${filePath}`)
}
// Read file and update time tracking (like edit tool does)
await FileTime.assert(ctx.sessionID, filePath)
const oldContent = await fs.readFile(filePath, "utf-8")
let newContent = oldContent
// Apply the update chunks to get new content
try {
const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks)
newContent = fileUpdate.content
} catch (error) {
throw new Error(`Failed to apply update to ${filePath}: ${error}`)
}
const diff = createTwoFilesPatch(filePath, filePath, oldContent, newContent)
const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined
await assertExternalDirectory(ctx, movePath)
fileChanges.push({
filePath,
oldContent,
newContent,
type: hunk.move_path ? "move" : "update",
movePath,
})
totalDiff += diff + "\n"
break
case "delete":
// Check if file exists for deletion
await FileTime.assert(ctx.sessionID, filePath)
const contentToDelete = await fs.readFile(filePath, "utf-8")
const deleteDiff = createTwoFilesPatch(filePath, filePath, contentToDelete, "")
fileChanges.push({
filePath,
oldContent: contentToDelete,
newContent: "",
type: "delete",
})
totalDiff += deleteDiff + "\n"
break
}
}
// Check permissions if needed
await ctx.ask({
permission: "edit",
patterns: fileChanges.map((c) => path.relative(Instance.worktree, c.filePath)),
always: ["*"],
metadata: {
diff: totalDiff,
},
})
// Apply the changes
const changedFiles: string[] = []
for (const change of fileChanges) {
switch (change.type) {
case "add":
// Create parent directories
const addDir = path.dirname(change.filePath)
if (addDir !== "." && addDir !== "/") {
await fs.mkdir(addDir, { recursive: true })
}
await fs.writeFile(change.filePath, change.newContent, "utf-8")
changedFiles.push(change.filePath)
break
case "update":
await fs.writeFile(change.filePath, change.newContent, "utf-8")
changedFiles.push(change.filePath)
break
case "move":
if (change.movePath) {
// Create parent directories for destination
const moveDir = path.dirname(change.movePath)
if (moveDir !== "." && moveDir !== "/") {
await fs.mkdir(moveDir, { recursive: true })
}
// Write to new location
await fs.writeFile(change.movePath, change.newContent, "utf-8")
// Remove original
await fs.unlink(change.filePath)
changedFiles.push(change.movePath)
}
break
case "delete":
await fs.unlink(change.filePath)
changedFiles.push(change.filePath)
break
}
// Update file time tracking
FileTime.read(ctx.sessionID, change.filePath)
if (change.movePath) {
FileTime.read(ctx.sessionID, change.movePath)
}
}
// Publish file change events
for (const filePath of changedFiles) {
await Bus.publish(FileWatcher.Event.Updated, { file: filePath, event: "change" })
}
// Generate output summary
const relativePaths = changedFiles.map((filePath) => path.relative(Instance.worktree, filePath))
const summary = `${fileChanges.length} files changed`
return {
title: summary,
metadata: {
diff: totalDiff,
},
output: `Patch applied successfully. ${summary}:\n${relativePaths.map((p) => ` ${p}`).join("\n")}`,
}
},
})

View File

@@ -1 +0,0 @@
do not use

View File

@@ -26,6 +26,7 @@ import { Log } from "@/util/log"
import { LspTool } from "./lsp"
import { Truncate } from "./truncation"
import { PlanExitTool, PlanEnterTool } from "./plan"
import { ApplyPatchTool } from "./apply_patch"
export namespace ToolRegistry {
const log = Log.create({ service: "tool.registry" })
@@ -108,6 +109,7 @@ export namespace ToolRegistry {
WebSearchTool,
CodeSearchTool,
SkillTool,
ApplyPatchTool,
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
...(config.experimental?.batch_tool === true ? [BatchTool] : []),
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool, PlanEnterTool] : []),
@@ -119,15 +121,28 @@ export namespace ToolRegistry {
return all().then((x) => x.map((t) => t.id))
}
export async function tools(providerID: string, agent?: Agent.Info) {
export async function tools(
model: {
providerID: string
modelID: string
},
agent?: Agent.Info,
) {
const tools = await all()
const result = await Promise.all(
tools
.filter((t) => {
// Enable websearch/codesearch for zen users OR via enable flag
if (t.id === "codesearch" || t.id === "websearch") {
return providerID === "opencode" || Flag.OPENCODE_ENABLE_EXA
return model.providerID === "opencode" || Flag.OPENCODE_ENABLE_EXA
}
// use apply tool in same format as codex
const usePatch =
model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4")
if (t.id === "apply_patch") return usePatch
if (t.id === "edit" || t.id === "write") return !usePatch
return true
})
.map(async (t) => {

View File

@@ -2,9 +2,9 @@ import fs from "fs/promises"
import path from "path"
import { Global } from "../global"
import { Identifier } from "../id/id"
import { lazy } from "../util/lazy"
import { PermissionNext } from "../permission/next"
import type { Agent } from "../agent/agent"
import { Scheduler } from "../scheduler"
export namespace Truncate {
export const MAX_LINES = 2000
@@ -12,7 +12,6 @@ export namespace Truncate {
export const DIR = path.join(Global.Path.data, "tool-output")
export const GLOB = path.join(DIR, "*")
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
const HOUR_MS = 60 * 60 * 1000
export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
@@ -22,15 +21,6 @@ export namespace Truncate {
direction?: "head" | "tail"
}
export function init() {
Scheduler.register({
id: "tool.truncation.cleanup",
interval: HOUR_MS,
run: cleanup,
scope: "global",
})
}
export async function cleanup() {
const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - RETENTION_MS))
const glob = new Bun.Glob("tool_*")
@@ -41,6 +31,8 @@ export namespace Truncate {
}
}
const init = lazy(cleanup)
function hasTaskTool(agent?: Agent.Info): boolean {
if (!agent?.permission) return false
const rule = PermissionNext.evaluate("task", "*", agent.permission)
@@ -89,6 +81,7 @@ export namespace Truncate {
const unit = hitBytes ? "bytes" : "lines"
const preview = out.join("\n")
await init()
const id = Identifier.ascending("tool")
const filepath = path.join(DIR, id)
await Bun.write(Bun.file(filepath), text)

View File

@@ -127,44 +127,6 @@ test("handles environment variable substitution", async () => {
}
})
test("preserves env variables when adding $schema to config", async () => {
const originalEnv = process.env["PRESERVE_VAR"]
process.env["PRESERVE_VAR"] = "secret_value"
try {
await using tmp = await tmpdir({
init: async (dir) => {
// Config without $schema - should trigger auto-add
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
theme: "{env:PRESERVE_VAR}",
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.theme).toBe("secret_value")
// Read the file to verify the env variable was preserved
const content = await Bun.file(path.join(tmp.path, "opencode.json")).text()
expect(content).toContain("{env:PRESERVE_VAR}")
expect(content).not.toContain("secret_value")
expect(content).toContain("$schema")
},
})
} finally {
if (originalEnv !== undefined) {
process.env["PRESERVE_VAR"] = originalEnv
} else {
delete process.env["PRESERVE_VAR"]
}
}
})
test("handles file inclusion substitution", async () => {
await using tmp = await tmpdir({
init: async (dir) => {

View File

@@ -1,73 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Scheduler } from "../src/scheduler"
import { Instance } from "../src/project/instance"
import { tmpdir } from "./fixture/fixture"
describe("Scheduler.register", () => {
const hour = 60 * 60 * 1000
test("defaults to instance scope per directory", async () => {
await using one = await tmpdir({ git: true })
await using two = await tmpdir({ git: true })
const runs = { count: 0 }
const id = "scheduler.instance." + Math.random().toString(36).slice(2)
const task = {
id,
interval: hour,
run: async () => {
runs.count += 1
},
}
await Instance.provide({
directory: one.path,
fn: async () => {
Scheduler.register(task)
await Instance.dispose()
},
})
expect(runs.count).toBe(1)
await Instance.provide({
directory: two.path,
fn: async () => {
Scheduler.register(task)
await Instance.dispose()
},
})
expect(runs.count).toBe(2)
})
test("global scope runs once across instances", async () => {
await using one = await tmpdir({ git: true })
await using two = await tmpdir({ git: true })
const runs = { count: 0 }
const id = "scheduler.global." + Math.random().toString(36).slice(2)
const task = {
id,
interval: hour,
run: async () => {
runs.count += 1
},
scope: "global" as const,
}
await Instance.provide({
directory: one.path,
fn: async () => {
Scheduler.register(task)
await Instance.dispose()
},
})
expect(runs.count).toBe(1)
await Instance.provide({
directory: two.path,
fn: async () => {
Scheduler.register(task)
await Instance.dispose()
},
})
expect(runs.count).toBe(1)
})
})

View File

@@ -0,0 +1,515 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import * as fs from "fs/promises"
import { ApplyPatchTool } from "../../src/tool/apply_patch"
import { Instance } from "../../src/project/instance"
import { FileTime } from "../../src/file/time"
import { tmpdir } from "../fixture/fixture"
const baseCtx = {
sessionID: "test",
messageID: "",
callID: "",
agent: "build",
abort: AbortSignal.any([]),
metadata: () => {},
}
type AskInput = {
permission: string
patterns: string[]
always: string[]
metadata: { diff: string }
}
type ToolCtx = typeof baseCtx & {
ask: (input: AskInput) => Promise<void>
}
const execute = async (params: { patchText: string }, ctx: ToolCtx) => {
const tool = await ApplyPatchTool.init()
return tool.execute(params, ctx)
}
const makeCtx = () => {
const calls: AskInput[] = []
const ctx: ToolCtx = {
...baseCtx,
ask: async (input) => {
calls.push(input)
},
}
return { ctx, calls }
}
describe("tool.apply_patch freeform", () => {
test("requires patchText", async () => {
const { ctx } = makeCtx()
await expect(execute({ patchText: "" }, ctx)).rejects.toThrow("patchText is required")
})
test("rejects invalid patch format", async () => {
const { ctx } = makeCtx()
await expect(execute({ patchText: "invalid patch" }, ctx)).rejects.toThrow("apply_patch verification failed")
})
test("rejects empty patch", async () => {
const { ctx } = makeCtx()
const emptyPatch = "*** Begin Patch\n*** End Patch"
await expect(execute({ patchText: emptyPatch }, ctx)).rejects.toThrow("patch rejected: empty patch")
})
test("applies add/update/delete in one patch", async () => {
await using fixture = await tmpdir()
const { ctx, calls } = makeCtx()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const modifyPath = path.join(fixture.path, "modify.txt")
const deletePath = path.join(fixture.path, "delete.txt")
await fs.writeFile(modifyPath, "line1\nline2\n", "utf-8")
await fs.writeFile(deletePath, "obsolete\n", "utf-8")
FileTime.read(ctx.sessionID, modifyPath)
FileTime.read(ctx.sessionID, deletePath)
const patchText =
"*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Delete File: delete.txt\n*** Update File: modify.txt\n@@\n-line2\n+changed\n*** End Patch"
const result = await execute({ patchText }, ctx)
expect(result.title).toContain("Success. Updated the following files")
expect(result.output).toContain("Success. Updated the following files")
expect(result.metadata.diff).toContain("Index:")
expect(calls.length).toBe(1)
const added = await fs.readFile(path.join(fixture.path, "nested", "new.txt"), "utf-8")
expect(added).toBe("created\n")
expect(await fs.readFile(modifyPath, "utf-8")).toBe("line1\nchanged\n")
await expect(fs.readFile(deletePath, "utf-8")).rejects.toThrow()
},
})
})
test("applies multiple hunks to one file", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const target = path.join(fixture.path, "multi.txt")
await fs.writeFile(target, "line1\nline2\nline3\nline4\n", "utf-8")
FileTime.read(ctx.sessionID, target)
const patchText =
"*** Begin Patch\n*** Update File: multi.txt\n@@\n-line2\n+changed2\n@@\n-line4\n+changed4\n*** End Patch"
await execute({ patchText }, ctx)
expect(await fs.readFile(target, "utf-8")).toBe("line1\nchanged2\nline3\nchanged4\n")
},
})
})
test("inserts lines with insert-only hunk", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const target = path.join(fixture.path, "insert_only.txt")
await fs.writeFile(target, "alpha\nomega\n", "utf-8")
FileTime.read(ctx.sessionID, target)
const patchText = "*** Begin Patch\n*** Update File: insert_only.txt\n@@\n alpha\n+beta\n omega\n*** End Patch"
await execute({ patchText }, ctx)
expect(await fs.readFile(target, "utf-8")).toBe("alpha\nbeta\nomega\n")
},
})
})
test("appends trailing newline on update", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const target = path.join(fixture.path, "no_newline.txt")
await fs.writeFile(target, "no newline at end", "utf-8")
FileTime.read(ctx.sessionID, target)
const patchText =
"*** Begin Patch\n*** Update File: no_newline.txt\n@@\n-no newline at end\n+first line\n+second line\n*** End Patch"
await execute({ patchText }, ctx)
const contents = await fs.readFile(target, "utf-8")
expect(contents.endsWith("\n")).toBe(true)
expect(contents).toBe("first line\nsecond line\n")
},
})
})
test("moves file to a new directory", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const original = path.join(fixture.path, "old", "name.txt")
await fs.mkdir(path.dirname(original), { recursive: true })
await fs.writeFile(original, "old content\n", "utf-8")
FileTime.read(ctx.sessionID, original)
const patchText =
"*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch"
await execute({ patchText }, ctx)
const moved = path.join(fixture.path, "renamed", "dir", "name.txt")
await expect(fs.readFile(original, "utf-8")).rejects.toThrow()
expect(await fs.readFile(moved, "utf-8")).toBe("new content\n")
},
})
})
test("moves file overwriting existing destination", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const original = path.join(fixture.path, "old", "name.txt")
const destination = path.join(fixture.path, "renamed", "dir", "name.txt")
await fs.mkdir(path.dirname(original), { recursive: true })
await fs.mkdir(path.dirname(destination), { recursive: true })
await fs.writeFile(original, "from\n", "utf-8")
await fs.writeFile(destination, "existing\n", "utf-8")
FileTime.read(ctx.sessionID, original)
const patchText =
"*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-from\n+new\n*** End Patch"
await execute({ patchText }, ctx)
await expect(fs.readFile(original, "utf-8")).rejects.toThrow()
expect(await fs.readFile(destination, "utf-8")).toBe("new\n")
},
})
})
test("adds file overwriting existing file", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const target = path.join(fixture.path, "duplicate.txt")
await fs.writeFile(target, "old content\n", "utf-8")
const patchText = "*** Begin Patch\n*** Add File: duplicate.txt\n+new content\n*** End Patch"
await execute({ patchText }, ctx)
expect(await fs.readFile(target, "utf-8")).toBe("new content\n")
},
})
})
test("rejects update when target file is missing", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const patchText = "*** Begin Patch\n*** Update File: missing.txt\n@@\n-nope\n+better\n*** End Patch"
await expect(execute({ patchText }, ctx)).rejects.toThrow(
"apply_patch verification failed: Failed to read file to update",
)
},
})
})
test("rejects delete when file is missing", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const patchText = "*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch"
await expect(execute({ patchText }, ctx)).rejects.toThrow()
},
})
})
test("rejects delete when target is a directory", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const dirPath = path.join(fixture.path, "dir")
await fs.mkdir(dirPath)
const patchText = "*** Begin Patch\n*** Delete File: dir\n*** End Patch"
await expect(execute({ patchText }, ctx)).rejects.toThrow()
},
})
})
test("rejects invalid hunk header", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const patchText = "*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch"
await expect(execute({ patchText }, ctx)).rejects.toThrow("apply_patch verification failed")
},
})
})
test("rejects update with missing context", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const target = path.join(fixture.path, "modify.txt")
await fs.writeFile(target, "line1\nline2\n", "utf-8")
FileTime.read(ctx.sessionID, target)
const patchText = "*** Begin Patch\n*** Update File: modify.txt\n@@\n-missing\n+changed\n*** End Patch"
await expect(execute({ patchText }, ctx)).rejects.toThrow("apply_patch verification failed")
expect(await fs.readFile(target, "utf-8")).toBe("line1\nline2\n")
},
})
})
test("verification failure leaves no side effects", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const patchText =
"*** Begin Patch\n*** Add File: created.txt\n+hello\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch"
await expect(execute({ patchText }, ctx)).rejects.toThrow()
const createdPath = path.join(fixture.path, "created.txt")
await expect(fs.readFile(createdPath, "utf-8")).rejects.toThrow()
},
})
})
test("supports end of file anchor", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const target = path.join(fixture.path, "tail.txt")
await fs.writeFile(target, "alpha\nlast\n", "utf-8")
FileTime.read(ctx.sessionID, target)
const patchText = "*** Begin Patch\n*** Update File: tail.txt\n@@\n-last\n+end\n*** End of File\n*** End Patch"
await execute({ patchText }, ctx)
expect(await fs.readFile(target, "utf-8")).toBe("alpha\nend\n")
},
})
})
test("rejects missing second chunk context", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const target = path.join(fixture.path, "two_chunks.txt")
await fs.writeFile(target, "a\nb\nc\nd\n", "utf-8")
FileTime.read(ctx.sessionID, target)
const patchText = "*** Begin Patch\n*** Update File: two_chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch"
await expect(execute({ patchText }, ctx)).rejects.toThrow()
expect(await fs.readFile(target, "utf-8")).toBe("a\nb\nc\nd\n")
},
})
})
test("disambiguates change context with @@ header", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const target = path.join(fixture.path, "multi_ctx.txt")
await fs.writeFile(target, "fn a\nx=10\ny=2\nfn b\nx=10\ny=20\n", "utf-8")
FileTime.read(ctx.sessionID, target)
const patchText = "*** Begin Patch\n*** Update File: multi_ctx.txt\n@@ fn b\n-x=10\n+x=11\n*** End Patch"
await execute({ patchText }, ctx)
expect(await fs.readFile(target, "utf-8")).toBe("fn a\nx=10\ny=2\nfn b\nx=11\ny=20\n")
},
})
})
test("EOF anchor matches from end of file first", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const target = path.join(fixture.path, "eof_anchor.txt")
// File has duplicate "marker" lines - one in middle, one at end
await fs.writeFile(target, "start\nmarker\nmiddle\nmarker\nend\n", "utf-8")
FileTime.read(ctx.sessionID, target)
// With EOF anchor, should match the LAST "marker" line, not the first
const patchText =
"*** Begin Patch\n*** Update File: eof_anchor.txt\n@@\n-marker\n-end\n+marker-changed\n+end\n*** End of File\n*** End Patch"
await execute({ patchText }, ctx)
// First marker unchanged, second marker changed
expect(await fs.readFile(target, "utf-8")).toBe("start\nmarker\nmiddle\nmarker-changed\nend\n")
},
})
})
test("parses heredoc-wrapped patch", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const patchText = `cat <<'EOF'
*** Begin Patch
*** Add File: heredoc_test.txt
+heredoc content
*** End Patch
EOF`
await execute({ patchText }, ctx)
const content = await fs.readFile(path.join(fixture.path, "heredoc_test.txt"), "utf-8")
expect(content).toBe("heredoc content\n")
},
})
})
test("parses heredoc-wrapped patch without cat", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const patchText = `<<EOF
*** Begin Patch
*** Add File: heredoc_no_cat.txt
+no cat prefix
*** End Patch
EOF`
await execute({ patchText }, ctx)
const content = await fs.readFile(path.join(fixture.path, "heredoc_no_cat.txt"), "utf-8")
expect(content).toBe("no cat prefix\n")
},
})
})
test("matches with trailing whitespace differences", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const target = path.join(fixture.path, "trailing_ws.txt")
// File has trailing spaces on some lines
await fs.writeFile(target, "line1 \nline2\nline3 \n", "utf-8")
FileTime.read(ctx.sessionID, target)
// Patch doesn't have trailing spaces - should still match via rstrip pass
const patchText = "*** Begin Patch\n*** Update File: trailing_ws.txt\n@@\n-line2\n+changed\n*** End Patch"
await execute({ patchText }, ctx)
expect(await fs.readFile(target, "utf-8")).toBe("line1 \nchanged\nline3 \n")
},
})
})
test("matches with leading whitespace differences", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const target = path.join(fixture.path, "leading_ws.txt")
// File has leading spaces
await fs.writeFile(target, " line1\nline2\n line3\n", "utf-8")
FileTime.read(ctx.sessionID, target)
// Patch without leading spaces - should match via trim pass
const patchText = "*** Begin Patch\n*** Update File: leading_ws.txt\n@@\n-line2\n+changed\n*** End Patch"
await execute({ patchText }, ctx)
expect(await fs.readFile(target, "utf-8")).toBe(" line1\nchanged\n line3\n")
},
})
})
test("matches with Unicode punctuation differences", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const target = path.join(fixture.path, "unicode.txt")
// File has fancy Unicode quotes (U+201C, U+201D) and em-dash (U+2014)
const leftQuote = "\u201C"
const rightQuote = "\u201D"
const emDash = "\u2014"
await fs.writeFile(target, `He said ${leftQuote}hello${rightQuote}\nsome${emDash}dash\nend\n`, "utf-8")
FileTime.read(ctx.sessionID, target)
// Patch uses ASCII equivalents - should match via normalized pass
// The replacement uses ASCII quotes from the patch (not preserving Unicode)
const patchText =
'*** Begin Patch\n*** Update File: unicode.txt\n@@\n-He said "hello"\n+He said "hi"\n*** End Patch'
await execute({ patchText }, ctx)
// Result has ASCII quotes because that's what the patch specifies
expect(await fs.readFile(target, "utf-8")).toBe(`He said "hi"\nsome${emDash}dash\nend\n`)
},
})
})
})

View File

@@ -1,261 +0,0 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { PatchTool } from "../../src/tool/patch"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import { PermissionNext } from "../../src/permission/next"
import * as fs from "fs/promises"
const ctx = {
sessionID: "test",
messageID: "",
callID: "",
agent: "build",
abort: AbortSignal.any([]),
metadata: () => {},
ask: async () => {},
}
const patchTool = await PatchTool.init()
describe("tool.patch", () => {
test("should validate required parameters", async () => {
await Instance.provide({
directory: "/tmp",
fn: async () => {
expect(patchTool.execute({ patchText: "" }, ctx)).rejects.toThrow("patchText is required")
},
})
})
test("should validate patch format", async () => {
await Instance.provide({
directory: "/tmp",
fn: async () => {
expect(patchTool.execute({ patchText: "invalid patch" }, ctx)).rejects.toThrow("Failed to parse patch")
},
})
})
test("should handle empty patch", async () => {
await Instance.provide({
directory: "/tmp",
fn: async () => {
const emptyPatch = `*** Begin Patch
*** End Patch`
expect(patchTool.execute({ patchText: emptyPatch }, ctx)).rejects.toThrow("No file changes found in patch")
},
})
})
test.skip("should ask permission for files outside working directory", async () => {
await Instance.provide({
directory: "/tmp",
fn: async () => {
const maliciousPatch = `*** Begin Patch
*** Add File: /etc/passwd
+malicious content
*** End Patch`
patchTool.execute({ patchText: maliciousPatch }, ctx)
// TODO: this sucks
await new Promise((resolve) => setTimeout(resolve, 1000))
const pending = await PermissionNext.list()
expect(pending.find((p) => p.sessionID === ctx.sessionID)).toBeDefined()
},
})
})
test("should handle simple add file operation", async () => {
await using fixture = await tmpdir()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const patchText = `*** Begin Patch
*** Add File: test-file.txt
+Hello World
+This is a test file
*** End Patch`
const result = await patchTool.execute({ patchText }, ctx)
expect(result.title).toContain("files changed")
expect(result.metadata.diff).toBeDefined()
expect(result.output).toContain("Patch applied successfully")
// Verify file was created
const filePath = path.join(fixture.path, "test-file.txt")
const content = await fs.readFile(filePath, "utf-8")
expect(content).toBe("Hello World\nThis is a test file")
},
})
})
test("should handle file with context update", async () => {
await using fixture = await tmpdir()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const patchText = `*** Begin Patch
*** Add File: config.js
+const API_KEY = "test-key"
+const DEBUG = false
+const VERSION = "1.0"
*** End Patch`
const result = await patchTool.execute({ patchText }, ctx)
expect(result.title).toContain("files changed")
expect(result.metadata.diff).toBeDefined()
expect(result.output).toContain("Patch applied successfully")
// Verify file was created with correct content
const filePath = path.join(fixture.path, "config.js")
const content = await fs.readFile(filePath, "utf-8")
expect(content).toBe('const API_KEY = "test-key"\nconst DEBUG = false\nconst VERSION = "1.0"')
},
})
})
test("should handle multiple file operations", async () => {
await using fixture = await tmpdir()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const patchText = `*** Begin Patch
*** Add File: file1.txt
+Content of file 1
*** Add File: file2.txt
+Content of file 2
*** Add File: file3.txt
+Content of file 3
*** End Patch`
const result = await patchTool.execute({ patchText }, ctx)
expect(result.title).toContain("3 files changed")
expect(result.metadata.diff).toBeDefined()
expect(result.output).toContain("Patch applied successfully")
// Verify all files were created
for (let i = 1; i <= 3; i++) {
const filePath = path.join(fixture.path, `file${i}.txt`)
const content = await fs.readFile(filePath, "utf-8")
expect(content).toBe(`Content of file ${i}`)
}
},
})
})
test("should create parent directories when adding nested files", async () => {
await using fixture = await tmpdir()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const patchText = `*** Begin Patch
*** Add File: deep/nested/file.txt
+Deep nested content
*** End Patch`
const result = await patchTool.execute({ patchText }, ctx)
expect(result.title).toContain("files changed")
expect(result.output).toContain("Patch applied successfully")
// Verify nested file was created
const nestedPath = path.join(fixture.path, "deep", "nested", "file.txt")
const exists = await fs
.access(nestedPath)
.then(() => true)
.catch(() => false)
expect(exists).toBe(true)
const content = await fs.readFile(nestedPath, "utf-8")
expect(content).toBe("Deep nested content")
},
})
})
test("should generate proper unified diff in metadata", async () => {
await using fixture = await tmpdir()
await Instance.provide({
directory: fixture.path,
fn: async () => {
// First create a file with simple content
const patchText1 = `*** Begin Patch
*** Add File: test.txt
+line 1
+line 2
+line 3
*** End Patch`
await patchTool.execute({ patchText: patchText1 }, ctx)
// Now create an update patch
const patchText2 = `*** Begin Patch
*** Update File: test.txt
@@
line 1
-line 2
+line 2 updated
line 3
*** End Patch`
const result = await patchTool.execute({ patchText: patchText2 }, ctx)
expect(result.metadata.diff).toBeDefined()
expect(result.metadata.diff).toContain("@@")
expect(result.metadata.diff).toContain("-line 2")
expect(result.metadata.diff).toContain("+line 2 updated")
},
})
})
test("should handle complex patch with multiple operations", async () => {
await using fixture = await tmpdir()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const patchText = `*** Begin Patch
*** Add File: new.txt
+This is a new file
+with multiple lines
*** Add File: existing.txt
+old content
+new line
+more content
*** Add File: config.json
+{
+ "version": "1.0",
+ "debug": true
+}
*** End Patch`
const result = await patchTool.execute({ patchText }, ctx)
expect(result.title).toContain("3 files changed")
expect(result.metadata.diff).toBeDefined()
expect(result.output).toContain("Patch applied successfully")
// Verify all files were created
const newPath = path.join(fixture.path, "new.txt")
const newContent = await fs.readFile(newPath, "utf-8")
expect(newContent).toBe("This is a new file\nwith multiple lines")
const existingPath = path.join(fixture.path, "existing.txt")
const existingContent = await fs.readFile(existingPath, "utf-8")
expect(existingContent).toBe("old content\nnew line\nmore content")
const configPath = path.join(fixture.path, "config.json")
const configContent = await fs.readFile(configPath, "utf-8")
expect(configContent).toBe('{\n "version": "1.0",\n "debug": true\n}')
},
})
})
})

View File

@@ -842,14 +842,6 @@ export type KeybindsConfig = {
* Scroll messages down by one page
*/
messages_page_down?: string
/**
* Scroll messages up by one line
*/
messages_line_up?: string
/**
* Scroll messages down by one line
*/
messages_line_down?: string
/**
* Scroll messages up by half page
*/

View File

@@ -651,8 +651,6 @@ export type EventTuiCommandExecute = {
| "session.compact"
| "session.page.up"
| "session.page.down"
| "session.line.up"
| "session.line.down"
| "session.half.page.up"
| "session.half.page.down"
| "session.first"
@@ -1021,14 +1019,6 @@ export type KeybindsConfig = {
* Scroll messages down by one page
*/
messages_page_down?: string
/**
* Scroll messages up by one line
*/
messages_line_up?: string
/**
* Scroll messages down by one line
*/
messages_line_down?: string
/**
* Scroll messages up by half page
*/

View File

@@ -7411,8 +7411,6 @@
"session.compact",
"session.page.up",
"session.page.down",
"session.line.up",
"session.line.down",
"session.half.page.up",
"session.half.page.down",
"session.first",
@@ -8284,22 +8282,12 @@
},
"messages_page_up": {
"description": "Scroll messages up by one page",
"default": "pageup,ctrl+alt+b",
"default": "pageup",
"type": "string"
},
"messages_page_down": {
"description": "Scroll messages down by one page",
"default": "pagedown,ctrl+alt+f",
"type": "string"
},
"messages_line_up": {
"description": "Scroll messages up by one line",
"default": "ctrl+alt+y",
"type": "string"
},
"messages_line_down": {
"description": "Scroll messages down by one line",
"default": "ctrl+alt+e",
"default": "pagedown",
"type": "string"
},
"messages_half_page_up": {

View File

@@ -13,21 +13,6 @@ export const Mark = (props: { class?: string }) => {
)
}
export const Splash = (props: { class?: string }) => {
return (
<svg
data-component="logo-splash"
classList={{ [props.class ?? ""]: !!props.class }}
viewBox="0 0 80 100"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M60 80H20V40H60V80Z" fill="var(--icon-base)" />
<path d="M60 20H20V80H60V20ZM80 100H0V0H80V100Z" fill="var(--icon-strong-base)" />
</svg>
)
}
export const Logo = (props: { class?: string }) => {
return (
<svg

View File

@@ -689,3 +689,75 @@
}
}
}
[data-component="apply-patch-files"] {
display: flex;
flex-direction: column;
}
[data-component="apply-patch-file"] {
display: flex;
flex-direction: column;
border-top: 1px solid var(--border-weaker-base);
&:first-child {
border-top: 1px solid var(--border-weaker-base);
}
[data-slot="apply-patch-file-header"] {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background-color: var(--surface-inset-base);
}
[data-slot="apply-patch-file-action"] {
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
color: var(--text-base);
flex-shrink: 0;
&[data-type="delete"] {
color: var(--text-critical-base);
}
&[data-type="add"] {
color: var(--text-success-base);
}
&[data-type="move"] {
color: var(--text-warning-base);
}
}
[data-slot="apply-patch-file-path"] {
font-family: var(--font-family-mono);
font-size: var(--font-size-small);
color: var(--text-weak);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-grow: 1;
}
[data-slot="apply-patch-deletion-count"] {
font-family: var(--font-family-mono);
font-size: var(--font-size-small);
color: var(--text-critical-base);
flex-shrink: 0;
}
}
[data-component="apply-patch-file-diff"] {
max-height: 420px;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}

View File

@@ -233,6 +233,12 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo {
title: "Write",
subtitle: input.filePath ? getFilename(input.filePath) : undefined,
}
case "apply_patch":
return {
icon: "code-lines",
title: "Patch",
subtitle: input.files?.length ? `${input.files.length} file${input.files.length > 1 ? "s" : ""}` : undefined,
}
case "todowrite":
return {
icon: "checklist",
@@ -1027,6 +1033,94 @@ ToolRegistry.register({
},
})
interface ApplyPatchFile {
filePath: string
relativePath: string
type: "add" | "update" | "delete" | "move"
diff: string
before: string
after: string
additions: number
deletions: number
movePath?: string
}
ToolRegistry.register({
name: "apply_patch",
render(props) {
const diffComponent = useDiffComponent()
const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[])
const subtitle = createMemo(() => {
const count = files().length
if (count === 0) return ""
return `${count} file${count > 1 ? "s" : ""}`
})
return (
<BasicTool
{...props}
icon="code-lines"
trigger={{
title: "Patch",
subtitle: subtitle(),
}}
>
<Show when={files().length > 0}>
<div data-component="apply-patch-files">
<For each={files()}>
{(file) => (
<div data-component="apply-patch-file">
<div data-slot="apply-patch-file-header">
<Switch>
<Match when={file.type === "delete"}>
<span data-slot="apply-patch-file-action" data-type="delete">
Deleted
</span>
</Match>
<Match when={file.type === "add"}>
<span data-slot="apply-patch-file-action" data-type="add">
Created
</span>
</Match>
<Match when={file.type === "move"}>
<span data-slot="apply-patch-file-action" data-type="move">
Moved
</span>
</Match>
<Match when={file.type === "update"}>
<span data-slot="apply-patch-file-action" data-type="update">
Patched
</span>
</Match>
</Switch>
<span data-slot="apply-patch-file-path">{file.relativePath}</span>
<Show when={file.type !== "delete"}>
<DiffChanges changes={{ additions: file.additions, deletions: file.deletions }} />
</Show>
<Show when={file.type === "delete"}>
<span data-slot="apply-patch-deletion-count">-{file.deletions}</span>
</Show>
</div>
<Show when={file.type !== "delete"}>
<div data-component="apply-patch-file-diff">
<Dynamic
component={diffComponent}
before={{ name: file.filePath, contents: file.before }}
after={{ name: file.filePath, contents: file.after }}
/>
</div>
</Show>
</div>
)}
</For>
</div>
</Show>
</BasicTool>
)
},
})
ToolRegistry.register({
name: "todowrite",
render(props) {

View File

@@ -5,8 +5,6 @@ import type { ComponentProps } from "solid-js"
export interface TooltipProps extends ComponentProps<typeof KobalteTooltip> {
value: JSX.Element
class?: string
contentClass?: string
contentStyle?: JSX.CSSProperties
inactive?: boolean
}
@@ -32,7 +30,7 @@ export function TooltipKeybind(props: TooltipKeybindProps) {
export function Tooltip(props: TooltipProps) {
const [open, setOpen] = createSignal(false)
const [local, others] = splitProps(props, ["children", "class", "contentClass", "contentStyle", "inactive"])
const [local, others] = splitProps(props, ["children", "class", "inactive"])
const c = children(() => local.children)
@@ -60,12 +58,7 @@ export function Tooltip(props: TooltipProps) {
{c()}
</KobalteTooltip.Trigger>
<KobalteTooltip.Portal>
<KobalteTooltip.Content
data-component="tooltip"
data-placement={props.placement}
class={local.contentClass}
style={local.contentStyle}
>
<KobalteTooltip.Content data-component="tooltip" data-placement={props.placement}>
{others.value}
{/* <KobalteTooltip.Arrow data-slot="tooltip-arrow" /> */}
</KobalteTooltip.Content>

View File

@@ -180,10 +180,8 @@ jobs:
- uses: anomalyco/opencode/github@latest
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
model: anthropic/claude-sonnet-4-20250514
use_github_token: true
prompt: |
Review this pull request:
- Check for code quality issues

View File

@@ -31,10 +31,8 @@ OpenCode has a list of keybinds that you can customize through the OpenCode conf
"session_child_cycle": "<leader>right",
"session_child_cycle_reverse": "<leader>left",
"session_parent": "<leader>up",
"messages_page_up": "pageup,ctrl+alt+b",
"messages_page_down": "pagedown,ctrl+alt+f",
"messages_line_up": "ctrl+alt+y",
"messages_line_down": "ctrl+alt+e",
"messages_page_up": "pageup",
"messages_page_down": "pagedown",
"messages_half_page_up": "ctrl+alt+u",
"messages_half_page_down": "ctrl+alt+d",
"messages_first": "ctrl+g,home",