mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-27 05:35:46 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e64d2c0bcc |
@@ -16,6 +16,7 @@ import { RipgrepBinary } from "./ripgrep/binary"
|
||||
*/
|
||||
|
||||
const ERROR_BYTES = 8 * 1024
|
||||
const MAX_RECORD_BYTES = 64 * 1024
|
||||
const MAX_SUBMATCHES = 100
|
||||
|
||||
const RawMatch = Schema.Struct({
|
||||
@@ -230,8 +231,10 @@ const layer = Layer.effect(
|
||||
input.file ?? ".",
|
||||
],
|
||||
parse: (line) =>
|
||||
decodeJsonRecord(line).pipe(
|
||||
Effect.mapError((cause) => failure("Invalid ripgrep JSON output", cause)),
|
||||
(Buffer.byteLength(line, "utf8") > MAX_RECORD_BYTES
|
||||
? Effect.fail(failure(`Ripgrep JSON record exceeded ${MAX_RECORD_BYTES} bytes`))
|
||||
: decodeJsonRecord(line).pipe(Effect.mapError((cause) => failure("Invalid ripgrep JSON output", cause)))
|
||||
).pipe(
|
||||
Effect.flatMap((json) => {
|
||||
if (!json || typeof json !== "object" || !("type" in json) || json.type !== "match")
|
||||
return Effect.succeed(undefined)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { it } from "./lib/effect"
|
||||
@@ -27,6 +29,13 @@ describe("CodeMode", () => {
|
||||
signature: "tools.echo(input: {\n text: string,\n}): Promise<string>",
|
||||
},
|
||||
])
|
||||
}).pipe(Effect.scoped, Effect.provide(AppNodeBuilder.build(Tool.node))),
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(Tool.node, [
|
||||
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
||||
]),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, expect } from "bun:test"
|
||||
import { CodeModeCatalog } from "@opencode-ai/core/codemode/catalog"
|
||||
import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { it } from "../lib/effect"
|
||||
@@ -79,7 +81,9 @@ describe("CodeModeInstructions", () => {
|
||||
output: Schema.String,
|
||||
execute: () => Effect.succeed({ output: "zeta" }),
|
||||
})
|
||||
const layer = AppNodeBuilder.build(Tool.node)
|
||||
const layer = AppNodeBuilder.build(Tool.node, [
|
||||
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
||||
])
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const tools = yield* Tool.Service
|
||||
|
||||
@@ -158,43 +158,6 @@ describe("PluginSupervisor config", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reloads an auto-discovered plugin when its file changes", () =>
|
||||
withLocation(
|
||||
undefined,
|
||||
Effect.gen(function* () {
|
||||
yield* ready()
|
||||
const agents = yield* Agent.Service
|
||||
const bus = yield* Bus.Service
|
||||
const location = yield* Location.Service
|
||||
const plugins = yield* Plugin.Service
|
||||
const file = path.join(location.directory, ".opencode", "plugin", "mutable.ts")
|
||||
const first = (yield* plugins.list()).find((plugin) => plugin.id === "mutable-plugin")?.id
|
||||
|
||||
expect(first).toBeDefined()
|
||||
expect((yield* agents.get(Agent.ID.make("mutable")))?.description).toBe("first")
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.writeFile(file, mutablePlugin("second"))
|
||||
const modified = new Date(Date.now() + 5_000)
|
||||
await fs.utimes(file, modified, modified)
|
||||
})
|
||||
yield* bus.publish(ConfigSchema.Event.Updated, {})
|
||||
yield* waitUntil(
|
||||
Effect.gen(function* () {
|
||||
const current = (yield* plugins.list()).find((plugin) => plugin.id === "mutable-plugin")?.id
|
||||
return current === first && (yield* agents.get(Agent.ID.make("mutable")))?.description === "second"
|
||||
}),
|
||||
)
|
||||
}),
|
||||
false,
|
||||
async (directory) => {
|
||||
const plugin = path.join(directory, ".opencode", "plugin")
|
||||
await fs.mkdir(plugin, { recursive: true })
|
||||
await fs.writeFile(path.join(plugin, "mutable.ts"), mutablePlugin("first"))
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
it.live("applies explicit removals after auto-discovery", () =>
|
||||
withLocation(
|
||||
{ plugins: ["-*"] },
|
||||
@@ -302,25 +265,6 @@ function withLocation<A, E, R>(
|
||||
)
|
||||
}
|
||||
|
||||
function mutablePlugin(description: string) {
|
||||
const plugin = pathToFileURL(path.join(import.meta.dir, "../../../plugin/src/index.ts")).href
|
||||
return `
|
||||
import { Plugin } from ${JSON.stringify(plugin)}
|
||||
|
||||
export default Plugin.define({
|
||||
id: "mutable-plugin",
|
||||
setup: async (ctx) => {
|
||||
await ctx.agent.transform((agents) => {
|
||||
agents.update("mutable", (agent) => {
|
||||
agent.description = ${JSON.stringify(description)}
|
||||
agent.mode = "subagent"
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
`
|
||||
}
|
||||
|
||||
const waitUntil = Effect.fnUntraced(function* (condition: Effect.Effect<boolean>) {
|
||||
for (let attempt = 0; attempt < 200; attempt++) {
|
||||
if (yield* condition) return
|
||||
|
||||
@@ -394,8 +394,7 @@ describe("Plugin", () => {
|
||||
metadata: undefined,
|
||||
})
|
||||
expect(execution).toMatchObject({
|
||||
status: "completed",
|
||||
content: [{ type: "text", text: "after-mutated" }],
|
||||
content: [{ type: "text", text: '{"text":"before-mutated"}' }],
|
||||
metadata: { rewritten: true },
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -346,7 +346,6 @@ describe("fromPromise", () => {
|
||||
call: { type: "tool-call", id: "call_promise_tool", name: "hello", input: { name: "world" } },
|
||||
}),
|
||||
).toMatchObject({
|
||||
status: "completed",
|
||||
output: "Hello, world!",
|
||||
content: [{ type: "text", text: "Hello, world!" }],
|
||||
})
|
||||
|
||||
@@ -62,27 +62,4 @@ describe("Ripgrep", () => {
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("returns a bounded preview for matches on oversized lines", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "generated.ts"), `Cloudflare${"x".repeat(70 * 1024)}\n`))
|
||||
|
||||
const matches = yield* (yield* Ripgrep.Service).grep({
|
||||
cwd: tmp.path,
|
||||
pattern: "Cloudflare",
|
||||
limit: 10,
|
||||
})
|
||||
|
||||
expect(matches).toHaveLength(1)
|
||||
expect(matches[0]?.entry.path).toBe(RelativePath.make("generated.ts"))
|
||||
expect(matches[0]?.text).toHaveLength(2_003)
|
||||
expect(matches[0]?.text.endsWith("...")).toBe(true)
|
||||
expect(matches[0]?.submatches).toEqual([{ text: "Cloudflare", start: 0, end: 10 }])
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -75,8 +75,10 @@ import { asc, eq } from "drizzle-orm"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { agentHost, catalogHost, host } from "./plugin/host"
|
||||
import PROMPT_DEFAULT from "../src/session/runner/prompt/base.txt"
|
||||
import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions"
|
||||
|
||||
const requests: LLMRequest[] = []
|
||||
const emptyCodeMode = `\n\n${CodeModeInstructions.render({ total: 0, shown: 0, namespaces: [] })}`
|
||||
let response: LLMEvent[] = []
|
||||
let responses: LLMEvent[][] | undefined
|
||||
let responseStream: Stream.Stream<LLMEvent, LLMError> | undefined
|
||||
@@ -94,7 +96,14 @@ const client = Layer.succeed(
|
||||
LLMClient.Service.of({
|
||||
prepare: () => Effect.die("unused"),
|
||||
stream: ((request: LLMRequest) => {
|
||||
requests.push(request)
|
||||
requests.push({
|
||||
...request,
|
||||
system: request.system.map((part) => ({
|
||||
...part,
|
||||
text: part.text.replace(emptyCodeMode, ""),
|
||||
})),
|
||||
tools: request.tools.filter((tool) => tool.name !== "execute"),
|
||||
})
|
||||
if (responseStreams) return responseStreams.shift() ?? Stream.empty
|
||||
if (responseStream) {
|
||||
const stream = responseStream
|
||||
@@ -855,7 +864,7 @@ describe("SessionRunnerLLM", () => {
|
||||
{
|
||||
type: "tool",
|
||||
id: "call-removed",
|
||||
state: { status: "error", error: { type: "tool.unknown" } },
|
||||
state: { status: "error", error: { type: "tool.execution" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -924,58 +933,6 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("prefers failure outcome metadata over retained progress", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const registry = yield* Tool.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
yield* hooks.register("tool", "execute.after", (event) => {
|
||||
if (event.status === "error")
|
||||
event.error = new Tool.Error({ message: event.error.message, metadata: { phase: "failed" } })
|
||||
return Effect.void
|
||||
})
|
||||
yield* transformTools(registry,
|
||||
{
|
||||
failing_progress: ({
|
||||
name: "failing_progress",
|
||||
description: "Report progress and fail",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({}),
|
||||
execute: (_, context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* context.progress({ phase: "running" })
|
||||
return yield* new ToolFailure({ message: "failed after progress" })
|
||||
}),
|
||||
}),
|
||||
},
|
||||
{ codemode: false },
|
||||
)
|
||||
yield* admit(session, "Run failing progress")
|
||||
responses = [reply.tool("call-failing-progress", "failing_progress", {}), reply.stop()]
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Run failing progress" },
|
||||
{
|
||||
type: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call-failing-progress",
|
||||
state: {
|
||||
status: "error",
|
||||
metadata: { phase: "failed" },
|
||||
error: { message: "failed after progress" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: "assistant", finish: "stop" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("executes the tool advertised before a registry reload", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
@@ -1330,7 +1287,7 @@ describe("SessionRunnerLLM", () => {
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
expect(updates).toHaveLength(2)
|
||||
expect(updates[0]?.data).toEqual({
|
||||
expect(updates[0]?.data).toMatchObject({
|
||||
sessionID,
|
||||
delta: { "test/context": Instructions.hash("Initial context") },
|
||||
})
|
||||
@@ -3439,7 +3396,7 @@ describe("SessionRunnerLLM", () => {
|
||||
id: "call-missing",
|
||||
state: {
|
||||
status: "error",
|
||||
error: { type: "tool.unknown", message: "Unknown tool: missing" },
|
||||
error: { type: "tool.execution", message: "Unknown tool: missing" },
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -3607,41 +3564,6 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails the drain when tool output persistence fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Call storefail")
|
||||
|
||||
responses = [reply.tool("call-storefail", "storefail", {}), []]
|
||||
|
||||
const exit = yield* session.resume(sessionID).pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Call storefail" },
|
||||
{
|
||||
type: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call-storefail",
|
||||
state: {
|
||||
status: "error",
|
||||
error: {
|
||||
type: "unknown",
|
||||
message: expect.stringContaining("Failed to write tool output"),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
finish: "error",
|
||||
error: { type: "unknown", message: expect.stringContaining("Failed to write tool output") },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns configured permission denials to the model and continues", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
|
||||
@@ -2,7 +2,6 @@ import { createMemo } from "solid-js"
|
||||
import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select"
|
||||
import { type DialogContext } from "../ui/dialog"
|
||||
import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "../context/keymap"
|
||||
import { DialogConfig, settingID, settings } from "./dialog-config"
|
||||
|
||||
function isSuggestedPaletteCommand(command: KeymapCommand) {
|
||||
const suggested = command.suggested
|
||||
@@ -17,14 +16,11 @@ export function CommandPaletteDialog() {
|
||||
const options = createMemo(() =>
|
||||
commands().flatMap((command) => {
|
||||
if (!command.id || !command.palette || command.id === COMMAND_PALETTE_COMMAND) return []
|
||||
const footer = shortcuts.all(command.id)
|
||||
return {
|
||||
title: command.title ?? command.id,
|
||||
description: command.description,
|
||||
category: command.group,
|
||||
searchText: [command.id, command.description].filter(Boolean).join(" "),
|
||||
searchFooter: [command.group, footer].filter(Boolean).join(" · "),
|
||||
footer,
|
||||
footer: shortcuts.all(command.id),
|
||||
value: command.id,
|
||||
suggested: isSuggestedPaletteCommand(command),
|
||||
onSelect: (dialog: DialogContext) => {
|
||||
@@ -34,20 +30,10 @@ export function CommandPaletteDialog() {
|
||||
}
|
||||
}),
|
||||
)
|
||||
const settingOptions = settings.map((setting) => ({
|
||||
title: setting.title,
|
||||
category: setting.category,
|
||||
searchText: setting.keywords?.join(" "),
|
||||
searchFooter: `Settings · ${setting.category}`,
|
||||
value: `setting:${settingID(setting)}`,
|
||||
onSelect: (dialog: DialogContext) => {
|
||||
dialog.replace(() => <DialogConfig current={settingID(setting)} />)
|
||||
},
|
||||
}))
|
||||
|
||||
let ref: DialogSelectRef<string>
|
||||
const list = () => {
|
||||
if (ref?.filter) return [...options(), ...settingOptions]
|
||||
if (ref?.filter) return options()
|
||||
return [
|
||||
...options()
|
||||
.filter((option) => option.suggested)
|
||||
@@ -60,7 +46,5 @@ export function CommandPaletteDialog() {
|
||||
]
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogSelect ref={(value) => (ref = value)} title="Commands" options={list()} flat={true} filterThreshold={0.7} />
|
||||
)
|
||||
return <DialogSelect ref={(value) => (ref = value)} title="Commands" options={list()} />
|
||||
}
|
||||
|
||||
@@ -15,16 +15,14 @@ type Setting = {
|
||||
min?: number
|
||||
max?: number
|
||||
format?: (value: unknown) => string
|
||||
keywords?: readonly string[]
|
||||
}
|
||||
|
||||
export const settings: Setting[] = [
|
||||
const settings: Setting[] = [
|
||||
{
|
||||
title: "Theme",
|
||||
category: "Appearance",
|
||||
path: ["theme", "name"],
|
||||
default: "opencode",
|
||||
keywords: ["color scheme", "colors"],
|
||||
},
|
||||
{
|
||||
title: "Color mode",
|
||||
@@ -32,7 +30,6 @@ export const settings: Setting[] = [
|
||||
path: ["theme", "mode"],
|
||||
default: "system",
|
||||
values: ["system", "dark", "light"],
|
||||
keywords: ["dark mode", "light mode", "system theme"],
|
||||
},
|
||||
{
|
||||
title: "Animations",
|
||||
@@ -41,7 +38,6 @@ export const settings: Setting[] = [
|
||||
default: false,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
keywords: ["motion", "effects"],
|
||||
},
|
||||
{
|
||||
title: "Onboarding",
|
||||
@@ -50,7 +46,6 @@ export const settings: Setting[] = [
|
||||
default: true,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
keywords: ["hints", "getting started", "guidance"],
|
||||
},
|
||||
{
|
||||
title: "Sidebar",
|
||||
@@ -58,7 +53,6 @@ export const settings: Setting[] = [
|
||||
path: ["session", "sidebar"],
|
||||
default: "auto",
|
||||
values: ["hide", "auto"],
|
||||
keywords: ["side panel"],
|
||||
},
|
||||
{
|
||||
title: "Scrollbar",
|
||||
@@ -67,7 +61,6 @@ export const settings: Setting[] = [
|
||||
default: false,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
keywords: ["scroll bar"],
|
||||
},
|
||||
{
|
||||
title: "Thinking",
|
||||
@@ -75,7 +68,6 @@ export const settings: Setting[] = [
|
||||
path: ["session", "thinking"],
|
||||
default: "hide",
|
||||
values: ["hide", "show"],
|
||||
keywords: ["reasoning", "chain of thought"],
|
||||
},
|
||||
{
|
||||
title: "Markdown",
|
||||
@@ -83,7 +75,6 @@ export const settings: Setting[] = [
|
||||
path: ["session", "markdown"],
|
||||
default: "rendered",
|
||||
values: ["source", "rendered"],
|
||||
keywords: ["syntax", "concealment", "rendering"],
|
||||
},
|
||||
{
|
||||
title: "Grouping",
|
||||
@@ -91,7 +82,6 @@ export const settings: Setting[] = [
|
||||
path: ["session", "grouping"],
|
||||
default: "auto",
|
||||
values: ["none", "auto"],
|
||||
keywords: ["transcript", "messages"],
|
||||
},
|
||||
{
|
||||
title: "Layout",
|
||||
@@ -99,7 +89,6 @@ export const settings: Setting[] = [
|
||||
path: ["diffs", "view"],
|
||||
default: "auto",
|
||||
values: ["auto", "split", "unified"],
|
||||
keywords: ["diff layout", "split diff", "unified diff"],
|
||||
},
|
||||
{
|
||||
title: "Wrapping",
|
||||
@@ -107,7 +96,6 @@ export const settings: Setting[] = [
|
||||
path: ["diffs", "wrap"],
|
||||
default: "word",
|
||||
values: ["none", "word"],
|
||||
keywords: ["diff wrap", "word wrap", "line wrap"],
|
||||
},
|
||||
{
|
||||
title: "File tree",
|
||||
@@ -116,7 +104,6 @@ export const settings: Setting[] = [
|
||||
default: true,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
keywords: ["diff files"],
|
||||
},
|
||||
{
|
||||
title: "Single patch",
|
||||
@@ -125,7 +112,6 @@ export const settings: Setting[] = [
|
||||
default: false,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
keywords: ["one file", "selected file"],
|
||||
},
|
||||
{
|
||||
title: "Scroll speed",
|
||||
@@ -136,7 +122,6 @@ export const settings: Setting[] = [
|
||||
min: 0.25,
|
||||
max: 10,
|
||||
format: (value) => Number(value).toFixed(2),
|
||||
keywords: ["scrolling"],
|
||||
},
|
||||
{
|
||||
title: "Acceleration",
|
||||
@@ -145,7 +130,6 @@ export const settings: Setting[] = [
|
||||
default: false,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
keywords: ["scroll acceleration"],
|
||||
},
|
||||
{
|
||||
title: "Mouse",
|
||||
@@ -154,7 +138,6 @@ export const settings: Setting[] = [
|
||||
default: true,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
keywords: ["mouse capture"],
|
||||
},
|
||||
{
|
||||
title: "Editor context",
|
||||
@@ -163,7 +146,6 @@ export const settings: Setting[] = [
|
||||
default: true,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
keywords: ["file context", "prompt context", "editor selection"],
|
||||
},
|
||||
{
|
||||
title: "Large pastes",
|
||||
@@ -171,7 +153,6 @@ export const settings: Setting[] = [
|
||||
path: ["prompt", "paste"],
|
||||
default: "compact",
|
||||
values: ["compact", "full"],
|
||||
keywords: ["paste summary", "clipboard", "pasted content"],
|
||||
},
|
||||
{
|
||||
title: "Leader timeout",
|
||||
@@ -182,7 +163,6 @@ export const settings: Setting[] = [
|
||||
min: 250,
|
||||
max: 10000,
|
||||
format: (value) => `${value} ms`,
|
||||
keywords: ["leader key", "shortcut timeout"],
|
||||
},
|
||||
{
|
||||
title: "Attention",
|
||||
@@ -191,7 +171,6 @@ export const settings: Setting[] = [
|
||||
default: false,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
keywords: ["alerts"],
|
||||
},
|
||||
{
|
||||
title: "Notifications",
|
||||
@@ -200,7 +179,6 @@ export const settings: Setting[] = [
|
||||
default: true,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
keywords: ["system notifications", "desktop notifications", "alerts"],
|
||||
},
|
||||
{
|
||||
title: "Sounds",
|
||||
@@ -209,7 +187,6 @@ export const settings: Setting[] = [
|
||||
default: true,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
keywords: ["audio", "sound effects"],
|
||||
},
|
||||
{
|
||||
title: "Volume",
|
||||
@@ -220,7 +197,6 @@ export const settings: Setting[] = [
|
||||
min: 0,
|
||||
max: 1,
|
||||
format: (value) => `${Math.round(Number(value) * 100)}%`,
|
||||
keywords: ["sound volume", "audio volume"],
|
||||
},
|
||||
{
|
||||
title: "Window title",
|
||||
@@ -229,7 +205,6 @@ export const settings: Setting[] = [
|
||||
default: true,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
keywords: ["terminal title", "tab title"],
|
||||
},
|
||||
{
|
||||
title: "Copy on select",
|
||||
@@ -238,7 +213,6 @@ export const settings: Setting[] = [
|
||||
default: process.platform !== "win32",
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
keywords: ["selection", "clipboard"],
|
||||
},
|
||||
{
|
||||
title: "DevTools",
|
||||
@@ -247,7 +221,6 @@ export const settings: Setting[] = [
|
||||
default: false,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
keywords: ["debug bar", "developer tools"],
|
||||
},
|
||||
{
|
||||
title: "Turn token usage",
|
||||
@@ -256,23 +229,14 @@ export const settings: Setting[] = [
|
||||
default: false,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
keywords: ["tokens", "usage", "debug"],
|
||||
},
|
||||
]
|
||||
|
||||
export function settingID(setting: Setting) {
|
||||
return setting.path.join(".")
|
||||
}
|
||||
|
||||
export function DialogConfig(props: { current?: string }) {
|
||||
export function DialogConfig() {
|
||||
const config = useConfig()
|
||||
const toast = useToast()
|
||||
const themeState = useTheme()
|
||||
const current = Math.max(
|
||||
0,
|
||||
settings.findIndex((setting) => settingID(setting) === props.current),
|
||||
)
|
||||
const [selected, setSelected] = createSignal(current)
|
||||
const [selected, setSelected] = createSignal(0)
|
||||
const [saving, setSaving] = createSignal(false)
|
||||
|
||||
const value = (setting: Setting) => {
|
||||
@@ -297,7 +261,6 @@ export function DialogConfig(props: { current?: string }) {
|
||||
settings.map((setting, index) => ({
|
||||
title: setting.title,
|
||||
category: setting.category,
|
||||
searchText: setting.keywords?.join(" "),
|
||||
footer: display(setting),
|
||||
value: index,
|
||||
})),
|
||||
@@ -329,8 +292,6 @@ export function DialogConfig(props: { current?: string }) {
|
||||
<DialogSelect
|
||||
title="Settings"
|
||||
options={options()}
|
||||
current={current}
|
||||
filterThreshold={0.7}
|
||||
onMove={(option) => setSelected(option.value)}
|
||||
onSelect={(option) => void change(1, option.value)}
|
||||
footerHints={[{ title: "←/→", label: "change" }]}
|
||||
|
||||
@@ -75,8 +75,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||
|
||||
state.closed = true
|
||||
state.queue.length = 0
|
||||
// Ordinary turn signals map to session.interrupt; exiting should only detach the TUI.
|
||||
if (state.active?.mode === "shell") state.ctrl?.abort()
|
||||
state.ctrl?.abort()
|
||||
admissionController.abort()
|
||||
stop.resolve({ type: "closed" })
|
||||
finish()
|
||||
@@ -199,6 +198,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||
|
||||
const next = await Promise.race([task, stop.promise])
|
||||
if (next.type === "closed") {
|
||||
ctrl.abort()
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ export interface DialogSelectProps<T> {
|
||||
noMatchView?: JSX.Element
|
||||
options: DialogSelectOption<T>[]
|
||||
flat?: boolean
|
||||
filterThreshold?: number
|
||||
ref?: (ref: DialogSelectRef<T>) => void
|
||||
onMove?: (option: DialogSelectOption<T>) => void
|
||||
onFilter?: (query: string) => void
|
||||
@@ -65,8 +64,6 @@ export interface DialogSelectOption<T = any> {
|
||||
titleView?: JSX.Element
|
||||
value: T
|
||||
description?: string
|
||||
searchText?: string
|
||||
searchFooter?: JSX.Element | string
|
||||
details?: string[]
|
||||
detailsColor?: RGBA
|
||||
detailsWrap?: boolean
|
||||
@@ -167,13 +164,12 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
)
|
||||
if (!needle) return options
|
||||
|
||||
// prioritize title matches (weight: 2) over category and supplemental search text matches (weight: 1).
|
||||
// prioritize title matches (weight: 2) over category matches (weight: 1).
|
||||
// users typically search by the item name, and not its category.
|
||||
const result = fuzzysort
|
||||
.go(needle, options, {
|
||||
keys: ["title", "category", "searchText"],
|
||||
scoreFn: (r) => r[0].score * 2 + r[1].score + r[2].score,
|
||||
threshold: props.filterThreshold,
|
||||
keys: ["title", "category"],
|
||||
scoreFn: (r) => r[0].score * 2 + r[1].score,
|
||||
})
|
||||
.map((x) => x.obj)
|
||||
|
||||
@@ -708,9 +704,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
<Option
|
||||
title={option.title}
|
||||
titleView={option.titleView}
|
||||
footer={
|
||||
flatten() ? (option.searchFooter ?? option.category ?? option.footer) : option.footer
|
||||
}
|
||||
footer={flatten() ? (option.category ?? option.footer) : option.footer}
|
||||
titleWidth={option.titleWidth}
|
||||
truncateTitle={option.truncateTitle}
|
||||
description={option.description !== category ? option.description : undefined}
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { InputRenderable } from "@opentui/core"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { onMount } from "solid-js"
|
||||
import { ConfigProvider, resolve, type Info, type Interface } from "../../../src/config"
|
||||
import { CommandPaletteDialog } from "../../../src/component/command-palette"
|
||||
import { Keymap } from "../../../src/context/keymap"
|
||||
import { ThemeProvider } from "../../../src/context/theme"
|
||||
import { DialogProvider, useDialog } from "../../../src/ui/dialog"
|
||||
import { ToastProvider } from "../../../src/ui/toast"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
|
||||
test("searches settings globally and opens the matching setting", async () => {
|
||||
let current: Info = {}
|
||||
const service: Interface = {
|
||||
get: async () => current,
|
||||
update: async (update) => {
|
||||
const draft = structuredClone(current)
|
||||
update(draft)
|
||||
current = draft
|
||||
return current
|
||||
},
|
||||
}
|
||||
|
||||
function Fixture() {
|
||||
const dialog = useDialog()
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "global",
|
||||
commands: [
|
||||
{
|
||||
id: "session.new",
|
||||
title: "New session",
|
||||
group: "Session",
|
||||
palette: true,
|
||||
run() {},
|
||||
},
|
||||
{
|
||||
id: "model.list",
|
||||
title: "Switch model",
|
||||
group: "Agent",
|
||||
palette: true,
|
||||
run() {},
|
||||
},
|
||||
],
|
||||
}))
|
||||
onMount(() => dialog.replace(() => <CommandPaletteDialog />))
|
||||
return null
|
||||
}
|
||||
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<TestTuiContexts>
|
||||
<ConfigProvider config={resolve(current, { terminalSuspend: true })} service={service}>
|
||||
<Keymap.Provider>
|
||||
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
|
||||
<ToastProvider>
|
||||
<DialogProvider>
|
||||
<Fixture />
|
||||
</DialogProvider>
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
</TestTuiContexts>
|
||||
),
|
||||
{ width: 80, height: 24, kittyKeyboard: true },
|
||||
)
|
||||
app.renderer.start()
|
||||
|
||||
try {
|
||||
await app.waitForFrame((frame) => frame.includes("New session"))
|
||||
expect(app.captureCharFrame()).not.toContain("Animations")
|
||||
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
|
||||
|
||||
for (const key of "side") app.mockInput.pressKey(key)
|
||||
await app.waitForFrame((frame) => frame.includes("Sidebar"))
|
||||
expect(app.captureCharFrame()).not.toContain("New session")
|
||||
expect(app.captureCharFrame()).not.toContain("Switch model")
|
||||
expect(app.captureCharFrame()).not.toContain("Markdown")
|
||||
|
||||
app.mockInput.pressEnter()
|
||||
await app.waitForFrame((frame) => frame.includes("Settings") && frame.includes("Color mode"))
|
||||
app.mockInput.pressEnter()
|
||||
await app.waitFor(() => current.session?.sidebar === "hide")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
@@ -338,30 +338,6 @@ describe("run runtime queue", () => {
|
||||
expect(admissionHit).toBe(true)
|
||||
})
|
||||
|
||||
test.each([
|
||||
["session", undefined, false],
|
||||
["shell", "shell", true],
|
||||
] as const)("close handles an active %s turn", async (_name, mode, aborted) => {
|
||||
const ui = createFooterApiFixture()
|
||||
const started = Promise.withResolvers<AbortSignal>()
|
||||
const active = Promise.withResolvers<void>()
|
||||
const task = runPromptQueue({
|
||||
footer: ui.api,
|
||||
run: async (_input, signal) => {
|
||||
started.resolve(signal)
|
||||
await active.promise
|
||||
},
|
||||
})
|
||||
|
||||
ui.submit("one", mode)
|
||||
const signal = await started.promise
|
||||
ui.api.close()
|
||||
await task
|
||||
|
||||
expect(signal.aborted).toBe(aborted)
|
||||
active.resolve()
|
||||
})
|
||||
|
||||
test("propagates run errors", async () => {
|
||||
const ui = createFooterApiFixture()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user