Compare commits

..

1 Commits

Author SHA1 Message Date
Shoubhit Dash 177370dae0 fix(tui): paste into custom form answers 2026-08-18 13:13:51 +00:00
14 changed files with 137 additions and 137 deletions
+1 -11
View File
@@ -4,7 +4,7 @@ import { run } from "@opencode-ai/tui"
import { Commands } from "../commands" import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime" import { Runtime } from "../../framework/runtime"
import { Config } from "../../config" import { Config } from "../../config"
import { Context, Effect, FileSystem, Option, Queue } from "effect" import { Context, Effect, FileSystem, Option } from "effect"
import { ServerConnection } from "../../services/server-connection" import { ServerConnection } from "../../services/server-connection"
import { Updater } from "../../services/updater" import { Updater } from "../../services/updater"
import { UpdatePreflight } from "../../services/update-preflight" import { UpdatePreflight } from "../../services/update-preflight"
@@ -19,21 +19,11 @@ export default Runtime.handler(Commands, (input) =>
if (requestedDirectory !== undefined) process.chdir(requestedDirectory) if (requestedDirectory !== undefined) process.chdir(requestedDirectory)
const preflight = UpdatePreflight.make() const preflight = UpdatePreflight.make()
yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close())) yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close()))
const serviceStarts = yield* Queue.unbounded<{
readonly reason: "missing" | "version-mismatch"
readonly previousVersion?: string
}>()
yield* Queue.take(serviceStarts).pipe(
Effect.flatMap((event) => Effect.logInfo("background service starting", event)),
Effect.forever,
Effect.forkScoped,
)
const server = yield* ServerConnection.resolve({ const server = yield* ServerConnection.resolve({
server: requestedServer, server: requestedServer,
standalone: input.standalone, standalone: input.standalone,
mismatch: "replace", mismatch: "replace",
onStart: (reason, previousVersion) => { onStart: (reason, previousVersion) => {
Queue.offerUnsafe(serviceStarts, { reason, previousVersion })
if (reason === "version-mismatch" && preflight.begin(previousVersion)) return if (reason === "version-mismatch" && preflight.begin(previousVersion)) return
process.stderr.write( process.stderr.write(
reason === "version-mismatch" reason === "version-mismatch"
-7
View File
@@ -194,13 +194,6 @@ const register = Effect.fnUntraced(function* (
yield* current.pipe( yield* current.pipe(
Effect.filterOrFail(owns), Effect.filterOrFail(owns),
Effect.repeat(Schedule.spaced("5 seconds")), Effect.repeat(Schedule.spaced("5 seconds")),
Effect.tapError(() =>
Effect.logWarning("managed service registration lost; shutting down", {
serviceID: id,
servicePID: process.pid,
registration: file,
}),
),
Effect.ignore, Effect.ignore,
Effect.andThen(shutdown), Effect.andThen(shutdown),
Effect.forkScoped, Effect.forkScoped,
+7 -1
View File
@@ -1232,6 +1232,7 @@ export function Prompt(props: PromptProps) {
} else if (slashHead && isCommand) { } else if (slashHead && isCommand) {
move.startSubmit() move.startSubmit()
const model = { providerID: selection.providerID, id: selection.modelID, variant } const model = { providerID: selection.providerID, id: selection.modelID, variant }
const cancelCommit = local.model.trackSessionCommit(sessionID, model)
void client.api.session void client.api.session
.command({ .command({
@@ -1246,6 +1247,7 @@ export function Prompt(props: PromptProps) {
delivery, delivery,
}) })
.catch((error) => { .catch((error) => {
cancelCommit()
toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" }) toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" })
}) })
} else if (isSkill) { } else if (isSkill) {
@@ -1269,7 +1271,11 @@ export function Prompt(props: PromptProps) {
(session.model.variant ?? "default") !== (variant ?? "default") (session.model.variant ?? "default") !== (variant ?? "default")
) { ) {
const model = { providerID: selection.providerID, id: selection.modelID, variant } const model = { providerID: selection.providerID, id: selection.modelID, variant }
await client.api.session.switchModel({ sessionID, model }) const cancelCommit = local.model.trackSessionCommit(sessionID, model)
await client.api.session.switchModel({ sessionID, model }).catch((error) => {
cancelCommit()
throw error
})
} }
if (session?.revert) { if (session?.revert) {
const error = await client.api.session.revert.commit({ sessionID }).then( const error = await client.api.session.revert.commit({ sessionID }).then(
+76 -61
View File
@@ -45,39 +45,6 @@ export function recentModels(model: ModelPreferenceModel, recent: ModelPreferenc
.map((item) => ({ providerID: item.providerID, modelID: item.modelID })) .map((item) => ({ providerID: item.providerID, modelID: item.modelID }))
} }
type ModelSelection = ModelPreferenceModel & { variant?: string }
type AgentSelection = {
id: string
model?: { providerID: string; id: string; variant?: string }
}
type SessionSelection = {
agent?: string
model?: { providerID: string; id: string; variant?: string }
}
export function resolveAgentModelSelection(input: {
selected?: ModelSelection
agent?: AgentSelection
session?: SessionSelection
available: (model: ModelPreferenceModel) => boolean
}) {
const model = (value: SessionSelection["model"]): ModelSelection | undefined =>
value && {
providerID: value.providerID,
modelID: value.id,
variant: normalizeModelVariant(value.variant),
}
const candidates = [
input.selected,
input.session?.agent === input.agent?.id ? model(input.session?.model) : undefined,
model(input.agent?.model),
model(input.session?.model),
]
return candidates.find((item): item is ModelSelection => !!item && input.available(item))
}
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
name: "Local", name: "Local",
init: () => { init: () => {
@@ -100,6 +67,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return !!models()?.some((item) => item.providerID === model.providerID && item.id === model.modelID) return !!models()?.some((item) => item.providerID === model.providerID && item.id === model.modelID)
} }
function getFirstValidModel(...modelFns: (() => ModelPreferenceModel | undefined)[]) {
for (const modelFn of modelFns) {
const model = modelFn()
if (model && isModelValid(model)) return model
}
}
function createAgent() { function createAgent() {
const agents = createMemo(() => const agents = createMemo(() =>
(data.location.agent.list(location.ref) ?? []).filter((agent) => agent.mode !== "subagent" && !agent.hidden), (data.location.agent.list(location.ref) ?? []).filter((agent) => agent.mode !== "subagent" && !agent.hidden),
@@ -158,6 +132,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const agent = createAgent() const agent = createAgent()
function createModel() { function createModel() {
type ModelSelection = ModelPreferenceModel & { variant?: string }
const [preferences, setPreferences] = createStore<ModelPreference & { ready: boolean }>({ const [preferences, setPreferences] = createStore<ModelPreference & { ready: boolean }>({
ready: false, ready: false,
recent: [], recent: [],
@@ -166,13 +141,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}) })
const [selectionState, setSelectionState] = createStore<{ const [selectionState, setSelectionState] = createStore<{
newSessionModelByLocationAgent: Record<string, ModelPreferenceModel | undefined> newSessionModelByLocationAgent: Record<string, ModelPreferenceModel | undefined>
modelBySessionAgent: Record<string, Record<string, ModelSelection | undefined> | undefined> draftBySession: Record<string, ModelSelection | undefined>
}>({ }>({
newSessionModelByLocationAgent: {}, newSessionModelByLocationAgent: {},
modelBySessionAgent: {}, draftBySession: {},
}) })
const repository = createModelPreferenceRepository(path.join(paths.state, "model.json")) const repository = createModelPreferenceRepository(path.join(paths.state, "model.json"))
const pendingSelectionCommits = new Map<string, string>()
const selectionKey = (value: ModelSelection) =>
`${modelPreferenceKey(value)}:${normalizeModelVariant(value.variant) ?? "default"}`
const saveState = { const saveState = {
pending: false, pending: false,
} }
@@ -230,19 +208,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
} }
}) })
const newSessionSelection = createMemo<ModelSelection | undefined>(() => { const newSessionModel = createMemo(() => {
const a = agent.current() const a = agent.current()
const selected = a && selectionState.newSessionModelByLocationAgent[locationAgentKey(a.id)] return getFirstValidModel(
const resolved = resolveAgentModelSelection({ selected, agent: a, available: isModelValid }) ?? fallbackModel() () => a && selectionState.newSessionModelByLocationAgent[locationAgentKey(a.id)],
if (!resolved) return () => a?.model && { providerID: a.model.providerID, modelID: a.model.id },
if (selected || !a?.model || resolved.providerID !== a.model.providerID || resolved.modelID !== a.model.id) fallbackModel,
return { ...resolved, variant: normalizeModelVariant(preferences.variant[modelPreferenceKey(resolved)]) } )
return resolved
}) })
const currentSelection = createMemo<ModelSelection | undefined>(() => { const currentSelection = createMemo<ModelSelection | undefined>(() => {
if (route.data.type === "session") return sessionSelection(route.data.sessionID) if (route.data.type === "session") return sessionSelection(route.data.sessionID)
return newSessionSelection() const model = newSessionModel()
if (!model) return
return { ...model, variant: normalizeModelVariant(preferences.variant[modelPreferenceKey(model)]) }
}) })
const currentModel = createMemo(() => { const currentModel = createMemo(() => {
@@ -256,23 +235,27 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return `${JSON.stringify([ref.directory, ref.workspaceID])}:${agentID}` return `${JSON.stringify([ref.directory, ref.workspaceID])}:${agentID}`
} }
function sessionSelection(sessionID: string) { function durableSelection(sessionID: string): ModelSelection | undefined {
const current = agent.current() const model = data.session.get(sessionID)?.model
return resolveAgentModelSelection({ if (!model) return
selected: current && selectionState.modelBySessionAgent[sessionID]?.[current.id], return {
agent: current, providerID: model.providerID,
session: data.session.get(sessionID), modelID: model.id,
available: isModelValid, variant: normalizeModelVariant(model.variant),
}) }
} }
function setSessionSelection(sessionID: string, selection: ModelSelection) { function sessionSelection(sessionID: string) {
const current = agent.current() return selectionState.draftBySession[sessionID] ?? durableSelection(sessionID)
if (!current) return }
setSelectionState("modelBySessionAgent", sessionID, {
...selectionState.modelBySessionAgent[sessionID], function setSessionDraft(sessionID: string, selection: ModelSelection) {
[current.id]: selection, const durable = durableSelection(sessionID)
}) setSelectionState(
"draftBySession",
sessionID,
durable && selectionKey(durable) === selectionKey(selection) ? undefined : selection,
)
} }
function selectModel(model: ModelPreferenceModel) { function selectModel(model: ModelPreferenceModel) {
@@ -286,7 +269,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
) )
const info = models()?.find((item) => item.providerID === model.providerID && item.id === model.modelID) const info = models()?.find((item) => item.providerID === model.providerID && item.id === model.modelID)
const variant = preferred && info?.variants?.some((item) => item.id === preferred) ? preferred : undefined const variant = preferred && info?.variants?.some((item) => item.id === preferred) ? preferred : undefined
setSessionSelection(sessionID, { ...model, variant }) setSessionDraft(sessionID, { ...model, variant })
return true return true
} }
const current = agent.current() const current = agent.current()
@@ -295,9 +278,27 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return true return true
} }
onCleanup(
event.on("session.model.selected", (evt) => {
const expected = pendingSelectionCommits.get(evt.data.sessionID)
if (!expected) return
const committed = selectionKey({
providerID: evt.data.model.providerID,
modelID: evt.data.model.id,
variant: evt.data.model.variant,
})
if (committed !== expected) return
pendingSelectionCommits.delete(evt.data.sessionID)
const draft = selectionState.draftBySession[evt.data.sessionID]
if (draft && selectionKey(draft) === committed)
setSelectionState("draftBySession", evt.data.sessionID, undefined)
}),
)
onCleanup( onCleanup(
event.on("session.deleted", (evt) => { event.on("session.deleted", (evt) => {
setSelectionState("modelBySessionAgent", evt.data.sessionID, undefined) pendingSelectionCommits.delete(evt.data.sessionID)
setSelectionState("draftBySession", evt.data.sessionID, undefined)
}), }),
) )
@@ -307,6 +308,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
available(model = currentModel()) { available(model = currentModel()) {
return model ? isModelValid(model) : false return model ? isModelValid(model) : false
}, },
trackSessionCommit(
sessionID: string,
value: {
providerID: string
id: string
variant?: string
},
) {
const committed = selectionKey({ providerID: value.providerID, modelID: value.id, variant: value.variant })
pendingSelectionCommits.set(sessionID, committed)
return () => {
if (pendingSelectionCommits.get(sessionID) === committed) pendingSelectionCommits.delete(sessionID)
}
},
get ready() { get ready() {
return preferences.ready return preferences.ready
}, },
@@ -421,7 +436,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const m = currentSelection() const m = currentSelection()
if (!m) return if (!m) return
if (route.data.type === "session") { if (route.data.type === "session") {
setSessionSelection(route.data.sessionID, { ...m, variant: normalizeModelVariant(value) }) setSessionDraft(route.data.sessionID, { ...m, variant: normalizeModelVariant(value) })
} }
setPreferences("variant", modelPreferenceKey(m), normalizeModelVariant(value)) setPreferences("variant", modelPreferenceKey(m), normalizeModelVariant(value))
savePreferences() savePreferences()
@@ -99,7 +99,7 @@ function View(props: { context: Plugin.Context }) {
} }
export default Plugin.define({ export default Plugin.define({
id: "opencode.home.footer", id: "opencode.home-footer",
setup(context) { setup(context) {
// Root takeover: an external plugin replacing home.footer wins (last- // Root takeover: an external plugin replacing home.footer wins (last-
// enabled) and this builtin shows as suppressed, not silently gone. // enabled) and this builtin shows as suppressed, not silently gone.
@@ -83,7 +83,7 @@ export function PromptFooter(props: { context: Plugin.Context; sessionID?: strin
} }
export default Plugin.define({ export default Plugin.define({
id: "opencode.prompt.footer", id: "opencode.prompt-footer",
setup(context) { setup(context) {
context.ui.slot({ context.ui.slot({
append: "prompt.footer", append: "prompt.footer",
@@ -42,7 +42,7 @@ export function SidebarContext(props: { context: Plugin.Context; sessionID: stri
} }
export default Plugin.define({ export default Plugin.define({
id: "opencode.sidebar.context", id: "internal:sidebar-context",
setup(context) { setup(context) {
context.ui.slot({ context.ui.slot({
append: "sidebar.content", append: "sidebar.content",
@@ -40,7 +40,7 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
} }
export default Plugin.define({ export default Plugin.define({
id: "opencode.sidebar.footer", id: "opencode.sidebar-footer",
setup(context) { setup(context) {
// Append keeps the path open to additive plugin claims; an external // Append keeps the path open to additive plugin claims; an external
// replace still takes the boundary over. // replace still takes the boundary over.
@@ -71,7 +71,7 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
} }
export default Plugin.define({ export default Plugin.define({
id: "opencode.sidebar.mcp", id: "internal:sidebar-mcp",
setup(context) { setup(context) {
context.ui.slot({ context.ui.slot({
append: "sidebar.content", append: "sidebar.content",
@@ -1079,7 +1079,7 @@ function Commands(props: { context: Plugin.Context }) {
} }
export default Plugin.define({ export default Plugin.define({
id: "opencode.diffs", id: "diff-viewer",
setup(context) { setup(context) {
context.ui.router.register({ context.ui.router.register({
name: ROUTE, name: ROUTE,
+12
View File
@@ -523,6 +523,18 @@ export function FormPrompt(props: {
textarea?.setText("") textarea?.setText("")
}, },
}, },
{
id: "prompt.paste",
title: "Paste into answer",
group: "Form",
async run(_input, event) {
event?.preventDefault()
event?.stopPropagation()
const content = await clipboard.read()
if (content?.mime !== "text/plain") return
textarea?.insertText(content.data)
},
},
{ {
bind: "escape", bind: "escape",
title: textual() ? "Dismiss form" : "Close answer edit", title: textual() ? "Dismiss form" : "Close answer edit",
@@ -29,7 +29,7 @@ test("closing the diff viewer returns to the route it opened from", async () =>
try { try {
expect(viewer.current()).toEqual({ expect(viewer.current()).toEqual({
type: "plugin", type: "plugin",
id: "opencode.diffs", id: "diff-viewer",
name: "diff", name: "diff",
data: { mode: "working", sessionID: "session-1", returnRoute: startRoute }, data: { mode: "working", sessionID: "session-1", returnRoute: startRoute },
}) })
@@ -207,7 +207,7 @@ async function renderDiffViewer(
navigate(destination: Destination) { navigate(destination: Destination) {
setCurrent( setCurrent(
destination.type === "plugin" && !("id" in destination) destination.type === "plugin" && !("id" in destination)
? { ...destination, id: "opencode.diffs" } ? { ...destination, id: "diff-viewer" }
: destination, : destination,
) )
}, },
@@ -334,7 +334,7 @@ test("branch diff source requests branch VCS diff", async () => {
const viewer = await renderDiffViewer([], { const viewer = await renderDiffViewer([], {
initialRoute: { initialRoute: {
type: "plugin", type: "plugin",
id: "opencode.diffs", id: "diff-viewer",
name: "diff", name: "diff",
data: { mode: "branch", sessionID: "session-1", returnRoute: startRoute }, data: { mode: "branch", sessionID: "session-1", returnRoute: startRoute },
}, },
@@ -342,7 +342,7 @@ test("branch diff source requests branch VCS diff", async () => {
try { try {
expect(viewer.current()).toEqual({ expect(viewer.current()).toEqual({
type: "plugin", type: "plugin",
id: "opencode.diffs", id: "diff-viewer",
name: "diff", name: "diff",
data: { mode: "branch", sessionID: "session-1", returnRoute: startRoute }, data: { mode: "branch", sessionID: "session-1", returnRoute: startRoute },
}) })
+30 -2
View File
@@ -14,7 +14,7 @@ import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { createApi, createEventStream, createFetch } from "../../fixture/tui-client" import { createApi, createEventStream, createFetch } from "../../fixture/tui-client"
async function mountForm(root: string, width = 80, fields?: FormWithLocation["fields"], height = 20) { async function mountForm(root: string, width = 80, fields?: FormWithLocation["fields"], height = 20, pasted?: string) {
const state = path.join(root, "state") const state = path.join(root, "state")
await mkdir(state, { recursive: true }) await mkdir(state, { recursive: true })
@@ -58,7 +58,7 @@ async function mountForm(root: string, width = 80, fields?: FormWithLocation["fi
}} }}
clipboard={{ clipboard={{
async read() { async read() {
return undefined return pasted ? { data: pasted, mime: "text/plain" } : undefined
}, },
write(text) { write(text) {
copied.push(text) copied.push(text)
@@ -217,6 +217,34 @@ test("pasting on a custom choice opens its editor without submitting", async ()
} }
}) })
test("ctrl-v pastes clipboard text into a custom answer", async () => {
await using tmp = await tmpdir()
const prompt = await mountForm(
tmp.path,
80,
[
{
key: "target",
type: "string",
options: [{ value: "staging", label: "Staging" }],
custom: true,
},
],
20,
"production west",
)
try {
prompt.app.mockInput.pressArrow("down")
prompt.app.mockInput.pressEnter()
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor !== null)
prompt.app.mockInput.pressKey("v", { ctrl: true })
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor?.plainText === "production west")
} finally {
prompt.app.renderer.destroy()
}
})
test("typing a custom multiselect answer selects it before commit", async () => { test("typing a custom multiselect answer selects it before commit", async () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
const prompt = await mountForm(tmp.path, 80, [ const prompt = await mountForm(tmp.path, 80, [
+1 -45
View File
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { parseModel, recentModels, resolveAgentModelSelection } from "../../src/context/local" import { parseModel, recentModels } from "../../src/context/local"
test("parses model IDs containing slashes", () => { test("parses model IDs containing slashes", () => {
expect(parseModel("provider/family/model")).toEqual({ expect(parseModel("provider/family/model")).toEqual({
@@ -20,47 +20,3 @@ test("moves a model to the front, deduplicates, and limits recents", () => {
...recent.slice(6, 10), ...recent.slice(6, 10),
]) ])
}) })
test("uses the configured model when switching agents", () => {
expect(
resolveAgentModelSelection({
agent: { id: "build", model: { providerID: "provider", id: "build-model", variant: "max" } },
session: {
agent: "plan",
model: { providerID: "provider", id: "plan-model", variant: "high" },
},
available: () => true,
}),
).toEqual({ providerID: "provider", modelID: "build-model", variant: "max" })
})
test("keeps a manual model selection for each session agent", () => {
expect(
resolveAgentModelSelection({
selected: { providerID: "provider", modelID: "manual-model", variant: "high" },
agent: { id: "build", model: { providerID: "provider", id: "build-model", variant: "max" } },
session: { agent: "plan", model: { providerID: "provider", id: "plan-model" } },
available: () => true,
}),
).toEqual({ providerID: "provider", modelID: "manual-model", variant: "high" })
})
test("keeps the durable model while the active agent is unchanged", () => {
expect(
resolveAgentModelSelection({
agent: { id: "plan", model: { providerID: "provider", id: "configured-model", variant: "max" } },
session: { agent: "plan", model: { providerID: "provider", id: "manual-model", variant: "high" } },
available: () => true,
}),
).toEqual({ providerID: "provider", modelID: "manual-model", variant: "high" })
})
test("keeps the session model when the next agent has no configured model", () => {
expect(
resolveAgentModelSelection({
agent: { id: "review" },
session: { agent: "plan", model: { providerID: "provider", id: "plan-model", variant: "high" } },
available: () => true,
}),
).toEqual({ providerID: "provider", modelID: "plan-model", variant: "high" })
})