mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-14 15:32:52 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a7288d231e | |||
| f75244d795 | |||
| 1d44d56d9c | |||
| 9adc9bb5df |
@@ -19,6 +19,13 @@
|
||||
- Expose the meaningful state dimensions through story keybindings and list them in `StoryFooter`; include a reset command when combinations can leave the fixture in a confusing state.
|
||||
- Run a specific story with `OPENCODE_STORY=<story-id> bun run dev:live` from the development worktree, and exercise narrow and wide terminal sizes when layout is relevant.
|
||||
|
||||
## TUI Theme Tokens
|
||||
|
||||
- Choose theme tokens by semantic role, not by their current color. Do not use raw `theme.hue` values or borrow an unrelated semantic token to achieve a preferred appearance.
|
||||
- Use `text.feedback` and `background.feedback` only for outcome or status feedback such as errors, warnings, success messages, and informational messages. Use `formfield` states for form-control text, ordinals, and selection markers, and `action` states for actions.
|
||||
- If the theme does not expose a token for the required semantic role, extend the theme schema, defaults, resolution, and types with that role before using it in a component. Do not repurpose the nearest-looking existing token.
|
||||
- When changing the public theme token surface, verify the built-in light and dark defaults and the custom-theme fallback path in addition to the affected TUI component.
|
||||
|
||||
## Branch Names
|
||||
|
||||
Use a short branch name of at most three words, separated by hyphens. Do not use slashes or type prefixes such as `feat/` or `fix/`.
|
||||
|
||||
@@ -15,7 +15,6 @@ import { GoogleVertexPlugin } from "./provider/google-vertex.js"
|
||||
import { GroqPlugin } from "./provider/groq.js"
|
||||
import { KiloPlugin } from "./provider/kilo.js"
|
||||
import { LLMGatewayPlugin } from "./provider/llmgateway.js"
|
||||
import { LMStudioPlugin } from "./provider/lmstudio.js"
|
||||
import { MistralPlugin } from "./provider/mistral.js"
|
||||
import { NvidiaPlugin } from "./provider/nvidia.js"
|
||||
import { OpenAIPlugin } from "./provider/openai.js"
|
||||
@@ -49,7 +48,6 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
|
||||
GroqPlugin,
|
||||
KiloPlugin,
|
||||
LLMGatewayPlugin,
|
||||
LMStudioPlugin,
|
||||
MistralPlugin,
|
||||
NvidiaPlugin,
|
||||
OpencodePlugin,
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Document, type Entry } from "@opencode-ai/schema/config"
|
||||
import { Duration, Effect, Schedule, Schema, Semaphore, Stream } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Config } from "../../config.js"
|
||||
import { Model } from "../../model.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
import type { PluginInternal } from "../internal.js"
|
||||
|
||||
const providerID = "lmstudio"
|
||||
|
||||
const RemoteModel = Schema.Struct({
|
||||
type: Schema.Literals(["llm", "embedding"]),
|
||||
key: Schema.String,
|
||||
display_name: Schema.String,
|
||||
architecture: Schema.NullOr(Schema.String).pipe(Schema.optional),
|
||||
loaded_instances: Schema.Array(
|
||||
Schema.Struct({
|
||||
config: Schema.Struct({ context_length: Schema.Int }),
|
||||
}),
|
||||
),
|
||||
max_context_length: Schema.Int,
|
||||
capabilities: Schema.Struct({
|
||||
vision: Schema.Boolean,
|
||||
trained_for_tool_use: Schema.Boolean,
|
||||
}).pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const Response = Schema.Struct({ models: Schema.Array(RemoteModel) })
|
||||
const discovery = new Map<string, { checked: number; apiKey?: string; models?: (typeof RemoteModel.Type)[] }>()
|
||||
const discoveryLock = Semaphore.makeUnsafe(1)
|
||||
|
||||
export function make(origin = "http://127.0.0.1:1234", interval: Duration.Input = "30 seconds") {
|
||||
return define({
|
||||
id: "opencode.provider.lmstudio",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
|
||||
const config = yield* Config.Service
|
||||
const source = { current: configured(yield* config.entries(), origin) }
|
||||
const loaded = { models: [] as (typeof RemoteModel.Type)[], hash: "[]" }
|
||||
|
||||
yield* ctx.integration.transform((integrations) => {
|
||||
if (loaded.models.length === 0) return
|
||||
integrations.remove(providerID)
|
||||
})
|
||||
|
||||
yield* ctx.catalog.transform((catalog) => {
|
||||
if (loaded.models.length === 0) return
|
||||
for (const model of catalog.provider.get(providerID)?.models.values() ?? []) {
|
||||
catalog.model.remove(providerID, model.id)
|
||||
}
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.name = "LM Studio"
|
||||
provider.package = "@opencode-ai/ai/providers/openai-compatible"
|
||||
provider.settings = {
|
||||
baseURL: source.current.baseURL,
|
||||
provider: providerID,
|
||||
apiKey: source.current.apiKey ?? "",
|
||||
}
|
||||
provider.integrationID = undefined
|
||||
})
|
||||
for (const item of loaded.models) {
|
||||
catalog.model.update(providerID, item.key, (model) => {
|
||||
model.modelID = Model.ID.make(item.key)
|
||||
model.name = item.display_name || item.key
|
||||
model.family = item.architecture ? Model.Family.make(item.architecture) : undefined
|
||||
model.capabilities = {
|
||||
tools: item.capabilities?.trained_for_tool_use ?? false,
|
||||
input: ["text", ...(item.capabilities?.vision ? ["image"] : [])],
|
||||
output: ["text"],
|
||||
}
|
||||
model.limit = {
|
||||
context:
|
||||
item.loaded_instances.length === 0
|
||||
? item.max_context_length
|
||||
: Math.min(...item.loaded_instances.map((instance) => instance.config.context_length)),
|
||||
output: 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const discover = Effect.fn("LMStudioPlugin.discover")(function* () {
|
||||
const current = source.current
|
||||
if (!current.endpoint) return undefined
|
||||
return yield* discoveryLock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const cached = discovery.get(current.endpoint)
|
||||
if (cached && cached.apiKey === current.apiKey && Date.now() - cached.checked < Duration.toMillis(interval))
|
||||
return { source: current, models: cached.models }
|
||||
discovery.set(current.endpoint, {
|
||||
checked: Date.now(),
|
||||
apiKey: current.apiKey,
|
||||
models: cached && cached.apiKey === current.apiKey ? cached.models : undefined,
|
||||
})
|
||||
const request = current.apiKey
|
||||
? HttpClientRequest.get(current.endpoint).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(current.apiKey),
|
||||
)
|
||||
: HttpClientRequest.get(current.endpoint).pipe(HttpClientRequest.acceptJson)
|
||||
const response = yield* http
|
||||
.execute(request)
|
||||
.pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(Response)), Effect.timeout("1 second"))
|
||||
const models = response.models
|
||||
.filter((model) => model.type === "llm" && model.key.length > 0)
|
||||
.toSorted((a, b) => a.key.localeCompare(b.key))
|
||||
discovery.set(current.endpoint, { checked: Date.now(), apiKey: current.apiKey, models })
|
||||
return { source: current, models }
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const refresh = Effect.fn("LMStudioPlugin.refresh")(function* () {
|
||||
const result = yield* discover()
|
||||
if (!result?.models || result.source !== source.current) return
|
||||
const hash = JSON.stringify(result.models)
|
||||
if (hash === loaded.hash) return
|
||||
loaded.models = result.models
|
||||
loaded.hash = hash
|
||||
yield* ctx.integration.reload()
|
||||
yield* ctx.catalog.reload()
|
||||
})
|
||||
|
||||
// Keep the last successful inventory through transient outages instead of flickering model availability.
|
||||
yield* refresh().pipe(Effect.ignore, Effect.repeat(Schedule.spaced(interval)), Effect.forkScoped)
|
||||
const reload = Effect.fn("LMStudioPlugin.reload")(function* () {
|
||||
const next = configured(yield* config.entries(), origin)
|
||||
if (
|
||||
next.baseURL === source.current.baseURL &&
|
||||
next.apiKey === source.current.apiKey &&
|
||||
next.endpoint === source.current.endpoint
|
||||
)
|
||||
return
|
||||
source.current = next
|
||||
loaded.models = []
|
||||
loaded.hash = "[]"
|
||||
yield* ctx.integration.reload()
|
||||
yield* ctx.catalog.reload()
|
||||
yield* refresh().pipe(Effect.ignore)
|
||||
})
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(reload),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}),
|
||||
} satisfies PluginInternal.InternalPlugin)
|
||||
}
|
||||
|
||||
export const LMStudioPlugin = make()
|
||||
|
||||
function configured(entries: readonly Entry[], origin: string) {
|
||||
const settings = entries
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.flatMap((entry) => {
|
||||
const settings = entry.info.providers?.[providerID]?.settings
|
||||
return settings ? [settings] : []
|
||||
})
|
||||
.reduce<Provider.Settings | undefined>((result, item) => Provider.mergeOverlay(result, item), undefined)
|
||||
const baseURL = (
|
||||
typeof settings?.baseURL === "string" ? settings.baseURL : `${origin.replace(/\/+$/, "")}/v1`
|
||||
).replace(/\/+$/, "")
|
||||
const apiKey = typeof settings?.apiKey === "string" ? settings.apiKey : undefined
|
||||
if (!URL.canParse(baseURL)) return { baseURL, apiKey }
|
||||
const url = new URL(baseURL)
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return { baseURL, apiKey }
|
||||
const prefix = url.pathname.endsWith("/v1") ? url.pathname.slice(0, -3) : url.pathname.replace(/\/+$/, "")
|
||||
url.pathname = `${prefix}/api/v1/models`
|
||||
url.search = ""
|
||||
url.hash = ""
|
||||
return { baseURL, apiKey, endpoint: url.toString() }
|
||||
}
|
||||
@@ -1,338 +0,0 @@
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { LMStudioPlugin, make } from "@opencode-ai/core/plugin/provider/lmstudio"
|
||||
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { Document, Event, Info } from "@opencode-ai/schema/config"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Duration, Effect, Layer, Schema } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(Layer.merge(PluginTestLayer, Config.testLayer()))
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
const addPlugin = Effect.fn(function* (origin: string, interval: Duration.Input = "1 hour") {
|
||||
const plugin = yield* Plugin.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* make(origin, interval).effect(host)
|
||||
})
|
||||
|
||||
function eventually<A>(
|
||||
effect: Effect.Effect<A>,
|
||||
predicate: (value: A) => boolean,
|
||||
remaining = 3000,
|
||||
): Effect.Effect<A, Error> {
|
||||
return Effect.gen(function* () {
|
||||
const value = yield* effect
|
||||
if (predicate(value)) return value
|
||||
if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value"))
|
||||
yield* Effect.promise(() => Bun.sleep(1))
|
||||
return yield* eventually(effect, predicate, remaining - 1)
|
||||
})
|
||||
}
|
||||
|
||||
describe("LMStudioPlugin", () => {
|
||||
it.effect("is registered as a built-in provider plugin", () =>
|
||||
Effect.sync(() => {
|
||||
expect(LMStudioPlugin.id).toBe("opencode.provider.lmstudio")
|
||||
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.lmstudio")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("discovers local language models with their capabilities and effective context", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() =>
|
||||
Bun.serve({
|
||||
port: 0,
|
||||
fetch: () =>
|
||||
Response.json({
|
||||
models: [
|
||||
{
|
||||
type: "llm",
|
||||
key: "google/gemma-4-26b-a4b",
|
||||
display_name: "Gemma 4 26B A4B",
|
||||
architecture: "gemma4",
|
||||
loaded_instances: [{ config: { context_length: 32_768 } }, { config: { context_length: 16_384 } }],
|
||||
max_context_length: 262_144,
|
||||
capabilities: { vision: true, trained_for_tool_use: true },
|
||||
},
|
||||
{
|
||||
type: "llm",
|
||||
key: "deepseek-r1",
|
||||
display_name: "DeepSeek R1",
|
||||
architecture: "deepseek",
|
||||
loaded_instances: [],
|
||||
max_context_length: 131_072,
|
||||
capabilities: { vision: false, trained_for_tool_use: false },
|
||||
},
|
||||
{
|
||||
type: "embedding",
|
||||
key: "nomic-embed",
|
||||
display_name: "Nomic Embed",
|
||||
loaded_instances: [],
|
||||
max_context_length: 2048,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
),
|
||||
(server) =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* addPlugin(server.url.origin)
|
||||
const providerID = Provider.ID.make("lmstudio")
|
||||
const gemma = yield* eventually(
|
||||
catalog.model.get(providerID, Model.ID.make("google/gemma-4-26b-a4b")),
|
||||
(model) => model !== undefined,
|
||||
)
|
||||
|
||||
expect(yield* catalog.provider.get(providerID)).toEqual({
|
||||
id: providerID,
|
||||
name: "LM Studio",
|
||||
package: "@opencode-ai/ai/providers/openai-compatible",
|
||||
settings: { baseURL: `${server.url.origin}/v1`, provider: "lmstudio", apiKey: "" },
|
||||
})
|
||||
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID)
|
||||
expect(gemma).toMatchObject({
|
||||
family: "gemma4",
|
||||
name: "Gemma 4 26B A4B",
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
limit: { context: 16_384, output: 0 },
|
||||
})
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("deepseek-r1"))).toMatchObject({
|
||||
capabilities: { tools: false, input: ["text"], output: ["text"] },
|
||||
limit: { context: 131_072, output: 0 },
|
||||
})
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("nomic-embed"))).toBeUndefined()
|
||||
}),
|
||||
(server) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("refreshes the catalog when LM Studio models change", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const models: Array<Record<string, unknown>> = []
|
||||
return {
|
||||
models,
|
||||
server: Bun.serve({ port: 0, fetch: () => Response.json({ models }) }),
|
||||
}
|
||||
}),
|
||||
({ models, server }) =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = Provider.ID.make("lmstudio")
|
||||
yield* addPlugin(server.url.origin, "5 millis")
|
||||
expect(yield* catalog.provider.get(providerID)).toBeUndefined()
|
||||
|
||||
models.push({
|
||||
type: "llm",
|
||||
key: "qwen/qwen3-coder",
|
||||
display_name: "Qwen 3 Coder",
|
||||
architecture: "qwen3",
|
||||
loaded_instances: [],
|
||||
max_context_length: 65_536,
|
||||
capabilities: { vision: false, trained_for_tool_use: true },
|
||||
})
|
||||
expect(
|
||||
yield* eventually(
|
||||
catalog.model.get(providerID, Model.ID.make("qwen/qwen3-coder")),
|
||||
(model) => model !== undefined,
|
||||
),
|
||||
).toMatchObject({ name: "Qwen 3 Coder" })
|
||||
|
||||
models.splice(0)
|
||||
yield* eventually(catalog.provider.get(providerID), (provider) => provider === undefined)
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("discovers from configured endpoints with bearer authentication", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const requests: Array<{ authorization: string | null; path: string }> = []
|
||||
const model = (key: string) => ({
|
||||
type: "llm",
|
||||
key,
|
||||
display_name: key,
|
||||
loaded_instances: [],
|
||||
max_context_length: 32_768,
|
||||
})
|
||||
return {
|
||||
requests,
|
||||
initial: Bun.serve({ port: 0, fetch: () => Response.json({ models: [model("initial-model")] }) }),
|
||||
configured: Bun.serve({
|
||||
port: 0,
|
||||
fetch: (request) => {
|
||||
requests.push({
|
||||
authorization: request.headers.get("authorization"),
|
||||
path: new URL(request.url).pathname,
|
||||
})
|
||||
return Response.json({ models: [model("configured-model")] })
|
||||
},
|
||||
}),
|
||||
}
|
||||
}),
|
||||
({ requests, initial, configured }) =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const config = yield* Config.Test
|
||||
const providerID = Provider.ID.make("lmstudio")
|
||||
yield* addPlugin(initial.url.origin)
|
||||
yield* eventually(
|
||||
catalog.model.get(providerID, Model.ID.make("initial-model")),
|
||||
(model) => model !== undefined,
|
||||
)
|
||||
|
||||
const baseURL = `${configured.url.origin}/proxy/v1`
|
||||
yield* config.setEntries([configuration(baseURL, "secret")])
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
yield* eventually(
|
||||
catalog.model.get(providerID, Model.ID.make("configured-model")),
|
||||
(model) => model !== undefined,
|
||||
)
|
||||
|
||||
expect(requests).toContainEqual({ authorization: "Bearer secret", path: "/proxy/api/v1/models" })
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("initial-model"))).toBeUndefined()
|
||||
expect((yield* catalog.provider.get(providerID))?.settings).toEqual({
|
||||
baseURL,
|
||||
provider: "lmstudio",
|
||||
apiKey: "secret",
|
||||
})
|
||||
|
||||
requests.splice(0)
|
||||
yield* config.setEntries([configuration(baseURL, "secret"), configuration(baseURL, null)])
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
yield* eventually(catalog.provider.get(providerID), (provider) => provider?.settings?.apiKey === "")
|
||||
expect(requests).toContainEqual({ authorization: null, path: "/proxy/api/v1/models" })
|
||||
}),
|
||||
({ initial, configured }) => Effect.promise(() => Promise.all([initial.stop(true), configured.stop(true)])),
|
||||
),
|
||||
10_000,
|
||||
)
|
||||
|
||||
it.live("shares discovery requests across plugin instances", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const requests = { count: 0 }
|
||||
return {
|
||||
requests,
|
||||
server: Bun.serve({
|
||||
port: 0,
|
||||
fetch: () => {
|
||||
requests.count++
|
||||
return Response.json({
|
||||
models: [
|
||||
{
|
||||
type: "llm",
|
||||
key: "shared-model",
|
||||
display_name: "Shared Model",
|
||||
loaded_instances: [],
|
||||
max_context_length: 32_768,
|
||||
},
|
||||
],
|
||||
})
|
||||
},
|
||||
}),
|
||||
}
|
||||
}),
|
||||
({ requests, server }) =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* addPlugin(server.url.origin)
|
||||
yield* addPlugin(server.url.origin)
|
||||
yield* eventually(
|
||||
catalog.model.get(Provider.ID.make("lmstudio"), Model.ID.make("shared-model")),
|
||||
(model) => model !== undefined,
|
||||
)
|
||||
expect(requests.count).toBe(1)
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("replaces the credential-gated Models.dev catalog when discovery succeeds", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const models = [
|
||||
{
|
||||
type: "llm",
|
||||
key: "discovered-model",
|
||||
display_name: "Discovered Model",
|
||||
loaded_instances: [],
|
||||
max_context_length: 32_768,
|
||||
},
|
||||
]
|
||||
return { models, server: Bun.serve({ port: 0, fetch: () => Response.json({ models }) }) }
|
||||
}),
|
||||
({ models, server }) =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const providerID = Provider.ID.make("lmstudio")
|
||||
yield* integrations.transform((draft) => {
|
||||
draft.update(Integration.ID.make("lmstudio"), (integration) => {
|
||||
integration.name = "LMStudio"
|
||||
})
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make("lmstudio"),
|
||||
method: { type: "env", names: ["LMSTUDIO_API_KEY"] },
|
||||
})
|
||||
})
|
||||
yield* catalog.transform((draft) => {
|
||||
draft.provider.update(providerID, (provider) => {
|
||||
provider.name = "LMStudio"
|
||||
provider.package = "aisdk:@ai-sdk/openai-compatible"
|
||||
provider.integrationID = Integration.ID.make("lmstudio")
|
||||
})
|
||||
draft.model.update(providerID, Model.ID.make("static-model"), () => {})
|
||||
})
|
||||
|
||||
expect((yield* catalog.provider.available()).map((provider) => provider.id)).not.toContain(providerID)
|
||||
yield* addPlugin(server.url.origin, "5 millis")
|
||||
yield* eventually(
|
||||
catalog.model.get(providerID, Model.ID.make("discovered-model")),
|
||||
(model) => model !== undefined,
|
||||
)
|
||||
|
||||
expect(yield* integrations.get(Integration.ID.make("lmstudio"))).toBeUndefined()
|
||||
expect((yield* catalog.provider.get(providerID))?.integrationID).toBeUndefined()
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("static-model"))).toBeUndefined()
|
||||
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID)
|
||||
|
||||
yield* integrations.transform((draft) => {
|
||||
draft.update(Integration.ID.make("lmstudio"), (integration) => {
|
||||
integration.name = "Configured LM Studio"
|
||||
})
|
||||
draft.method.update({ integrationID: Integration.ID.make("lmstudio"), method: { type: "key" } })
|
||||
})
|
||||
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID)
|
||||
|
||||
models.splice(0)
|
||||
yield* eventually(
|
||||
catalog.model.get(providerID, Model.ID.make("static-model")),
|
||||
(model) => model !== undefined,
|
||||
)
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("discovered-model"))).toBeUndefined()
|
||||
expect(yield* integrations.get(Integration.ID.make("lmstudio"))).toBeDefined()
|
||||
expect((yield* catalog.provider.get(providerID))?.integrationID).toBe(Integration.ID.make("lmstudio"))
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
function configuration(baseURL: string, apiKey: string | null) {
|
||||
return new Document({
|
||||
type: "document",
|
||||
info: decode({ providers: { lmstudio: { settings: { baseURL, apiKey } } } }),
|
||||
})
|
||||
}
|
||||
@@ -171,7 +171,7 @@ export interface SlotMap {
|
||||
readonly "prompt.footer.file": PromptFooterInput
|
||||
readonly "session.composer.top": { readonly sessionID: string }
|
||||
readonly "sidebar.content": { readonly sessionID: string }
|
||||
readonly "sidebar.footer": Readonly<Record<string, never>>
|
||||
readonly "sidebar.footer": { readonly sessionID: string }
|
||||
}
|
||||
export type SlotPath = keyof SlotMap
|
||||
|
||||
|
||||
@@ -1349,9 +1349,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
width={1}
|
||||
height="100%"
|
||||
backgroundColor={
|
||||
tabsResizeHovered() || tabsResizing()
|
||||
? tabsTheme.background.action.primary.hovered
|
||||
: tabsTheme.background.default
|
||||
tabsResizeHovered() || tabsResizing() ? tabsTheme.background.action.primary.hovered : undefined
|
||||
}
|
||||
/>
|
||||
</box>
|
||||
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
import { DialogImagePreview } from "../dialog-image-preview"
|
||||
import { useDirectoryRecents } from "../../prompt/directory-recents"
|
||||
import { directoryRecentValue } from "../../prompt/directory-completion"
|
||||
import { useWorkingDirectoryActions } from "../../ui/working-directory-actions"
|
||||
|
||||
export type PromptProps = {
|
||||
sessionID?: string
|
||||
@@ -1539,21 +1540,25 @@ export function Prompt(props: PromptProps) {
|
||||
const width = dimensions().width < 44 ? dimensions().width - 5 : Math.min(75, dimensions().width - 4) - 5
|
||||
return Locale.takeWidth(value, Math.max(1, width)).trimEnd()
|
||||
})
|
||||
const locationLabel = createMemo(() => {
|
||||
const footerLocation = createMemo(() => {
|
||||
if (!props.sessionID) {
|
||||
// No session yet: show where the next session will be created.
|
||||
const location = currentLocation.ref ?? data.location.default()
|
||||
const directory = abbreviateHome(location.directory, paths.home)
|
||||
const branch = data.location.vcs.info(location)?.branch.current
|
||||
return branch ? `${directory}:${branch}` : directory
|
||||
return currentLocation.ref ?? data.location.default()
|
||||
}
|
||||
if (status() !== "idle") return
|
||||
const location = data.session.get(props.sessionID)?.location
|
||||
return data.session.get(props.sessionID)?.location
|
||||
})
|
||||
const locationLabel = createMemo(() => {
|
||||
const location = footerLocation()
|
||||
if (!location) return
|
||||
const directory = abbreviateHome(location.directory, paths.home)
|
||||
const branch = data.location.vcs.info(location)?.branch.current
|
||||
return branch ? `${directory}:${branch}` : directory
|
||||
})
|
||||
const locationActions = useWorkingDirectoryActions({
|
||||
directory: () => footerLocation()?.directory,
|
||||
onMove: () => void move.open(),
|
||||
})
|
||||
|
||||
const spinnerDef = createMemo(() => {
|
||||
const agent = status() === "running" ? local.agent.current() : local.agent.current()
|
||||
@@ -1874,7 +1879,17 @@ export function Prompt(props: PromptProps) {
|
||||
<Match when={true}>
|
||||
<Show when={!props.hint && locationLabel()} fallback={props.hint ?? <text />}>
|
||||
{(location) => (
|
||||
<text fg={theme.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
|
||||
<text
|
||||
id="prompt.footer.location"
|
||||
fg={locationActions.hovered() ? theme.text.default : theme.text.subdued}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
onMouseOver={locationActions.onMouseOver}
|
||||
onMouseOut={locationActions.onMouseOut}
|
||||
onMouseUp={locationActions.onMouseUp}
|
||||
>
|
||||
{location()}
|
||||
</text>
|
||||
)}
|
||||
|
||||
@@ -307,6 +307,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
const [addHovered, setAddHovered] = createSignal(false)
|
||||
const marquee = createTabMarquee(animations)
|
||||
const hovered = marquee.hovered
|
||||
// OpenTUI captures the first drag target, which may differ from the tab pressed on a fast move.
|
||||
const [dragging, setDragging] = createSignal<string>()
|
||||
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
|
||||
const [contextMenu, setContextMenu] = createSignal<TabContextMenuState>()
|
||||
@@ -343,6 +344,9 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
const itemStatus = (tab: SessionTab) => statuses().get(tab.sessionID)!
|
||||
let rail: { screenX: number; screenY: number } | undefined
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
let didDrag = false
|
||||
// A captured drag ends with a synthetic up on its drop target; do not turn that into a click.
|
||||
let suppressClick = false
|
||||
|
||||
createEffect(() => {
|
||||
const pending = preview()
|
||||
@@ -364,6 +368,29 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
}
|
||||
})
|
||||
|
||||
const release = () => {
|
||||
const source = dragging()
|
||||
if (!source) return
|
||||
if (didDrag) suppressClick = true
|
||||
setDragging(undefined)
|
||||
const pending = preview()
|
||||
if (pending?.sessionID === source) tabs.move(pending.sessionID, pending.index)
|
||||
tabs.select(source)
|
||||
}
|
||||
|
||||
const drag = (event: MouseEvent) => {
|
||||
if (!rail) return
|
||||
const source = dragging()
|
||||
if (!source) return
|
||||
didDrag = true
|
||||
const target = Math.max(
|
||||
0,
|
||||
Math.min(tabs.tabs().length - 1, Math.floor((event.y - rail.screenY - 1 + (scroll?.scrollTop ?? 0)) / 3)),
|
||||
)
|
||||
const sourceIndex = items().findIndex((item) => item.sessionID === source)
|
||||
if (target !== sourceIndex && preview()?.index !== target) setPreview({ sessionID: source, index: target })
|
||||
}
|
||||
|
||||
return (
|
||||
<box
|
||||
ref={(element) => (rail = element)}
|
||||
@@ -375,6 +402,15 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
paddingTop={1}
|
||||
backgroundColor={theme.background.default}
|
||||
onMouseOut={marquee.leaveHovered}
|
||||
onMouseUp={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
release()
|
||||
if (!didDrag) return
|
||||
didDrag = false
|
||||
queueMicrotask(() => (suppressClick = false))
|
||||
}}
|
||||
onMouseDrag={drag}
|
||||
onMouseDragEnd={release}
|
||||
>
|
||||
<scrollbox ref={(element) => (scroll = element)} flexGrow={1} scrollbarOptions={{ visible: false }}>
|
||||
<box flexShrink={0} flexDirection="column" gap={1}>
|
||||
@@ -522,12 +558,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
: color
|
||||
return separator ? tint(faded, pulseBackground(), 0.55) : faded
|
||||
}
|
||||
const release = () => {
|
||||
setDragging(undefined)
|
||||
const pending = preview()
|
||||
if (pending?.sessionID === tab.sessionID) tabs.move(pending.sessionID, pending.index)
|
||||
tabs.select(tab.sessionID)
|
||||
}
|
||||
return (
|
||||
<box
|
||||
height={2}
|
||||
@@ -539,6 +569,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
onMouseOut={() => marquee.leave(tab.sessionID)}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) {
|
||||
didDrag = false
|
||||
setDragging(undefined)
|
||||
if (!rail) return
|
||||
setContextMenu({
|
||||
@@ -551,26 +582,10 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
didDrag = false
|
||||
marquee.enter(tab.sessionID, title(), hoveredTitleWidth())
|
||||
setDragging(tab.sessionID)
|
||||
}}
|
||||
onMouseUp={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
release()
|
||||
}}
|
||||
onMouseDrag={(event) => {
|
||||
if (!rail) return
|
||||
const target = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
tabs.tabs().length - 1,
|
||||
Math.floor((event.y - rail.screenY - 1 + (scroll?.scrollTop ?? 0)) / 3),
|
||||
),
|
||||
)
|
||||
if (target !== index() && preview()?.index !== target)
|
||||
setPreview({ sessionID: tab.sessionID, index: target })
|
||||
}}
|
||||
onMouseDragEnd={release}
|
||||
>
|
||||
<TabPulse
|
||||
top={-1}
|
||||
@@ -677,8 +692,14 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
selectable={false}
|
||||
onMouseOver={() => setCloseHovered(true)}
|
||||
onMouseOut={() => setCloseHovered(false)}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON || hovered() !== tab.sessionID) return
|
||||
didDrag = false
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onMouseUp={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
if (suppressClick) return
|
||||
if (hovered() !== tab.sessionID) return
|
||||
event.stopPropagation()
|
||||
tabs.close(tab.sessionID)
|
||||
@@ -737,6 +758,8 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
onMouseOver={() => setAddHovered(true)}
|
||||
onMouseOut={() => setAddHovered(false)}
|
||||
onMouseDown={(event: MouseEvent) => {
|
||||
didDrag = false
|
||||
setDragging(undefined)
|
||||
if (event.button !== RIGHT_MOUSE_BUTTON) return
|
||||
if (!rail) return
|
||||
setContextMenu({ x: event.x, y: event.y })
|
||||
@@ -745,6 +768,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
}}
|
||||
onMouseUp={(event: MouseEvent) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
if (suppressClick) return
|
||||
if (!newTab()) tabs.add?.()
|
||||
}}
|
||||
>
|
||||
@@ -774,6 +798,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
selectable={false}
|
||||
onMouseUp={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
if (suppressClick) return
|
||||
if (!addHovered()) return
|
||||
event.stopPropagation()
|
||||
tabs.close()
|
||||
@@ -803,6 +828,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
const [addHovered, setAddHovered] = createSignal(false)
|
||||
const marquee = createTabMarquee(animations)
|
||||
const hovered = marquee.hovered
|
||||
// OpenTUI captures the first drag target, which may differ from the tab pressed on a fast move.
|
||||
const [dragging, setDragging] = createSignal<string>()
|
||||
// A drag reorders a local preview and persists one move on release instead of writing
|
||||
// per slot crossing; the preview holds after release until the store reflects the move,
|
||||
@@ -810,6 +836,9 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
|
||||
const [contextMenu, setContextMenu] = createSignal<TabContextMenuState>()
|
||||
let strip: { screenX: number; screenY: number } | undefined
|
||||
let didDrag = false
|
||||
// A captured drag ends with a synthetic up on its drop target; do not turn that into a click.
|
||||
let suppressClick = false
|
||||
const hueStep = () => (mode() === "light" ? 800 : 200)
|
||||
const accent = () => theme.hue.accent[hueStep()]
|
||||
const activeNumber = () => theme.hue.interactive[hueStep()]
|
||||
@@ -931,6 +960,29 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
return layout().before + layout().widths.length - 1
|
||||
}
|
||||
|
||||
const release = () => {
|
||||
const source = dragging()
|
||||
if (!source) return
|
||||
if (didDrag) suppressClick = true
|
||||
setDragging(undefined)
|
||||
const pending = preview()
|
||||
if (pending?.sessionID === source) tabs.move(pending.sessionID, pending.index)
|
||||
if (source === NEW_SESSION_TAB.sessionID) return
|
||||
tabs.select(source)
|
||||
}
|
||||
|
||||
const drag = (event: MouseEvent) => {
|
||||
const source = dragging()
|
||||
if (!source || source === NEW_SESSION_TAB.sessionID) return
|
||||
didDrag = true
|
||||
const slot = slotAt(event.x)
|
||||
const target = slot === undefined ? undefined : Math.min(slot, tabs.tabs().length - 1)
|
||||
const sourceIndex = items().findIndex((item) => item.sessionID === source)
|
||||
if (target !== undefined && target !== sourceIndex && preview()?.index !== target) {
|
||||
setPreview({ sessionID: source, index: target })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<box
|
||||
ref={(element) => (strip = element)}
|
||||
@@ -940,6 +992,15 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
flexDirection="row"
|
||||
zIndex={1}
|
||||
onMouseOut={marquee.leaveHovered}
|
||||
onMouseUp={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
release()
|
||||
if (!didDrag) return
|
||||
didDrag = false
|
||||
queueMicrotask(() => (suppressClick = false))
|
||||
}}
|
||||
onMouseDrag={drag}
|
||||
onMouseDragEnd={release}
|
||||
renderAfter={function (buffer) {
|
||||
const x = Math.max(0, this.screenX)
|
||||
const y = this.screenY + this.height
|
||||
@@ -1051,15 +1112,6 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
}
|
||||
const bold = () => (selected() || dragged() ? TextAttributes.BOLD : undefined)
|
||||
const closeColor = () => tint(theme.text.subdued, theme.text.default, 0.6)
|
||||
// Releasing a drag (or a plain click) selects the tab, matching browser tab strips and
|
||||
// keeping sloppy clicks indistinguishable from clean ones.
|
||||
const release = () => {
|
||||
setDragging(undefined)
|
||||
const pending = preview()
|
||||
if (pending?.sessionID === tab.sessionID) tabs.move(pending.sessionID, pending.index)
|
||||
if (tab === NEW_SESSION_TAB) return
|
||||
tabs.select(tab.sessionID)
|
||||
}
|
||||
return (
|
||||
<box
|
||||
width={width()}
|
||||
@@ -1070,6 +1122,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
onMouseOut={() => marquee.leave(tab.sessionID)}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) {
|
||||
didDrag = false
|
||||
setDragging(undefined)
|
||||
setContextMenu({
|
||||
x: event.x,
|
||||
@@ -1081,20 +1134,10 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
didDrag = false
|
||||
marquee.enter(tab.sessionID, title(), hoveredTitleWidth())
|
||||
setDragging(tab.sessionID)
|
||||
}}
|
||||
onMouseUp={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
release()
|
||||
}}
|
||||
onMouseDrag={(event) => {
|
||||
if (tab === NEW_SESSION_TAB) return
|
||||
const slot = slotAt(event.x)
|
||||
if (slot !== undefined && slot !== tabNumber() - 1)
|
||||
setPreview({ sessionID: tab.sessionID, index: slot })
|
||||
}}
|
||||
onMouseDragEnd={release}
|
||||
>
|
||||
<TabPulse
|
||||
enabled={animations()}
|
||||
@@ -1140,8 +1183,14 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
selectable={false}
|
||||
onMouseOver={() => setCloseHovered(true)}
|
||||
onMouseOut={() => setCloseHovered(false)}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON || hovered() !== tab.sessionID) return
|
||||
didDrag = false
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onMouseUp={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
if (suppressClick) return
|
||||
// The close mark only renders while hovered; without motion events a click can
|
||||
// land here first, and must select the tab instead of closing it invisibly.
|
||||
if (hovered() !== tab.sessionID) return
|
||||
@@ -1170,6 +1219,8 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
onMouseOver={() => setAddHovered(true)}
|
||||
onMouseOut={() => setAddHovered(false)}
|
||||
onMouseDown={(event) => {
|
||||
didDrag = false
|
||||
setDragging(undefined)
|
||||
if (event.button !== RIGHT_MOUSE_BUTTON) return
|
||||
setContextMenu({ x: event.x, y: event.y })
|
||||
event.preventDefault()
|
||||
@@ -1177,6 +1228,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
}}
|
||||
onMouseUp={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
if (suppressClick) return
|
||||
tabs.add?.()
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { FilePath } from "../../ui/file-path"
|
||||
import { useWorkingDirectoryActions } from "../../ui/working-directory-actions"
|
||||
import { usePromptMove } from "../../component/prompt/move"
|
||||
|
||||
function View(props: { context: Plugin.Context }) {
|
||||
function View(props: { context: Plugin.Context; sessionID: string }) {
|
||||
const move = usePromptMove({
|
||||
projectID: () => props.context.data.session.get(props.sessionID)?.projectID,
|
||||
sessionID: () => props.sessionID,
|
||||
})
|
||||
const actions = useWorkingDirectoryActions({
|
||||
directory: () => props.context.location?.directory,
|
||||
onMove: () => void move.open(),
|
||||
})
|
||||
const directory = createMemo(() => {
|
||||
if (!props.context.location) return undefined
|
||||
const value = props.context.ui.format.path(props.context.location.directory)
|
||||
@@ -11,7 +21,20 @@ function View(props: { context: Plugin.Context }) {
|
||||
})
|
||||
return (
|
||||
<Show when={directory()}>
|
||||
{(value) => <FilePath value={value()} maxWidth={38} fg={props.context.theme.text.subdued} />}
|
||||
{(value) => (
|
||||
<box
|
||||
id="sidebar.footer.location"
|
||||
onMouseOver={actions.onMouseOver}
|
||||
onMouseOut={actions.onMouseOut}
|
||||
onMouseUp={actions.onMouseUp}
|
||||
>
|
||||
<FilePath
|
||||
value={value()}
|
||||
maxWidth={38}
|
||||
fg={actions.hovered() ? props.context.theme.text.default : props.context.theme.text.subdued}
|
||||
/>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -21,6 +44,9 @@ export default Plugin.define({
|
||||
setup(context) {
|
||||
// Append keeps the path open to additive plugin claims; an external
|
||||
// replace still takes the boundary over.
|
||||
context.ui.slot({ append: "sidebar.footer", render: () => <View context={context} /> })
|
||||
context.ui.slot({
|
||||
append: "sidebar.footer",
|
||||
render: (props) => <View context={context} sessionID={props.sessionID} />,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -904,7 +904,13 @@ export function FormPrompt(props: {
|
||||
<text
|
||||
width={4}
|
||||
flexShrink={0}
|
||||
fg={picked() ? theme.text.feedback.success.default : theme.text.subdued}
|
||||
fg={
|
||||
active()
|
||||
? theme.text.formfield.focused
|
||||
: picked()
|
||||
? theme.text.formfield.selected
|
||||
: theme.text.subdued
|
||||
}
|
||||
>
|
||||
[{picked() ? "✓" : " "}]
|
||||
</text>
|
||||
@@ -914,7 +920,7 @@ export function FormPrompt(props: {
|
||||
</text>
|
||||
</box>
|
||||
<Show when={!multi()}>
|
||||
<text fg={theme.text.feedback.success.default}>{picked() ? " ✓" : ""}</text>
|
||||
<text fg={theme.text.formfield.selected}>{picked() ? " ✓" : ""}</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={row.description}>
|
||||
@@ -953,7 +959,13 @@ export function FormPrompt(props: {
|
||||
<text
|
||||
width={4}
|
||||
flexShrink={0}
|
||||
fg={customChecked() ? theme.text.feedback.success.default : theme.text.subdued}
|
||||
fg={
|
||||
other()
|
||||
? theme.text.formfield.focused
|
||||
: customChecked()
|
||||
? theme.text.formfield.selected
|
||||
: theme.text.subdued
|
||||
}
|
||||
>
|
||||
[{customChecked() ? "✓" : " "}]
|
||||
</text>
|
||||
@@ -966,7 +978,7 @@ export function FormPrompt(props: {
|
||||
{input() || "Type your own answer"}
|
||||
</text>
|
||||
<Show when={!multi() && customPicked()}>
|
||||
<text fg={theme.text.feedback.success.default}>✓</text>
|
||||
<text fg={theme.text.formfield.selected}>✓</text>
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
</scrollbox>
|
||||
|
||||
<box flexShrink={0} gap={1} paddingTop={1}>
|
||||
<Slot path="sidebar.footer" />
|
||||
<Slot path="sidebar.footer" input={{ sessionID: props.sessionID }} />
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import open from "open"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
import { useDialog } from "./dialog"
|
||||
import { DialogSelect } from "./dialog-select"
|
||||
import { useToast } from "./toast"
|
||||
|
||||
export function useWorkingDirectoryActions(input: { directory: () => string | undefined; onMove?: () => void }) {
|
||||
const clipboard = useClipboard()
|
||||
const dialog = useDialog()
|
||||
const renderer = useRenderer()
|
||||
const toast = useToast()
|
||||
const [hovered, setHovered] = createSignal(false)
|
||||
|
||||
function openMenu() {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
const directory = input.directory()
|
||||
if (!directory) return
|
||||
dialog.replace(() => (
|
||||
<DialogSelect
|
||||
title="Working directory"
|
||||
renderFilter={false}
|
||||
options={[
|
||||
{
|
||||
title: "Copy path",
|
||||
value: "location.copy",
|
||||
description: directory,
|
||||
onSelect: (dialog) => {
|
||||
void clipboard.write(directory).then(() => {
|
||||
dialog.clear()
|
||||
toast.show({ message: "Path copied to clipboard", variant: "info" })
|
||||
}, toast.error)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Open folder",
|
||||
value: "location.open",
|
||||
description: "in system file manager",
|
||||
onSelect: (dialog) => {
|
||||
dialog.clear()
|
||||
void open(directory).catch(toast.error)
|
||||
},
|
||||
},
|
||||
...(input.onMove
|
||||
? [
|
||||
{
|
||||
title: "Move session",
|
||||
value: "session.move",
|
||||
description: "to another working directory",
|
||||
onSelect: () => void input.onMove?.(),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
return {
|
||||
hovered,
|
||||
onMouseOver: () => setHovered(true),
|
||||
onMouseOut: () => setHovered(false),
|
||||
onMouseUp: openMenu,
|
||||
}
|
||||
}
|
||||
@@ -162,38 +162,6 @@ provider and model configuration. An unknown variant fails model resolution inst
|
||||
|
||||
### Local models
|
||||
|
||||
OpenCode automatically discovers language models from an unauthenticated LM Studio server listening on its default
|
||||
address, `http://127.0.0.1:1234`. Discovered models use the `lmstudio` provider ID and LM Studio's model key:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"model": "lmstudio/google/gemma-4-26b-a4b",
|
||||
}
|
||||
```
|
||||
|
||||
OpenCode refreshes the inventory in the background and reads context, vision, and tool-use capabilities from LM
|
||||
Studio. Embedding models are excluded because they cannot drive a session. Disable discovery with
|
||||
`"plugins": ["-opencode.provider.lmstudio"]`.
|
||||
|
||||
For a different host or port, configure the OpenAI-compatible base URL. Models are still discovered automatically:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"providers": {
|
||||
"lmstudio": {
|
||||
"settings": {
|
||||
"baseURL": "http://127.0.0.1:5678/v1",
|
||||
"apiKey": "{env:LMSTUDIO_API_KEY}",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Omit `apiKey` when LM Studio authentication is disabled.
|
||||
|
||||
For an OpenAI-compatible server, define a provider package, endpoint, and at least one model:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
@@ -203,7 +171,7 @@ For an OpenAI-compatible server, define a provider package, endpoint, and at lea
|
||||
"providers": {
|
||||
"local": {
|
||||
"name": "Local server",
|
||||
"package": "@opencode-ai/ai/providers/openai-compatible",
|
||||
"package": "aisdk:@ai-sdk/openai-compatible",
|
||||
"settings": {
|
||||
"baseURL": "http://127.0.0.1:1234/v1",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user