Compare commits

..

8 Commits

Author SHA1 Message Date
Kit Langton 00bb8291ea fix(tui): refine open worktree flow 2026-08-14 15:58:32 -04:00
Kit Langton 6633529389 feat(tui): make open location-first 2026-08-14 15:44:56 -04:00
opencode-agent[bot] b6ea0c2209 chore: generate 2026-08-14 19:44:43 +00:00
Kit Langton f7b222e95a fix(tui): open footer status dialogs on click 2026-08-14 15:43:07 -04:00
Kit Langton a7288d231e feat(tui): add working directory actions (#42624) 2026-08-14 15:29:22 -04:00
Kit Langton f75244d795 fix(tui): preserve tab drag source (#42619) 2026-08-14 17:59:18 +00:00
Kit Langton 1d44d56d9c fix(tui): use semantic form tokens (#42599) 2026-08-14 17:49:06 +00:00
Kit Langton 9adc9bb5df fix(tui): preserve full-width tab hover 2026-08-14 13:41:39 -04:00
18 changed files with 575 additions and 653 deletions
+7
View File
@@ -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/`.
-2
View File
@@ -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 } } } }),
})
}
+1 -1
View File
@@ -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
+2 -4
View File
@@ -722,7 +722,7 @@ function App(props: { pair?: DialogPairCredentials }) {
},
{
name: "open.menu",
title: "Open session or project",
title: "Open session or worktree",
category: "Session",
slash: { name: "open", aliases: ["projects", "project"] },
run: async () => {
@@ -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>
@@ -19,6 +19,7 @@ import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes"
import type { WorktreeListOutput } from "@opencode-ai/client"
import { useRoute } from "../context/route"
import { DialogWorktreeName } from "./dialog-worktree-name"
import { Slug } from "@opencode-ai/core/util/slug"
export type MoveSessionSelection =
| { type: "directory"; directory: string; subdirectory: boolean }
@@ -27,6 +28,9 @@ type ProjectDirectory = WorktreeListOutput[number]
type DialogMoveSessionProps = {
projectID: string
title?: string
compact?: boolean
randomWorktree?: boolean
current?: MoveSessionSelection
onSelect: (selection: MoveSessionSelection) => void
onCurrentChange?: (selection: MoveSessionSelection) => void
@@ -51,7 +55,8 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
const [removing, setRemoving] = createSignal(props.initialRemoving)
const [replacementCurrent, setReplacementCurrent] = createSignal<string>()
const [loadError, setLoadError] = createSignal<unknown>()
onMount(() => dialog.setSize("xlarge"))
const randomWorktree = Slug.create()
onMount(() => dialog.setSize(props.compact ? "large" : "xlarge"))
function reopen(initialRemoving?: string) {
dialog.replace(() => (
@@ -122,8 +127,6 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
if (!a.strategy && !b.strategy) return a.directory.length - b.directory.length
return 0
})
if (roots.length === 0) return []
const subdirectories = sessionData.session
.list()
.filter(
@@ -150,7 +153,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
})
const titleWidth = Math.max(1, dialogSelectContentWidth(Math.min(dialogWidth("xlarge"), dimensions().width - 2)))
return list.map((item) => {
const options: DialogSelectOption<MoveSessionSelection | undefined>[] = list.map((item) => {
const title = abbreviateHome(item.location, paths.home)
const suffix =
item.location === item.root.directory ? undefined : path.sep + path.relative(item.root.directory, item.location)
@@ -183,6 +186,19 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
truncateTitle: "left" as const,
}
})
if (props.randomWorktree) {
return [
{
title: "+ New random worktree",
footer: randomWorktree,
value: { type: "new", name: randomWorktree },
category: "Create",
titleWidth,
},
...options,
]
}
return options
})
const current = createMemo(() => {
@@ -300,11 +316,11 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
return (
<box minHeight={showError() ? 5 : fullHeight()}>
<DialogSelect
title="Move session"
title={props.title ?? "Move session"}
titleView={
<box flexDirection="row" gap={1}>
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
Move session
{props.title ?? "Move session"}
</text>
<Show when={working() || directories.loading || loadedProject.loading}>
<Spinner />
+166 -33
View File
@@ -1,5 +1,6 @@
import { createMemo, createResource, createSignal } from "solid-js"
import type { SessionInfo } from "@opencode-ai/client"
import path from "path"
import { useTerminalDimensions } from "@opentui/solid"
import type { RGBA } from "@opentui/core"
import { dialogWidth, useDialog } from "../ui/dialog"
@@ -19,11 +20,17 @@ import { stringWidth } from "../util/string-width"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { Spinner } from "./spinner"
import { projectName } from "../util/project"
import { DialogMoveSession } from "./dialog-move-session"
import { DialogPrompt } from "../ui/dialog-prompt"
import { useToast } from "../ui/toast"
import { errorMessage } from "../util/error"
const RECENT_LIMIT = 8
const RECENT_LIMIT = 3
export const DialogOpenKey = Symbol("DialogOpen")
type OpenTarget = { type: "session"; sessionID: string } | { type: "project"; directory: string }
type OpenTarget =
| { type: "session"; sessionID: string }
| { type: "location"; directory: string; projectID?: string; vcs?: "git" | "hg" }
export async function loadDialogOpen(data: ReturnType<typeof useData>, client: ReturnType<typeof useClient>) {
const [, sessions] = await Promise.all([
@@ -49,8 +56,10 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
const paths = useTuiPaths()
const dimensions = useTerminalDimensions()
const shortcuts = Keymap.useShortcuts()
const toast = useToast()
const [filter, setFilter] = createSignal("")
const [selectionMoved, setSelectionMoved] = createSignal(false)
const [selected, setSelected] = createSignal<OpenTarget>()
const [matched] = createResource(
() => {
@@ -84,14 +93,12 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
const options = createMemo(() => {
const tabs = openTabs()
// With an empty query the menu shows what is not already one keystroke away: open tabs are
// visible in the strip, so recents exclude them. Typing widens the pool to every session so
// matching a loaded tab by name still switches to it.
const recent = filter().trim()
? sessions()
: sessions()
.filter((session) => !tabs.has(session.id))
.slice(0, RECENT_LIMIT)
const exact = matched()
const recent = sessions()
.filter((session) => !tabs.has(session.id))
.slice(0, RECENT_LIMIT)
.concat(exact && !tabs.has(exact.id) ? [exact] : [])
.filter((session, index, items) => items.findIndex((item) => item.id === session.id) === index)
const sessionOptions = recent.map((session) => {
const project = data.project.get(session.projectID)
const name = projectName(project)
@@ -102,7 +109,7 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
title: withTimestampedFallback(session),
searchText: session.id,
value: { type: "session", sessionID: session.id } as OpenTarget,
category: "Sessions",
category: "Recent sessions",
footer: `${name ? `${Locale.truncate(name, 20)} · ` : ""}${timeAgo(session.time.updated)}`,
onSelect: () => location.set(session.location),
gutter: running
@@ -113,47 +120,171 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
}
})
const current = location.current?.project
const seen = new Set<string>()
const projectOptions = data.project
.list()
.filter((project) => {
if (project.canonical === "/" || seen.has(project.canonical)) return false
seen.add(project.canonical)
return true
const current = location.current
const locations = new Map<
string,
{
directory: string
title: string
updated: number
category: "Recent worktrees" | "Recent folders"
projectID?: string
vcs?: "git" | "hg"
}
>()
for (const project of data.project.list()) {
if (project.canonical === "/" || isDisposableLocation(project.canonical) || locations.has(project.canonical))
continue
locations.set(project.canonical, {
directory: project.canonical,
title: projectName(project) ?? project.canonical,
updated: project.time.updated,
category: "Recent worktrees",
projectID: project.id,
vcs: project.vcs,
})
.map((project) => {
const title = projectName(project) ?? project.canonical
const footer = abbreviateHome(project.canonical, paths.home)
}
for (const session of sessions()) {
const project = data.project.get(session.projectID)
const managedProject = project && project.canonical !== "/" ? project : undefined
const worktree = managedProject && !session.subpath
const directory = worktree ? session.location.directory : (managedProject?.canonical ?? session.location.directory)
if (isDisposableLocation(directory)) continue
const existing = locations.get(directory)
if (existing) {
existing.updated = Math.max(existing.updated, session.time.updated)
continue
}
locations.set(directory, {
directory,
title:
worktree && directory !== managedProject.canonical
? [projectName(managedProject), path.basename(directory)].filter(Boolean).join(" · ")
: (projectName(project) ?? (path.basename(directory) || directory)),
updated: session.time.updated,
category: managedProject ? "Recent worktrees" : "Recent folders",
projectID: managedProject?.id,
vcs: managedProject?.vcs,
})
}
const locationOptions = [...locations.values()]
.toSorted((a, b) => b.updated - a.updated)
.map((item) => {
const footer = abbreviateHome(item.directory, paths.home)
const width =
dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) - stringWidth(title)
dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) - stringWidth(item.title)
return {
title,
title: item.title,
footer: truncateFilePath(footer, width),
searchText: footer,
value: { type: "project", directory: project.canonical } as OpenTarget,
category: "Projects",
value: {
type: "location",
directory: item.directory,
projectID: item.projectID,
vcs: item.vcs,
} as OpenTarget,
category: item.category,
gutter:
project.canonical === current?.canonical
item.directory === current?.directory || item.directory === current?.project.canonical
? () => <text fg={theme.text.formfield.selected}></text>
: undefined,
}
})
return [...sessionOptions, ...projectOptions]
return [
...sessionOptions,
...locationOptions.filter((item) => item.category === "Recent worktrees"),
...locationOptions.filter((item) => item.category === "Recent folders"),
]
})
function openLocation(directory: string) {
dialog.clear()
const target = { directory }
route.navigate({ type: "home", location: target })
location.set(target)
}
function openWorktrees(target: OpenTarget | undefined) {
if (target?.type !== "location" || !target.projectID || target.vcs !== "git") return
const projectID = target.projectID
dialog.replace(() => (
<DialogMoveSession
projectID={projectID}
title="Open worktree"
compact={true}
randomWorktree={true}
onSelect={(selection) => {
if (selection.type === "directory") {
openLocation(selection.directory)
return
}
void client.api.worktree
.create({
projectID,
strategy: "git",
directory: path.join(paths.worktree, projectID.slice(0, 6)),
name: selection.name,
})
.then((result) => openLocation(result.directory))
.catch((error) =>
toast.show({ variant: "error", title: "Creating worktree failed", message: errorMessage(error) }),
)
}}
/>
))
}
function browse() {
dialog.replace(() => (
<DialogPrompt
title="Open folder"
placeholder="Absolute path"
value={location.current?.directory ?? paths.home}
onConfirm={(value) => {
const directory = value.trim().replace(/^~(?=$|[\\/])/, paths.home)
if (!directory) return
void client.api.file
.list({ location: { directory } })
.then(() => openLocation(directory))
.catch((error) =>
toast.show({ variant: "error", title: "Could not open folder", message: errorMessage(error) }),
)
}}
/>
))
}
return (
<DialogSelect
title="Open"
placeholder="Search sessions and projects…"
placeholder="Search sessions and worktrees…"
options={options()}
current={currentSessionID() ? ({ type: "session", sessionID: currentSessionID()! } as OpenTarget) : undefined}
focusCurrent={false}
sectionNavigation={true}
preserveSelection={selectionMoved()}
onMove={() => setSelectionMoved(true)}
onMove={(option) => {
setSelectionMoved(true)
setSelected(option.value)
}}
onFilter={setFilter}
footer={
<text fg={theme.text.default}>
enter <span style={{ fg: theme.text.subdued }}>open</span>
{" "} <span style={{ fg: theme.text.subdued }}>worktrees</span>
{" "}/ <span style={{ fg: theme.text.subdued }}>browse</span>
</text>
}
bindings={[
{
bind: "right",
title: "Open worktrees",
group: "Dialog",
run: () => openWorktrees(selected() ?? options()[0]?.value),
},
{ bind: "/", title: "Browse folders", group: "Dialog", run: browse },
]}
noMatchView={
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>
@@ -169,9 +300,7 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
route.navigate({ type: "session", sessionID: option.value.sessionID })
return
}
const target = { directory: option.value.directory }
route.navigate({ type: "home", location: target })
location.set(target)
openLocation(option.value.directory)
}}
/>
)
@@ -189,3 +318,7 @@ function timeAgo(timestamp: number) {
if (months < 12) return `${months}mo`
return `${Math.floor(days / 365)}y`
}
function isDisposableLocation(directory: string) {
return /^opencode-(?:test|e2e-project)-/.test(path.basename(directory))
}
+22 -7
View File
@@ -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>
)}
+95 -43
View File
@@ -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 -1
View File
@@ -96,7 +96,7 @@ export const Definitions = {
"session.move": keybind("none", "Move session"),
"session.new": keybind("<leader>n", "Create a new session"),
"session.list": keybind("<leader>l", "List all sessions"),
"open.menu": keybind("ctrl+o", "Open recent sessions and projects"),
"open.menu": keybind("ctrl+o", "Open recent sessions and worktrees"),
"session.tab.next": keybind("ctrl+tab,alt+down", "Switch to next open session tab"),
"session.tab.previous": keybind("ctrl+shift+tab,alt+up", "Switch to previous open session tab"),
"session.tab.history.back": keybind("none", "Go back in session tab history"),
@@ -20,7 +20,7 @@ function Mcp(props: { context: Plugin.Context }) {
return (
<Show when={list().length}>
<box gap={1} flexDirection="row" flexShrink={0}>
<box gap={1} flexDirection="row" flexShrink={0} onMouseUp={() => props.context.keymap.dispatch("mcp.list")}>
<text fg={props.context.theme.text.default}>
<Switch>
<Match when={failed()}>
@@ -56,7 +56,7 @@ function Plugins(props: { context: Plugin.Context }) {
return (
<Show when={failed()}>
<box gap={1} flexDirection="row" flexShrink={0}>
<box gap={1} flexDirection="row" flexShrink={0} onMouseUp={() => props.context.keymap.dispatch("plugins.list")}>
<text fg={props.context.theme.text.default}>
<span style={{ fg: props.context.theme.text.feedback.error.default }}> </span>
{failed()} plugin{failed() === 1 ? "" : "s"} failed
@@ -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} />,
})
},
})
+16 -4
View File
@@ -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>
</>
}
+1 -1
View File
@@ -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,
}
}
+144 -2
View File
@@ -75,7 +75,7 @@ test("finds and opens an exact session ID outside the recent list", async () =>
})
try {
await fixture.app.waitForFrame((frame) => frame.includes("Search sessions and projects"))
await fixture.app.waitForFrame((frame) => frame.includes("Search sessions and worktrees"))
await fixture.app.mockInput.typeText(sessionID)
await fixture.app.waitForFrame((frame) => frame.includes("TUI plugin slot API v2"))
@@ -158,7 +158,7 @@ test("waits for sessions before showing the populated picker", async () => {
try {
await fixture.app.renderOnce()
expect(fixture.app.captureCharFrame()).not.toContain("Search sessions and projects")
expect(fixture.app.captureCharFrame()).not.toContain("Search sessions and worktrees")
resolveSessions(
json({
@@ -276,6 +276,148 @@ test("option arrows stay in the only visible section", async () => {
}
})
test("search keeps sessions limited to recents", async () => {
const fixture = await renderOpen((url) => {
if (url.pathname === "/api/project") return json([])
if (url.pathname !== "/api/session") return undefined
return json({
data: Array.from({ length: 9 }, (_, index) => ({
id: `ses_${index}`,
projectID: `proj_${index}`,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 10 - index, updated: 10 - index },
title: index === 8 ? "Ancient hidden session" : `Recent session ${index}`,
location: { directory: `/tmp/location-${index}` },
})),
cursor: {},
})
})
try {
await fixture.app.waitForFrame((frame) => frame.includes("Recent sessions"))
await fixture.app.mockInput.typeText("Ancient hidden session")
await fixture.app.waitForFrame(
(frame) => frame.includes("No matches") && frame.split("Ancient hidden session").length === 2,
)
} finally {
await fixture.dispose()
}
})
test("opens worktrees with right and creates a random worktree", async () => {
const fixture = await renderOpen((url) => {
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
if (url.pathname !== "/api/project") return undefined
return json([
{
id: "proj_test",
canonical: "/tmp/opencode",
vcs: "git",
name: "OpenCode",
time: { created: 1, updated: 2 },
sandboxes: [],
},
])
})
try {
await fixture.app.waitForFrame((frame) => frame.includes("OpenCode") && frame.includes("Recent worktrees"))
fixture.app.mockInput.pressArrow("right")
await fixture.app.waitForFrame((frame) => frame.includes("Open worktree") && frame.includes("New random worktree"))
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "home")
expect(fixture.route.data).toEqual({ type: "home", location: { directory: "/tmp/opencode/created" } })
} finally {
await fixture.dispose()
}
})
test("opens the folder prompt with slash", async () => {
const fixture = await renderOpen((url) => {
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
if (url.pathname === "/api/project") return json([])
return undefined
})
try {
await fixture.app.waitForFrame((frame) => frame.includes("Search sessions and worktrees"))
await fixture.app.mockInput.typeText("/")
await fixture.app.waitForFrame((frame) => frame.includes("Open folder") && frame.includes("/tmp/opencode/home"))
} finally {
await fixture.dispose()
}
})
test("hides disposable test projects from recent locations", async () => {
const fixture = await renderOpen((url) => {
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
if (url.pathname !== "/api/project") return undefined
return json([
{
id: "proj_real",
canonical: "/workspace/opencode",
name: "OpenCode",
time: { created: 1, updated: 2 },
sandboxes: [],
},
{
id: "proj_test",
canonical: "/tmp/opencode-e2e-project-BX8Aug",
time: { created: 1, updated: 3 },
sandboxes: [],
},
])
})
try {
const frame = await fixture.app.waitForFrame((value) => value.includes("OpenCode"))
expect(frame).not.toContain("opencode-e2e-project-BX8Aug")
} finally {
await fixture.dispose()
}
})
test("shows a session checkout as a recent worktree", async () => {
const fixture = await renderOpen((url) => {
if (url.pathname === "/api/session")
return json({
data: [
{
id: "ses_worktree",
projectID: "proj_opencode",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 2, updated: 3 },
title: "Refine Open screen",
location: { directory: "/workspace/worktrees/open-screen" },
},
],
cursor: {},
})
if (url.pathname !== "/api/project") return undefined
return json([
{
id: "proj_opencode",
canonical: "/workspace/opencode",
vcs: "git",
name: "OpenCode",
time: { created: 1, updated: 2 },
sandboxes: [],
},
])
})
try {
const frame = await fixture.app.waitForFrame(
(value) => value.includes("Recent worktrees") && value.includes("OpenCode · open-screen"),
)
expect(frame).toContain("/workspace/worktrees/open-screen")
} finally {
await fixture.dispose()
}
})
async function renderOpen(
handler: FetchHandler,
beforeOpen?: (contexts: {
@@ -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",
},