Compare commits

..

3 Commits

Author SHA1 Message Date
Dax Raad 0a8da2c985 fix(api): require session selection 2026-08-07 01:32:23 +00:00
opencode-agent[bot] d7651519f3 fix(tui): use tab layout setting (#40952)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-08-07 01:19:28 +00:00
Kit Langton 1eb3a43add fix(tui): keep model selection session scoped (#40913) 2026-08-06 20:34:57 -04:00
23 changed files with 354 additions and 223 deletions
+9 -6
View File
@@ -94,11 +94,9 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
prepare: async (next) => { prepare: async (next) => {
const selected = const selected =
next.model ?? next.model ??
(options.variant (await client.model
? await client.model .default({ location: { directory: next.location.directory, workspace: next.location.workspaceID } })
.default({ location: { directory: next.location.directory, workspace: next.location.workspaceID } }) .then((result) => result.data))
.then((result) => result.data)
: undefined)
const model = selected const model = selected
? { ? {
providerID: selected.providerID, providerID: selected.providerID,
@@ -108,7 +106,12 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
: undefined : undefined
if ((options.variant ?? explicit?.variant) && !model) if ((options.variant ?? explicit?.variant) && !model)
throw new RunTargetError("Cannot select a variant before selecting a model", next.session?.id) throw new RunTargetError("Cannot select a variant before selecting a model", next.session?.id)
return { model, agent: next.agent } const agent =
next.agent ??
(await client.agent
.list({ location: { directory: next.location.directory, workspace: next.location.workspaceID } })
.then((result) => result.data.find((item) => item.mode !== "subagent" && !item.hidden)?.id))
return { model, agent }
}, },
}).catch((error) => { }).catch((error) => {
if (!(error instanceof RunTargetError)) throw error if (!(error instanceof RunTargetError)) throw error
+5 -2
View File
@@ -56,13 +56,16 @@ export async function resolveSessionTarget(input: {
agent: input.agent ?? selected?.agent, agent: input.agent ?? selected?.agent,
signal: input.signal, signal: input.signal,
}) })
if (!selected && (!prepared.agent || !prepared.model)) {
throw new SessionTargetMutationError(new Error("Creating a session requires an agent and model"))
}
const session = const session =
selected ?? selected ??
(await input.client.session (await input.client.session
.create( .create(
{ {
agent: prepared.agent, agent: prepared.agent!,
model: prepared.model, model: prepared.model!,
location: { directory: location.directory, workspaceID: location.workspaceID }, location: { directory: location.directory, workspaceID: location.workspaceID },
}, },
...requestOptions(input.signal), ...requestOptions(input.signal),
+12 -6
View File
@@ -61,7 +61,11 @@ describe("session target resolver", () => {
spyOn(client.location, "get").mockResolvedValue(location("/server", "work_1")) spyOn(client.location, "get").mockResolvedValue(location("/server", "work_1"))
const create = spyOn(client.session, "create").mockImplementation(async (input) => { const create = spyOn(client.session, "create").mockImplementation(async (input) => {
order.push("create") order.push("create")
expect(input).toMatchObject({ agent: "prepared", location: { directory: "/server", workspaceID: "work_1" } }) expect(input).toMatchObject({
agent: "prepared",
model: { providerID: "openai", id: "gpt-5" },
location: { directory: "/server", workspaceID: "work_1" },
})
return session("ses_fresh", "/server", "work_1") return session("ses_fresh", "/server", "work_1")
}) })
@@ -71,20 +75,22 @@ describe("session target resolver", () => {
prepare: async (input) => { prepare: async (input) => {
order.push("prepare") order.push("prepare")
expect(input.location.workspaceID).toBe("work_1") expect(input.location.workspaceID).toBe("work_1")
return { model: input.model, agent: "prepared" } return { model: { providerID: "openai", id: "gpt-5" }, agent: "prepared" }
}, },
}) })
expect(create).toHaveBeenCalledTimes(1) expect(create).toHaveBeenCalledTimes(1)
expect(order).toEqual(["prepare", "create"]) expect(order).toEqual(["prepare", "create"])
}) })
test("uses the agent resolved by the server for a fresh Session", async () => { test("requires an explicit agent and model for a fresh Session", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" }) const client = OpenCode.make({ baseUrl: "https://opencode.test" })
spyOn(client.location, "get").mockResolvedValue(location("/project")) spyOn(client.location, "get").mockResolvedValue(location("/project"))
spyOn(client.session, "create").mockResolvedValue({ ...session("ses_fresh", "/project"), agent: "review" }) const create = spyOn(client.session, "create")
const target = await resolveSessionTarget({ client, prepare }) await expect(resolveSessionTarget({ client, prepare })).rejects.toThrow(
expect(target.agent).toBe("review") "Creating a session requires an agent and model",
)
expect(create).not.toHaveBeenCalled()
}) })
test("does not retry an ambiguous Session creation", async () => { test("does not retry an ambiguous Session creation", async () => {
+3 -3
View File
@@ -120,12 +120,12 @@ export type SessionListOperation<E = never> = (input?: Endpoint5_0Input) => Effe
export type Endpoint5_1Input = { export type Endpoint5_1Input = {
readonly id?: Session.ID | undefined readonly id?: Session.ID | undefined
readonly title?: string | undefined readonly title?: string | undefined
readonly agent?: Agent.ID | undefined readonly agent: Agent.ID
readonly model?: Model.Ref | undefined readonly model: Model.Ref
readonly location?: Location.Ref | undefined readonly location?: Location.Ref | undefined
} }
export type Endpoint5_1Output = Session.Info export type Endpoint5_1Output = Session.Info
export type SessionCreateOperation<E = never> = (input?: Endpoint5_1Input) => Effect.Effect<Endpoint5_1Output, E> export type SessionCreateOperation<E = never> = (input: Endpoint5_1Input) => Effect.Effect<Endpoint5_1Output, E>
export type Endpoint5_2Input = { export type Endpoint5_2Input = {
readonly info: Session.Info readonly info: Session.Info
@@ -305,15 +305,15 @@ const Endpoint5_0 = (raw: RawClient["server.session"]) => (input?: Endpoint5_0In
}).pipe(Effect.mapError(mapClientError)), }).pipe(Effect.mapError(mapClientError)),
) )
const Endpoint5_1 = (raw: RawClient["server.session"]) => (input?: Endpoint5_1Input) => const Endpoint5_1 = (raw: RawClient["server.session"]) => (input: Endpoint5_1Input) =>
preserveEffect<Endpoint5_1Output>()( preserveEffect<Endpoint5_1Output>()(
raw["session.create"]({ raw["session.create"]({
payload: { payload: {
id: input?.["id"], id: input["id"],
title: input?.["title"], title: input["title"],
agent: input?.["agent"], agent: input["agent"],
model: input?.["model"], model: input["model"],
location: input?.["location"], location: input["location"],
}, },
}).pipe( }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
@@ -464,17 +464,17 @@ export function make(options: ClientOptions) {
}, },
requestOptions, requestOptions,
), ),
create: (input?: SessionCreateInput, requestOptions?: RequestOptions) => create: (input: SessionCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionCreateOutput }>( request<{ readonly data: SessionCreateOutput }>(
{ {
method: "POST", method: "POST",
path: `/api/session`, path: `/api/session`,
body: { body: {
id: input?.["id"], id: input["id"],
title: input?.["title"], title: input["title"],
agent: input?.["agent"], agent: input["agent"],
model: input?.["model"], model: input["model"],
location: input?.["location"], location: input["location"],
}, },
successStatus: 200, successStatus: 200,
declaredStatuses: [401, 400], declaredStatuses: [401, 400],
+12 -12
View File
@@ -2436,36 +2436,36 @@ export type SessionCreateInput = {
readonly id?: { readonly id?: {
readonly id?: string | null readonly id?: string | null
readonly title?: string | null readonly title?: string | null
readonly agent?: string | null readonly agent: string
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["id"] }["id"]
readonly title?: { readonly title?: {
readonly id?: string | null readonly id?: string | null
readonly title?: string | null readonly title?: string | null
readonly agent?: string | null readonly agent: string
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["title"] }["title"]
readonly agent?: { readonly agent: {
readonly id?: string | null readonly id?: string | null
readonly title?: string | null readonly title?: string | null
readonly agent?: string | null readonly agent: string
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["agent"] }["agent"]
readonly model?: { readonly model: {
readonly id?: string | null readonly id?: string | null
readonly title?: string | null readonly title?: string | null
readonly agent?: string | null readonly agent: string
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["model"] }["model"]
readonly location?: { readonly location?: {
readonly id?: string | null readonly id?: string | null
readonly title?: string | null readonly title?: string | null
readonly agent?: string | null readonly agent: string
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["location"] }["location"]
} }
+2
View File
@@ -181,6 +181,8 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
const page = yield* client.session.list({ limit: 10 }) const page = yield* client.session.list({ limit: 10 })
const active = yield* client.session.active() const active = yield* client.session.active()
const created = yield* client.session.create({ const created = yield* client.session.create({
agent: Agent.ID.make("build"),
model: Model.Ref.make({ id: "claude", providerID: "anthropic" }),
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }), location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }),
}) })
yield* client.session.switchAgent({ sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build") }) yield* client.session.switchAgent({ sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build") })
+6 -2
View File
@@ -454,7 +454,11 @@ test("session methods use the public HTTP contract", async () => {
const page = await client.session.list({ limit: 10, order: "desc", parentID: null }) const page = await client.session.list({ limit: 10, order: "desc", parentID: null })
const active = await client.session.active() const active = await client.session.active()
const created = await client.session.create({ location: { directory: "/tmp/project" } }) const created = await client.session.create({
agent: "build",
model: { id: "claude", providerID: "anthropic" },
location: { directory: "/tmp/project" },
})
await client.session.switchAgent({ sessionID: "ses_test", agent: "build" }) await client.session.switchAgent({ sessionID: "ses_test", agent: "build" })
await client.session.switchModel({ await client.session.switchModel({
sessionID: "ses_test", sessionID: "ses_test",
@@ -528,7 +532,7 @@ test("middleware errors remain declared client errors", async () => {
}) })
try { try {
await client.session.create({}) await client.session.create({ agent: "build", model: { id: "claude", providerID: "anthropic" } })
throw new Error("Expected request to fail") throw new Error("Expected request to fail")
} catch (error) { } catch (error) {
expect(isUnauthorizedError(error)).toBe(true) expect(isUnauthorizedError(error)).toBe(true)
+6 -3
View File
@@ -161,11 +161,14 @@ function isPathAction(action: string): action is PathAction {
} }
function expandHome(resource: string, home: string) { function expandHome(resource: string, home: string) {
if (resource.startsWith("~/")) return home + resource.slice(1)
if (resource === "~") return home if (resource === "~") return home
if (resource === "$HOME") return home if (resource === "$HOME") return home
if (resource.startsWith("$HOME/")) return home + resource.slice(5) const relative = resource.startsWith("~/")
if (resource.startsWith("$HOME\\")) return home + resource.slice(5) ? resource.slice(2)
: resource.startsWith("$HOME/") || resource.startsWith("$HOME\\")
? resource.slice(6)
: undefined
if (relative !== undefined) return (path.posix.isAbsolute(home) ? path.posix : path.win32).join(home, relative)
return resource return resource
} }
+5 -5
View File
@@ -340,12 +340,12 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
hook: (name, callback) => hooks.register("session", name, callback), hook: (name, callback) => hooks.register("session", name, callback),
create: (input) => create: (input) =>
runtime.session.create({ runtime.session.create({
id: input?.id, id: input.id,
title: input?.title, title: input.title,
agent: input?.agent, agent: input.agent,
model: input?.model, model: input.model,
location: location:
input?.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }), input.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
}), }),
get: (input) => runtime.session.get(input.sessionID), get: (input) => runtime.session.get(input.sessionID),
prompt: runtime.session.prompt, prompt: runtime.session.prompt,
+15 -19
View File
@@ -269,25 +269,21 @@ export function fromPromise(plugin: Plugin) {
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))), register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
create: (input) => create: (input) =>
run( run(
host.session.create( host.session.create({
input === undefined id: input.id == null ? undefined : Session.ID.make(input.id),
? undefined agent: Agent.ID.make(input.agent),
: { model: model(input.model),
id: input.id == null ? undefined : Session.ID.make(input.id), location:
agent: input.agent == null ? undefined : Agent.ID.make(input.agent), input.location == null
model: input.model == null ? undefined : model(input.model), ? undefined
location: : Location.Ref.make({
input.location == null directory: AbsolutePath.make(input.location.directory),
? undefined workspaceID:
: Location.Ref.make({ input.location.workspaceID === undefined
directory: AbsolutePath.make(input.location.directory), ? undefined
workspaceID: : Workspace.ID.make(input.location.workspaceID),
input.location.workspaceID === undefined }),
? undefined }),
: Workspace.ID.make(input.location.workspaceID),
}),
},
),
), ),
get: (input) => run(host.session.get({ sessionID: Session.ID.make(input.sessionID) })), get: (input) => run(host.session.get({ sessionID: Session.ID.make(input.sessionID) })),
prompt: (input) => prompt: (input) =>
+5
View File
@@ -51,6 +51,11 @@ describe("ConfigAgentPlugin.Plugin", () => {
it.effect("matches Windows paths against home-relative permissions", () => it.effect("matches Windows paths against home-relative permissions", () =>
Effect.gen(function* () { Effect.gen(function* () {
const permissions = yield* loadHomePermissions("C:\\Users\\test") const permissions = yield* loadHomePermissions("C:\\Users\\test")
expect(permissions).toContainEqual({
action: "external_directory",
resource: "C:\\Users\\test\\p\\**",
effect: "allow",
})
expect( expect(
Permission.evaluate("external_directory", "C:\\Users\\test\\p\\opencode\\src\\*", permissions).effect, Permission.evaluate("external_directory", "C:\\Users\\test\\p\\opencode\\src\\*", permissions).effect,
).toBe("allow") ).toBe("allow")
+3 -3
View File
@@ -151,8 +151,8 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
payload: Schema.Struct({ payload: Schema.Struct({
id: Session.ID.pipe(Schema.optional), id: Session.ID.pipe(Schema.optional),
title: Schema.String.pipe(Schema.optional), title: Schema.String.pipe(Schema.optional),
agent: Agent.ID.pipe(Schema.optional), agent: Agent.ID,
model: Model.Ref.pipe(Schema.optional), model: Model.Ref,
location: Location.Ref.pipe(Schema.optional), location: Location.Ref.pipe(Schema.optional),
}), }),
success: Schema.Struct({ data: Session.Info }), success: Schema.Struct({ data: Session.Info }),
@@ -160,7 +160,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
OpenApi.annotations({ OpenApi.annotations({
identifier: "v2.session.create", identifier: "v2.session.create",
summary: "Create session", summary: "Create session",
description: "Create a session at the requested location.", description: "Create a session with an explicit agent and model at the requested location.",
}), }),
), ),
) )
+1 -1
View File
@@ -512,7 +512,7 @@ function App(props: { pair?: DialogPairCredentials }) {
const terminalTitleEnabled = () => config.data.terminal?.title ?? true const terminalTitleEnabled = () => config.data.terminal?.title ?? true
const copyOnSelectEnabled = () => config.data.terminal?.copy_on_select ?? process.platform !== "win32" const copyOnSelectEnabled = () => config.data.terminal?.copy_on_select ?? process.platform !== "win32"
const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full" const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
const tabsVertical = () => (config.data.tabs?.vertical ?? false) && sessionTabsFitVertically(dimensions().width) const tabsVertical = () => config.data.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width)
const tabsVisible = () => const tabsVisible = () =>
sessionTabs.enabled() && (sessionTabs.tabs().length > 0 || sessionTabs.newTab()) && route.data.type !== "plugin" sessionTabs.enabled() && (sessionTabs.tabs().length > 0 || sessionTabs.newTab()) && route.data.type !== "plugin"
+4 -5
View File
@@ -101,12 +101,11 @@ export const settings: Setting[] = [
labels: ["current directory", "global"], labels: ["current directory", "global"],
}, },
{ {
title: "Vertical", title: "Layout",
category: "Tabs", category: "Tabs",
path: ["tabs", "vertical"], path: ["tabs", "layout"],
default: false, default: "horizontal",
values: [false, true], values: ["horizontal", "vertical"],
labels: ["off", "on"],
keywords: ["sidebar", "orientation", "left"], keywords: ["sidebar", "orientation", "left"],
}, },
{ {
+6 -2
View File
@@ -8,17 +8,21 @@ import * as fuzzysort from "fuzzysort"
import { useConnected } from "./use-connected" import { useConnected } from "./use-connected"
import { useData } from "../context/data" import { useData } from "../context/data"
import { modelPreferenceKey } from "../model-preference" import { modelPreferenceKey } from "../model-preference"
import { useLocation } from "../context/location"
export function DialogModel(props: { providerID?: string }) { export function DialogModel(props: { providerID?: string }) {
const local = useLocal() const local = useLocal()
const data = useData() const data = useData()
const dialog = useDialog() const dialog = useDialog()
const location = useLocation()
const [query, setQuery] = createSignal("") const [query, setQuery] = createSignal("")
const favoritePriority = new Set(local.model.favorite().map(modelPreferenceKey)) const favoritePriority = new Set(local.model.favorite().map(modelPreferenceKey))
const connected = useConnected() const connected = useConnected()
const providers = createMemo(() => new Map((data.location.provider.list() ?? []).map((item) => [item.id, item]))) const providers = createMemo(
const models = createMemo(() => data.location.model.list() ?? []) () => new Map((data.location.provider.list(location.ref) ?? []).map((item) => [item.id, item])),
)
const models = createMemo(() => data.location.model.list(location.ref) ?? [])
const showExtra = createMemo(() => connected() && !props.providerID) const showExtra = createMemo(() => connected() && !props.providerID)
+50 -51
View File
@@ -327,10 +327,6 @@ export function Prompt(props: PromptProps) {
if (!session) return if (!session) return
const agent = session.agent && local.agent.list().find((agent) => agent.id === session.agent) const agent = session.agent && local.agent.list().find((agent) => agent.id === session.agent)
if (agent && !args.agent) local.agent.set(agent.id) if (agent && !args.agent) local.agent.set(agent.id)
if (session.model) {
local.model.set({ providerID: session.model.providerID, modelID: session.model.id })
local.model.variant.set(session.model.variant)
}
syncedSessionID = sessionID syncedSessionID = sessionID
}) })
@@ -943,15 +939,43 @@ export function Prompt(props: PromptProps) {
await slash.command.run(slash.input) await slash.command.run(slash.input)
return true return true
} }
const inputText = expandTrackedPastedText(
store.prompt.text,
input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => {
const ref = store.extmarkToPart.get(extmark.id)
if (ref?.type !== "pasted") return []
const part = store.prompt.pasted[ref.index]
if (!part) return []
return [{ start: extmark.start, end: extmark.end, text: part.text }]
}),
)
const slashHead = parseSlashHead(inputText, /\s/)
const isSkill =
slashHead !== undefined &&
(data.location.skill.list(currentLocation.ref) ?? []).some(
(skill) => skill.slash === true && skill.id === slashHead.name,
)
const isCommand =
slashHead !== undefined &&
(data.location.command.list(currentLocation.ref) ?? []).some((command) => command.name === slashHead.name)
const agent = local.agent.current() const agent = local.agent.current()
if (!agent) return false if (!agent) return false
const selectedModel = local.model.current() const selection = local.model.selection()
if (!selectedModel) { if (!selection) {
void promptModelWarning() void promptModelWarning()
return false return false
} }
const usesModel = !props.sessionID || (store.mode !== "shell" && !isSkill)
if (usesModel && !local.model.available(selection)) {
toast.show({
title: "Model unavailable",
message: `${selection.providerID}/${selection.modelID} is not available in this session's location`,
variant: "warning",
})
return false
}
const variant = local.model.variant.current() const variant = selection.variant
let sessionID = props.sessionID let sessionID = props.sessionID
let session = sessionID ? data.session.get(sessionID) : undefined let session = sessionID ? data.session.get(sessionID) : undefined
let finishMoveProgress = false let finishMoveProgress = false
@@ -969,8 +993,8 @@ export function Prompt(props: PromptProps) {
location: directory ? { directory } : location, location: directory ? { directory } : location,
agent: agent.id, agent: agent.id,
model: { model: {
providerID: selectedModel.providerID, providerID: selection.providerID,
id: selectedModel.modelID, id: selection.modelID,
variant, variant,
}, },
}) })
@@ -990,17 +1014,6 @@ export function Prompt(props: PromptProps) {
session = created session = created
} }
const inputText = expandTrackedPastedText(
store.prompt.text,
input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => {
const ref = store.extmarkToPart.get(extmark.id)
if (ref?.type !== "pasted") return []
const part = store.prompt.pasted[ref.index]
if (!part) return []
return [{ start: extmark.start, end: extmark.end, text: part.text }]
}),
)
// Capture mode before it gets reset // Capture mode before it gets reset
const currentMode = store.mode const currentMode = store.mode
const editorSelection = editorContext() const editorSelection = editorContext()
@@ -1013,43 +1026,30 @@ export function Prompt(props: PromptProps) {
command: inputText, command: inputText,
}) })
setStore("mode", "normal") setStore("mode", "normal")
} else if ( } else if (slashHead && isCommand) {
inputText.startsWith("/") &&
(data.location.command.list(currentLocation.current) ?? []).some(
(command) => command.name === inputText.split("\n")[0].split(" ")[0].slice(1),
)
) {
move.startSubmit() move.startSubmit()
// Parse command from first line, preserve multi-line content in arguments const model = { providerID: selection.providerID, id: selection.modelID, variant }
const firstLineEnd = inputText.indexOf("\n") const cancelCommit = local.model.trackSessionCommit(sessionID, model)
const firstLine = firstLineEnd === -1 ? inputText : inputText.slice(0, firstLineEnd)
const [command, ...firstLineArgs] = firstLine.split(" ")
const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1)
const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "")
void client.api.session void client.api.session
.command({ .command({
sessionID, sessionID,
command: command.slice(1), command: slashHead.name,
arguments: args, arguments: slashHead.arguments,
agent: agent.id, agent: agent.id,
model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant }, model,
files: store.prompt.files, files: store.prompt.files,
agents: store.prompt.agents, agents: store.prompt.agents,
}) })
.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 ( } else if (isSkill) {
inputText.startsWith("/") &&
(data.location.skill.list(currentLocation.current) ?? []).some(
(skill) => skill.slash === true && skill.id === inputText.split("\n")[0].split(" ")[0].slice(1),
)
) {
move.startSubmit() move.startSubmit()
void client.api.session.skill({ void client.api.session.skill({
sessionID, sessionID,
skill: inputText.split("\n")[0].split(" ")[0].slice(1), skill: slashHead!.name,
}) })
} else { } else {
move.startSubmit() move.startSubmit()
@@ -1061,13 +1061,15 @@ export function Prompt(props: PromptProps) {
await client.api.session.switchAgent({ sessionID, agent: agent.id }) await client.api.session.switchAgent({ sessionID, agent: agent.id })
} }
if ( if (
session?.model?.providerID !== selectedModel.providerID || session?.model?.providerID !== selection.providerID ||
session.model.id !== selectedModel.modelID || session.model.id !== selection.modelID ||
(session.model.variant ?? "default") !== (variant ?? "default") (session.model.variant ?? "default") !== (variant ?? "default")
) { ) {
await client.api.session.switchModel({ const model = { providerID: selection.providerID, id: selection.modelID, variant }
sessionID, const cancelCommit = local.model.trackSessionCommit(sessionID, model)
model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant }, await client.api.session.switchModel({ sessionID, model }).catch((error) => {
cancelCommit()
throw error
}) })
} }
if (session?.revert) { if (session?.revert) {
@@ -1320,10 +1322,7 @@ export function Prompt(props: PromptProps) {
return `Ask anything... "${list()[store.placeholder % list().length]}"` return `Ask anything... "${list()[store.placeholder % list().length]}"`
})() })()
if (!value) return undefined if (!value) return undefined
const width = const width = dimensions().width < 44 ? dimensions().width - 5 : Math.min(75, dimensions().width - 4) - 5
dimensions().width < 44
? dimensions().width - 5
: Math.min(75, dimensions().width - 4) - 5
return Locale.takeWidth(value, Math.max(1, width)).trimEnd() return Locale.takeWidth(value, Math.max(1, width)).trimEnd()
}) })
const locationLabel = createMemo(() => { const locationLabel = createMemo(() => {
+4 -3
View File
@@ -132,8 +132,8 @@ export const Info = Schema.Struct({
scope: Schema.optional(Schema.Literals(["global", "cwd"])).annotate({ scope: Schema.optional(Schema.Literals(["global", "cwd"])).annotate({
description: "Share tabs globally or keep a separate set for each working directory", description: "Share tabs globally or keep a separate set for each working directory",
}), }),
vertical: Schema.optional(Schema.Boolean).annotate({ layout: Schema.optional(Schema.Literals(["horizontal", "vertical"])).annotate({
description: "Show tabs in a left sidebar instead of a horizontal strip", description: "Show tabs in a horizontal strip or vertical sidebar",
}), }),
}), }),
).annotate({ description: "Tab strip settings" }), ).annotate({ description: "Tab strip settings" }),
@@ -194,7 +194,7 @@ export type Resolved = Omit<Info, "attention" | "keybinds" | "leader" | "mouse"
tabs: { tabs: {
enabled: boolean enabled: boolean
scope: "global" | "cwd" scope: "global" | "cwd"
vertical?: boolean layout: "horizontal" | "vertical"
} }
} }
@@ -230,6 +230,7 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res
...input.tabs, ...input.tabs,
enabled: input.tabs?.enabled ?? true, enabled: input.tabs?.enabled ?? true,
scope: input.tabs?.scope ?? "cwd", scope: input.tabs?.scope ?? "cwd",
layout: input.tabs?.layout ?? "horizontal",
}, },
} }
} }
+184 -80
View File
@@ -1,7 +1,7 @@
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { dedupeWith } from "effect/Array" import { dedupeWith } from "effect/Array"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import { batch, createMemo } from "solid-js" import { batch, createMemo, onCleanup } from "solid-js"
import { useEvent } from "./event" import { useEvent } from "./event"
import path from "path" import path from "path"
import { useTuiPaths } from "./runtime" import { useTuiPaths } from "./runtime"
@@ -22,6 +22,7 @@ import { useToast } from "../ui/toast"
import { useRoute } from "./route" import { useRoute } from "./route"
import { useData } from "./data" import { useData } from "./data"
import { usePermission } from "./permission" import { usePermission } from "./permission"
import { useLocation } from "./location"
export function parseModel(model: string) { export function parseModel(model: string) {
const [providerID, ...rest] = model.split("/") const [providerID, ...rest] = model.split("/")
@@ -57,26 +58,29 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const args = useArgs() const args = useArgs()
const event = useEvent() const event = useEvent()
const permission = usePermission() const permission = usePermission()
const location = useLocation()
const models = () => data.location.model.list(location.ref)
const providers = () => data.location.provider.list(location.ref)
function isModelValid(model: ModelPreferenceModel) { function isModelValid(model: ModelPreferenceModel) {
return !!data.location.model return !!models()?.some((item) => item.providerID === model.providerID && item.id === model.modelID)
.list()
?.some((item) => item.providerID === model.providerID && item.id === model.modelID)
} }
function getFirstValidModel(...modelFns: (() => ModelPreferenceModel | undefined)[]) { function getFirstValidModel(...modelFns: (() => ModelPreferenceModel | undefined)[]) {
for (const modelFn of modelFns) { for (const modelFn of modelFns) {
const model = modelFn() const model = modelFn()
if (!model) continue if (model && isModelValid(model)) return model
if (isModelValid(model)) return model
} }
} }
function createAgent() { function createAgent() {
const agents = createMemo(() => const agents = createMemo(() =>
(data.location.agent.list() ?? []).filter((agent) => agent.mode !== "subagent" && !agent.hidden), (data.location.agent.list(location.ref) ?? []).filter((agent) => agent.mode !== "subagent" && !agent.hidden),
)
const visibleAgents = createMemo(() =>
(data.location.agent.list(location.ref) ?? []).filter((agent) => !agent.hidden),
) )
const visibleAgents = createMemo(() => (data.location.agent.list() ?? []).filter((agent) => !agent.hidden))
const [agentStore, setAgentStore] = createStore({ const [agentStore, setAgentStore] = createStore({
current: undefined as string | undefined, current: undefined as string | undefined,
}) })
@@ -128,35 +132,40 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const agent = createAgent() const agent = createAgent()
function createModel() { function createModel() {
const [modelStore, setModelStore] = createStore< type ModelSelection = ModelPreferenceModel & { variant?: string }
ModelPreference & { const [preferences, setPreferences] = createStore<ModelPreference & { ready: boolean }>({
ready: boolean
model: Record<string, ModelPreferenceModel>
}
>({
ready: false, ready: false,
model: {},
recent: [], recent: [],
favorite: [], favorite: [],
variant: {}, variant: {},
}) })
const [selectionState, setSelectionState] = createStore<{
newSessionModelByLocationAgent: Record<string, ModelPreferenceModel | undefined>
draftBySession: Record<string, ModelSelection | undefined>
}>({
newSessionModelByLocationAgent: {},
draftBySession: {},
})
const repository = createModelPreferenceRepository(path.join(paths.state, "model.json")) const repository = createModelPreferenceRepository(path.join(paths.state, "model.json"))
const state = { const pendingSelectionCommits = new Map<string, string>()
const selectionKey = (value: ModelSelection) =>
`${modelPreferenceKey(value)}:${normalizeModelVariant(value.variant) ?? "default"}`
const saveState = {
pending: false, pending: false,
} }
function save() { function savePreferences() {
if (!modelStore.ready) { if (!preferences.ready) {
state.pending = true saveState.pending = true
return return
} }
state.pending = false saveState.pending = false
void repository void repository
.patch({ .patch({
recent: modelStore.recent, recent: preferences.recent,
favorite: modelStore.favorite, favorite: preferences.favorite,
variant: modelStore.variant, variant: preferences.variant,
}) })
.catch(() => undefined) .catch(() => undefined)
} }
@@ -164,14 +173,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
repository repository
.load() .load()
.then((value) => { .then((value) => {
setModelStore("recent", value.recent) setPreferences("recent", value.recent)
setModelStore("favorite", value.favorite) setPreferences("favorite", value.favorite)
setModelStore("variant", value.variant) setPreferences("variant", value.variant)
}) })
.catch(() => {}) .catch(() => {})
.finally(() => { .finally(() => {
setModelStore("ready", true) setPreferences("ready", true)
if (state.pending) save() if (saveState.pending) savePreferences()
}) })
const fallbackModel = createMemo(() => { const fallbackModel = createMemo(() => {
@@ -185,13 +194,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
} }
} }
for (const item of modelStore.recent) { for (const item of preferences.recent) {
if (isModelValid(item)) { if (isModelValid(item)) {
return item return item
} }
} }
const model = data.location.model.list()?.[0] const model = models()?.[0]
if (!model) return undefined if (!model) return undefined
return { return {
providerID: model.providerID, providerID: model.providerID,
@@ -199,30 +208,134 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
} }
}) })
const currentModel = createMemo(() => { const newSessionModel = createMemo(() => {
const a = agent.current() const a = agent.current()
return ( return getFirstValidModel(
getFirstValidModel( () => a && selectionState.newSessionModelByLocationAgent[locationAgentKey(a.id)],
() => a && modelStore.model[a.id], () => a?.model && { providerID: a.model.providerID, modelID: a.model.id },
() => a?.model && { providerID: a.model.providerID, modelID: a.model.id }, fallbackModel,
fallbackModel,
) ?? undefined
) )
}) })
const currentSelection = createMemo<ModelSelection | undefined>(() => {
if (route.data.type === "session") return sessionSelection(route.data.sessionID)
const model = newSessionModel()
if (!model) return
return { ...model, variant: normalizeModelVariant(preferences.variant[modelPreferenceKey(model)]) }
})
const currentModel = createMemo(() => {
const selection = currentSelection()
if (!selection) return
return { providerID: selection.providerID, modelID: selection.modelID }
})
function locationAgentKey(agentID: string) {
const ref = location.ref ?? data.location.default()
return `${JSON.stringify([ref.directory, ref.workspaceID])}:${agentID}`
}
function durableSelection(sessionID: string): ModelSelection | undefined {
const model = data.session.get(sessionID)?.model
if (!model) return
return {
providerID: model.providerID,
modelID: model.id,
variant: normalizeModelVariant(model.variant),
}
}
function sessionSelection(sessionID: string) {
return selectionState.draftBySession[sessionID] ?? durableSelection(sessionID)
}
function setSessionDraft(sessionID: string, selection: ModelSelection) {
const durable = durableSelection(sessionID)
setSelectionState(
"draftBySession",
sessionID,
durable && selectionKey(durable) === selectionKey(selection) ? undefined : selection,
)
}
function selectModel(model: ModelPreferenceModel) {
if (route.data.type === "session") {
const sessionID = route.data.sessionID
const current = sessionSelection(sessionID)
const preferred = normalizeModelVariant(
current?.providerID === model.providerID && current.modelID === model.modelID
? current.variant
: preferences.variant[modelPreferenceKey(model)],
)
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
setSessionDraft(sessionID, { ...model, variant })
return true
}
const current = agent.current()
if (!current) return false
setSelectionState("newSessionModelByLocationAgent", locationAgentKey(current.id), model)
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(
event.on("session.deleted", (evt) => {
pendingSelectionCommits.delete(evt.data.sessionID)
setSelectionState("draftBySession", evt.data.sessionID, undefined)
}),
)
return { return {
current: currentModel, current: currentModel,
selection: currentSelection,
available(model = currentModel()) {
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 modelStore.ready return preferences.ready
},
get catalogReady() {
return models() !== undefined
}, },
recent() { recent() {
return modelStore.recent return preferences.recent
}, },
favorite() { favorite() {
return modelStore.favorite return preferences.favorite
}, },
parsed: createMemo(() => { parsed: createMemo(() => {
const value = currentModel() const value = currentSelection()
if (!value) { if (!value) {
return { return {
provider: "Connect a provider", provider: "Connect a provider",
@@ -230,33 +343,28 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
reasoning: false, reasoning: false,
} }
} }
const provider = data.location.provider.list()?.find((item) => item.id === value.providerID) const provider = providers()?.find((item) => item.id === value.providerID)
const info = data.location.model const info = models()?.find((item) => item.providerID === value.providerID && item.id === value.modelID)
.list()
?.find((item) => item.providerID === value.providerID && item.id === value.modelID)
return { return {
provider: provider?.name ?? value.providerID, provider: provider?.name ?? value.providerID,
model: info?.name ?? value.modelID, model: info?.name ?? `${value.modelID} (unavailable)`,
reasoning: (info?.variants?.length ?? 0) !== 0, reasoning: (info?.variants?.length ?? 0) !== 0,
} }
}), }),
cycle(direction: 1 | -1) { cycle(direction: 1 | -1) {
const current = currentModel() const current = currentSelection()
if (!current) return if (!current) return
const recent = modelStore.recent const recent = recentModels(current, preferences.recent).filter(isModelValid)
const index = recent.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID) const index = recent.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID)
if (index === -1) return let next = index === -1 ? (direction === 1 ? 0 : recent.length - 1) : index + direction
let next = index + direction
if (next < 0) next = recent.length - 1 if (next < 0) next = recent.length - 1
if (next >= recent.length) next = 0 if (next >= recent.length) next = 0
const val = recent[next] const val = recent[next]
if (!val) return if (!val) return
const a = agent.current() selectModel({ ...val })
if (!a) return
setModelStore("model", a.id, { ...val })
}, },
cycleFavorite(direction: 1 | -1) { cycleFavorite(direction: 1 | -1) {
const favorites = modelStore.favorite.filter((item) => isModelValid(item)) const favorites = preferences.favorite.filter((item) => isModelValid(item))
if (!favorites.length) { if (!favorites.length) {
toast.show({ toast.show({
variant: "info", variant: "info",
@@ -265,7 +373,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}) })
return return
} }
const current = currentModel() const current = currentSelection()
let index = -1 let index = -1
if (current) { if (current) {
index = favorites.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID) index = favorites.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID)
@@ -279,45 +387,39 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
} }
const next = favorites[index] const next = favorites[index]
if (!next) return if (!next) return
const a = agent.current() if (!selectModel({ ...next })) return
if (!a) return setPreferences("recent", recentModels(next, preferences.recent))
setModelStore("model", a.id, { ...next }) savePreferences()
setModelStore("recent", recentModels(next, modelStore.recent))
save()
}, },
set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) { set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
batch(() => { batch(() => {
if (!isModelValid(model)) return if (!isModelValid(model)) return
const a = agent.current() if (!selectModel(model)) return
if (!a) return
setModelStore("model", a.id, model)
if (options?.recent) { if (options?.recent) {
setModelStore("recent", recentModels(model, modelStore.recent)) setPreferences("recent", recentModels(model, preferences.recent))
save() savePreferences()
} }
}) })
}, },
toggleFavorite(model: { providerID: string; modelID: string }) { toggleFavorite(model: { providerID: string; modelID: string }) {
batch(() => { batch(() => {
if (!isModelValid(model)) return if (!isModelValid(model)) return
const exists = modelStore.favorite.some( const exists = preferences.favorite.some(
(x) => x.providerID === model.providerID && x.modelID === model.modelID, (x) => x.providerID === model.providerID && x.modelID === model.modelID,
) )
const next = exists const next = exists
? modelStore.favorite.filter((x) => x.providerID !== model.providerID || x.modelID !== model.modelID) ? preferences.favorite.filter((x) => x.providerID !== model.providerID || x.modelID !== model.modelID)
: [model, ...modelStore.favorite] : [model, ...preferences.favorite]
setModelStore( setPreferences(
"favorite", "favorite",
next.map((x) => ({ providerID: x.providerID, modelID: x.modelID })), next.map((x) => ({ providerID: x.providerID, modelID: x.modelID })),
) )
save() savePreferences()
}) })
}, },
variant: { variant: {
selected() { selected() {
const m = currentModel() return currentSelection()?.variant
if (!m) return undefined
return normalizeModelVariant(modelStore.variant[modelPreferenceKey(m)])
}, },
current() { current() {
const v = this.selected() const v = this.selected()
@@ -325,18 +427,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return undefined return undefined
}, },
list() { list() {
const m = currentModel() const m = currentSelection()
if (!m) return [] if (!m) return []
const info = data.location.model const info = models()?.find((item) => item.providerID === m.providerID && item.id === m.modelID)
.list()
?.find((item) => item.providerID === m.providerID && item.id === m.modelID)
return info?.variants?.map((variant) => variant.id) ?? [] return info?.variants?.map((variant) => variant.id) ?? []
}, },
set(value: string | undefined) { set(value: string | undefined) {
const m = currentModel() const m = currentSelection()
if (!m) return if (!m) return
setModelStore("variant", modelPreferenceKey(m), normalizeModelVariant(value)) if (route.data.type === "session") {
save() setSessionDraft(route.data.sessionID, { ...m, variant: normalizeModelVariant(value) })
return
}
setPreferences("variant", modelPreferenceKey(m), normalizeModelVariant(value))
savePreferences()
}, },
cycle() { cycle() {
const variants = this.list() const variants = this.list()
+2 -2
View File
@@ -204,7 +204,7 @@ export function Session() {
const availableWidth = createMemo( const availableWidth = createMemo(
() => () =>
dimensions().width - dimensions().width -
(config.tabs?.enabled && config.tabs.vertical && sessionTabsFitVertically(dimensions().width) (config.tabs?.enabled && config.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width)
? SESSION_SIDEBAR_WIDTH ? SESSION_SIDEBAR_WIDTH
: 0), : 0),
) )
@@ -361,7 +361,7 @@ export function Session() {
createEffect(() => { createEffect(() => {
const current = prompt() const current = prompt()
if (sent || !current || !synced() || !local.model.ready) return if (sent || !current || !synced() || !local.model.ready || !local.model.catalogReady) return
if (!local.agent.current() || !local.model.current()) return if (!local.agent.current() || !local.model.current()) return
if (!args.prompt || route.prompt?.text !== args.prompt || current.current.text !== args.prompt) return if (!args.prompt || route.prompt?.text !== args.prompt || current.current.text !== args.prompt) return
sent = true sent = true
@@ -200,12 +200,10 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
<box <box
paddingLeft={4} paddingLeft={4}
paddingRight={4} paddingRight={4}
backgroundColor={ backgroundColor={overlayTheme.background.default}
store.active === "copy" ? theme.background.action.primary.focused : overlayTheme.background.default
}
onMouseUp={() => confirm("copy")} onMouseUp={() => confirm("copy")}
> >
<text fg={store.active === "copy" ? theme.text.action.primary.focused : overlayTheme.text.default}>Copy</text> <text fg={overlayTheme.text.default}>Copy</text>
</box> </box>
<box <box
paddingLeft={4} paddingLeft={4}
+6 -2
View File
@@ -18,7 +18,10 @@ test("validates mini replay settings", () => {
test("validates the session tabs setting", () => { test("validates the session tabs setting", () => {
const decode = Schema.decodeUnknownSync(Info) const decode = Schema.decodeUnknownSync(Info)
expect(decode({ tabs: { enabled: true, vertical: true } })).toEqual({ tabs: { enabled: true, vertical: true } }) expect(decode({ tabs: { enabled: true, layout: "vertical" } })).toEqual({
tabs: { enabled: true, layout: "vertical" },
})
expect(() => decode({ tabs: { layout: true } })).toThrow()
expect(() => decode({ tabs: { enabled: "on" } })).toThrow() expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
}) })
@@ -39,12 +42,13 @@ test("resolves nested config and keybind defaults", () => {
expect(config.scroll).toEqual({ speed: 2, acceleration: true }) expect(config.scroll).toEqual({ speed: 2, acceleration: true })
expect(config.diffs).toEqual({ view: "split" }) expect(config.diffs).toEqual({ view: "split" })
expect(config.debug).toEqual({ devtools: true }) expect(config.debug).toEqual({ devtools: true })
expect(config.tabs).toEqual({ enabled: true, scope: "cwd" }) expect(config.tabs).toEqual({ enabled: true, scope: "cwd", layout: "horizontal" })
}) })
test("shows resolved tab defaults in settings", () => { test("shows resolved tab defaults in settings", () => {
expect(settings.find((setting) => setting.path.join(".") === "tabs.enabled")?.default).toBe(true) expect(settings.find((setting) => setting.path.join(".") === "tabs.enabled")?.default).toBe(true)
expect(settings.find((setting) => setting.path.join(".") === "tabs.scope")?.default).toBe("cwd") expect(settings.find((setting) => setting.path.join(".") === "tabs.scope")?.default).toBe("cwd")
expect(settings.find((setting) => setting.path.join(".") === "tabs.layout")?.default).toBe("horizontal")
}) })
test("provides config and its host interface", async () => { test("provides config and its host interface", async () => {