Merge branch 'dev' into feat/canceled-prompts-in-history

This commit is contained in:
Ariane Emory
2026-02-07 13:34:42 -05:00
59 changed files with 640 additions and 268 deletions
+6 -4
View File
@@ -29,6 +29,7 @@ import {
} from "@agentclientprotocol/sdk"
import { Log } from "../util/log"
import { pathToFileURL } from "bun"
import { ACPSessionManager } from "./session"
import type { ACPConfig } from "./types"
import { Provider } from "../provider/provider"
@@ -986,7 +987,7 @@ export namespace ACP {
type: "image",
mimeType: effectiveMime,
data: base64Data,
uri: `file://${filename}`,
uri: pathToFileURL(filename).href,
},
},
})
@@ -996,13 +997,14 @@ export namespace ACP {
} else {
// Non-image: text types get decoded, binary types stay as blob
const isText = effectiveMime.startsWith("text/") || effectiveMime === "application/json"
const fileUri = pathToFileURL(filename).href
const resource = isText
? {
uri: `file://${filename}`,
uri: fileUri,
mimeType: effectiveMime,
text: Buffer.from(base64Data, "base64").toString("utf-8"),
}
: { uri: `file://${filename}`, mimeType: effectiveMime, blob: base64Data }
: { uri: fileUri, mimeType: effectiveMime, blob: base64Data }
await this.connection
.sessionUpdate({
@@ -1544,7 +1546,7 @@ export namespace ACP {
const name = path.split("/").pop() || path
return {
type: "file",
url: `file://${path}`,
url: pathToFileURL(path).href,
filename: name,
mime: "text/plain",
}
+19 -5
View File
@@ -1,5 +1,6 @@
import type { Argv } from "yargs"
import path from "path"
import { pathToFileURL } from "bun"
import { UI } from "../ui"
import { cmd } from "./cmd"
import { Flag } from "../../flag/flag"
@@ -236,6 +237,10 @@ export const RunCommand = cmd({
describe: "session id to continue",
type: "string",
})
.option("fork", {
describe: "fork the session before continuing (requires --continue or --session)",
type: "boolean",
})
.option("share", {
type: "boolean",
describe: "share the session",
@@ -310,7 +315,7 @@ export const RunCommand = cmd({
files.push({
type: "file",
url: `file://${resolvedPath}`,
url: pathToFileURL(resolvedPath).href,
filename: path.basename(resolvedPath),
mime,
})
@@ -324,6 +329,11 @@ export const RunCommand = cmd({
process.exit(1)
}
if (args.fork && !args.continue && !args.session) {
UI.error("--fork requires --continue or --session")
process.exit(1)
}
const rules: PermissionNext.Ruleset = [
{
permission: "question",
@@ -349,11 +359,15 @@ export const RunCommand = cmd({
}
async function session(sdk: OpencodeClient) {
if (args.continue) {
const result = await sdk.session.list()
return result.data?.find((s) => !s.parentID)?.id
const baseID = args.continue ? (await sdk.session.list()).data?.find((s) => !s.parentID)?.id : args.session
if (baseID && args.fork) {
const forked = await sdk.session.fork({ sessionID: baseID })
return forked.data?.id
}
if (args.session) return args.session
if (baseID) return baseID
const name = title()
const result = await sdk.session.create({ title: name, permission: rules })
return result.data?.id
+29 -2
View File
@@ -250,7 +250,8 @@ function App() {
})
local.model.set({ providerID, modelID }, { recent: true })
}
if (args.sessionID) {
// Handle --session without --fork immediately (fork is handled in createEffect below)
if (args.sessionID && !args.fork) {
route.navigate({
type: "session",
sessionID: args.sessionID,
@@ -268,10 +269,36 @@ function App() {
.find((x) => x.parentID === undefined)?.id
if (match) {
continued = true
route.navigate({ type: "session", sessionID: match })
if (args.fork) {
sdk.client.session.fork({ sessionID: match }).then((result) => {
if (result.data?.id) {
route.navigate({ type: "session", sessionID: result.data.id })
} else {
toast.show({ message: "Failed to fork session", variant: "error" })
}
})
} else {
route.navigate({ type: "session", sessionID: match })
}
}
})
// Handle --session with --fork: wait for sync to be fully complete before forking
// (session list loads in non-blocking phase for --session, so we must wait for "complete"
// to avoid a race where reconcile overwrites the newly forked session)
let forked = false
createEffect(() => {
if (forked || sync.status !== "complete" || !args.sessionID || !args.fork) return
forked = true
sdk.client.session.fork({ sessionID: args.sessionID }).then((result) => {
if (result.data?.id) {
route.navigate({ type: "session", sessionID: result.data.id })
} else {
toast.show({ message: "Failed to fork session", variant: "error" })
}
})
})
createEffect(
on(
() => sync.status === "complete" && sync.data.provider.length === 0,
@@ -1,4 +1,5 @@
import { TextAttributes } from "@opentui/core"
import { fileURLToPath } from "bun"
import { useTheme } from "../context/theme"
import { useDialog } from "@tui/ui/dialog"
import { useSync } from "@tui/context/sync"
@@ -19,7 +20,7 @@ export function DialogStatus() {
const list = sync.data.config.plugin ?? []
const result = list.map((value) => {
if (value.startsWith("file://")) {
const path = value.substring("file://".length)
const path = fileURLToPath(value)
const parts = path.split("/")
const filename = parts.pop() || path
if (!filename.includes(".")) return { name: filename }
@@ -1,4 +1,5 @@
import type { BoxRenderable, TextareaRenderable, KeyEvent, ScrollBoxRenderable } from "@opentui/core"
import { pathToFileURL } from "bun"
import fuzzysort from "fuzzysort"
import { firstBy } from "remeda"
import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js"
@@ -246,17 +247,17 @@ export function Autocomplete(props: {
const width = props.anchor().width - 4
options.push(
...sortedFiles.map((item): AutocompleteOption => {
let url = `file://${process.cwd()}/${item}`
const fullPath = `${process.cwd()}/${item}`
const urlObj = pathToFileURL(fullPath)
let filename = item
if (lineRange && !item.endsWith("/")) {
filename = `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}`
const urlObj = new URL(url)
urlObj.searchParams.set("start", String(lineRange.startLine))
if (lineRange.endLine !== undefined) {
urlObj.searchParams.set("end", String(lineRange.endLine))
}
url = urlObj.toString()
}
const url = urlObj.href
const isDir = item.endsWith("/")
return {
@@ -6,6 +6,7 @@ export interface Args {
prompt?: string
continue?: boolean
sessionID?: string
fork?: boolean
}
export const { use: useArgs, provider: ArgsProvider } = createSimpleContext({
@@ -64,6 +64,10 @@ export const TuiThreadCommand = cmd({
type: "string",
describe: "session id to continue",
})
.option("fork", {
type: "boolean",
describe: "fork the session when continuing (use with --continue or --session)",
})
.option("prompt", {
type: "string",
describe: "prompt to use",
@@ -73,6 +77,11 @@ export const TuiThreadCommand = cmd({
describe: "agent to use",
}),
handler: async (args) => {
if (args.fork && !args.continue && !args.session) {
UI.error("--fork requires --continue or --session")
process.exit(1)
}
// Resolve relative paths against PWD to preserve behavior when using --cwd flag
const baseCwd = process.env.PWD ?? process.cwd()
const cwd = args.project ? path.resolve(baseCwd, args.project) : process.cwd()
@@ -150,6 +159,7 @@ export const TuiThreadCommand = cmd({
agent: args.agent,
model: args.model,
prompt,
fork: args.fork,
},
onExit: async () => {
await client.call("shutdown", undefined)
+8 -7
View File
@@ -33,6 +33,8 @@ import { proxied } from "@/util/proxied"
import { iife } from "@/util/iife"
export namespace Config {
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
const log = Log.create({ service: "config" })
// Managed settings directory for enterprise deployments (highest priority, admin-controlled)
@@ -653,7 +655,7 @@ export namespace Config {
template: z.string(),
description: z.string().optional(),
agent: z.string().optional(),
model: z.string().optional(),
model: ModelId.optional(),
subtask: z.boolean().optional(),
})
export type Command = z.infer<typeof Command>
@@ -669,7 +671,7 @@ export namespace Config {
export const Agent = z
.object({
model: z.string().optional(),
model: ModelId.optional(),
variant: z
.string()
.optional()
@@ -1040,11 +1042,10 @@ export namespace Config {
.array(z.string())
.optional()
.describe("When set, ONLY these providers will be enabled. All other providers will be ignored"),
model: z.string().describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
small_model: z
.string()
.describe("Small model to use for tasks like title generation in the format of provider/model")
.optional(),
model: ModelId.describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
small_model: ModelId.describe(
"Small model to use for tasks like title generation in the format of provider/model",
).optional(),
default_agent: z
.string()
.optional()
+2 -2
View File
@@ -3,7 +3,7 @@ import { Bus } from "@/bus"
import { Log } from "../util/log"
import { LSPClient } from "./client"
import path from "path"
import { pathToFileURL } from "url"
import { pathToFileURL, fileURLToPath } from "url"
import { LSPServer } from "./server"
import z from "zod"
import { Config } from "../config/config"
@@ -369,7 +369,7 @@ export namespace LSP {
}
export async function documentSymbol(uri: string) {
const file = new URL(uri).pathname
const file = fileURLToPath(uri)
return run(file, (client) =>
client.connection
.sendRequest("textDocument/documentSymbol", {
@@ -646,6 +646,7 @@ export namespace ProviderTransform {
if (input.model.api.id.includes("gpt-5") && !input.model.api.id.includes("gpt-5-chat")) {
if (!input.model.api.id.includes("gpt-5-pro")) {
result["reasoningEffort"] = "medium"
result["reasoningSummary"] = "auto"
}
// Only set textVerbosity for non-chat gpt-5.x models
@@ -539,7 +539,7 @@ export const SessionRoutes = lazy(() =>
},
auto: body.auto,
})
await SessionPrompt.loop(sessionID)
await SessionPrompt.loop({ sessionID })
return c.json(true)
},
)
@@ -108,6 +108,7 @@ export namespace SessionCompaction {
sessionID: input.sessionID,
mode: "compaction",
agent: "compaction",
variant: userMessage.variant,
summary: true,
path: {
cwd: Instance.directory,
@@ -387,6 +387,7 @@ export namespace MessageV2 {
write: z.number(),
}),
}),
variant: z.string().optional(),
finish: z.string().optional(),
}).meta({
ref: "AssistantMessage",
+34 -7
View File
@@ -32,7 +32,7 @@ import { Flag } from "../flag/flag"
import { ulid } from "ulid"
import { spawn } from "child_process"
import { Command } from "../command"
import { $, fileURLToPath } from "bun"
import { $, fileURLToPath, pathToFileURL } from "bun"
import { ConfigMarkdown } from "../config/markdown"
import { SessionSummary } from "./summary"
import { NamedError } from "@opencode-ai/util/error"
@@ -174,7 +174,7 @@ export namespace SessionPrompt {
return message
}
return loop(input.sessionID)
return loop({ sessionID: input.sessionID })
})
export async function resolvePromptParts(template: string): Promise<PromptInput["parts"]> {
@@ -210,7 +210,7 @@ export namespace SessionPrompt {
if (stats.isDirectory()) {
parts.push({
type: "file",
url: `file://${filepath}`,
url: pathToFileURL(filepath).href,
filename: name,
mime: "application/x-directory",
})
@@ -219,7 +219,7 @@ export namespace SessionPrompt {
parts.push({
type: "file",
url: `file://${filepath}`,
url: pathToFileURL(filepath).href,
filename: name,
mime: "text/plain",
})
@@ -239,6 +239,13 @@ export namespace SessionPrompt {
return controller.signal
}
function resume(sessionID: string) {
const s = state()
if (!s[sessionID]) return
return s[sessionID].abort.signal
}
export function cancel(sessionID: string) {
log.info("cancel", { sessionID })
const s = state()
@@ -253,8 +260,14 @@ export namespace SessionPrompt {
return
}
export const loop = fn(Identifier.schema("session"), async (sessionID) => {
const abort = start(sessionID)
export const LoopInput = z.object({
sessionID: Identifier.schema("session"),
resume_existing: z.boolean().optional(),
})
export const loop = fn(LoopInput, async (input) => {
const { sessionID, resume_existing } = input
const abort = resume_existing ? resume(sessionID) : start(sessionID)
if (!abort) {
return new Promise<MessageV2.WithParts>((resolve, reject) => {
const callbacks = state()[sessionID].callbacks
@@ -323,6 +336,7 @@ export namespace SessionPrompt {
sessionID,
mode: task.agent,
agent: task.agent,
variant: lastUser.variant,
path: {
cwd: Instance.directory,
root: Instance.worktree,
@@ -526,6 +540,7 @@ export namespace SessionPrompt {
role: "assistant",
mode: agent.name,
agent: agent.name,
variant: lastUser.variant,
path: {
cwd: Instance.directory,
root: Instance.worktree,
@@ -1366,7 +1381,19 @@ NOTE: At any point in time through this workflow you should feel free to ask the
if (!abort) {
throw new Session.BusyError(input.sessionID)
}
using _ = defer(() => cancel(input.sessionID))
using _ = defer(() => {
// If no queued callbacks, cancel (the default)
const callbacks = state()[input.sessionID]?.callbacks ?? []
if (callbacks.length === 0) {
cancel(input.sessionID)
} else {
// Otherwise, trigger the session loop to process queued items
loop({ sessionID: input.sessionID, resume_existing: true }).catch((error) => {
log.error("session loop failed to resume after shell command", { sessionID: input.sessionID, error })
})
}
})
const session = await Session.get(input.sessionID)
if (session.revert) {
+6 -1
View File
@@ -411,8 +411,13 @@ export namespace Worktree {
if (key === directory) return item
}
})()
if (!entry?.path) {
throw new RemoveFailedError({ message: "Worktree not found" })
const directoryExists = await exists(directory)
if (directoryExists) {
await fs.rm(directory, { recursive: true, force: true })
}
return true
}
const removed = await $`git worktree remove --force ${entry.path}`.quiet().nothrow().cwd(Instance.worktree)
@@ -0,0 +1,56 @@
import path from "path"
import { describe, expect, test } from "bun:test"
import { fileURLToPath } from "url"
import { Instance } from "../../src/project/instance"
import { Log } from "../../src/util/log"
import { Session } from "../../src/session"
import { SessionPrompt } from "../../src/session/prompt"
import { MessageV2 } from "../../src/session/message-v2"
import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
describe("session.prompt special characters", () => {
test("handles filenames with # character", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
await Bun.write(path.join(dir, "file#name.txt"), "special content\n")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const template = "Read @file#name.txt"
const parts = await SessionPrompt.resolvePromptParts(template)
const fileParts = parts.filter((part) => part.type === "file")
expect(fileParts.length).toBe(1)
expect(fileParts[0].filename).toBe("file#name.txt")
// Verify the URL is properly encoded (# should be %23)
expect(fileParts[0].url).toContain("%23")
// Verify the URL can be correctly converted back to a file path
const decodedPath = fileURLToPath(fileParts[0].url)
expect(decodedPath).toBe(path.join(tmp.path, "file#name.txt"))
const message = await SessionPrompt.prompt({
sessionID: session.id,
parts,
noReply: true,
})
const stored = await MessageV2.get({ sessionID: session.id, messageID: message.info.id })
// Verify the file content was read correctly
const textParts = stored.parts.filter((part) => part.type === "text")
const hasContent = textParts.some((part) => part.text.includes("special content"))
expect(hasContent).toBe(true)
await Session.remove(session.id)
},
})
})
})
@@ -0,0 +1,60 @@
import { describe, test, expect } from "bun:test"
import { Discovery } from "../../src/skill/discovery"
import path from "path"
const CLOUDFLARE_SKILLS_URL = "https://developers.cloudflare.com/.well-known/skills/"
describe("Discovery.pull", () => {
test("downloads skills from cloudflare url", async () => {
const dirs = await Discovery.pull(CLOUDFLARE_SKILLS_URL)
expect(dirs.length).toBeGreaterThan(0)
for (const dir of dirs) {
expect(dir).toStartWith(Discovery.dir())
const md = path.join(dir, "SKILL.md")
expect(await Bun.file(md).exists()).toBe(true)
}
}, 30_000)
test("url without trailing slash works", async () => {
const dirs = await Discovery.pull(CLOUDFLARE_SKILLS_URL.replace(/\/$/, ""))
expect(dirs.length).toBeGreaterThan(0)
for (const dir of dirs) {
const md = path.join(dir, "SKILL.md")
expect(await Bun.file(md).exists()).toBe(true)
}
}, 30_000)
test("returns empty array for invalid url", async () => {
const dirs = await Discovery.pull("https://example.invalid/.well-known/skills/")
expect(dirs).toEqual([])
})
test("returns empty array for non-json response", async () => {
const dirs = await Discovery.pull("https://example.com/")
expect(dirs).toEqual([])
})
test("downloads reference files alongside SKILL.md", async () => {
const dirs = await Discovery.pull(CLOUDFLARE_SKILLS_URL)
// find a skill dir that should have reference files (e.g. agents-sdk)
const agentsSdk = dirs.find((d) => d.endsWith("/agents-sdk"))
if (agentsSdk) {
const refs = path.join(agentsSdk, "references")
expect(await Bun.file(path.join(agentsSdk, "SKILL.md")).exists()).toBe(true)
// agents-sdk has reference files per the index
const refDir = await Array.fromAsync(new Bun.Glob("**/*.md").scan({ cwd: refs, onlyFiles: true }))
expect(refDir.length).toBeGreaterThan(0)
}
}, 30_000)
test("caches downloaded files on second pull", async () => {
// first pull to populate cache
const first = await Discovery.pull(CLOUDFLARE_SKILLS_URL)
expect(first.length).toBeGreaterThan(0)
// second pull should return same results from cache
const second = await Discovery.pull(CLOUDFLARE_SKILLS_URL)
expect(second.length).toBe(first.length)
expect(second.sort()).toEqual(first.sort())
}, 60_000)
})