Compare commits

..

14 Commits

Author SHA1 Message Date
Kit Langton c478ad52a9 fix(session): show interrupt progress immediately 2026-08-12 14:48:54 -04:00
Kit Langton 723f5e0028 fix(tui): consolidate developer tools controls (#42088) 2026-08-12 13:23:14 -04:00
Kit Langton 17cae23dce fix(tui): stabilize repeated open menu (#42086) 2026-08-12 13:17:13 -04:00
Kit Langton fd30b9765d refactor(core): spawn stdio MCP servers through the location environment (#42076) 2026-08-12 13:16:12 -04:00
Dax Raad a20d945245 docs: base worktrees on v2 2026-08-12 12:56:00 -04:00
Kit Langton 10ebf70a07 feat(tui): add session tab context menu (#42068) 2026-08-12 16:45:59 +00:00
opencode-agent[bot] b50924b993 test: stabilize Windows integration coverage (#42079)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-08-12 12:45:38 -04:00
Kit Langton b24b1b3f16 fix(tui): keep exact-fit tab titles stationary (#42073) 2026-08-12 12:17:45 -04:00
Kit Langton 930b0751b1 fix(core): generate session titles before model execution (#42067) 2026-08-12 12:08:24 -04:00
Matt Robinson f06a86eeac feat(client): support service version ranges (#42023)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-08-12 09:01:37 -07:00
Kit Langton 653b7d79cd fix(tui): restore navigation keybind defaults (#42066) 2026-08-12 11:57:39 -04:00
Kit Langton 70ce0d0970 feat(tui): jump between open menu sections (#42061) 2026-08-12 15:52:51 +00:00
opencode-agent[bot] 0777e84598 fix(tui): fill image message background (#42062)
Co-authored-by: Simon Klee <hello@simonklee.dk>
2026-08-12 15:51:19 +00:00
Kit Langton bef795b2fe fix(tui): smooth session tab marquees (#42055) 2026-08-12 15:49:17 +00:00
43 changed files with 1443 additions and 406 deletions
+3 -2
View File
@@ -1,8 +1,9 @@
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly.
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
- Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
- The default branch in this repo is `dev`.
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
- The default branch in this repo is `v2`.
- Base all new branches and worktrees on `v2`, or `origin/v2` when the local `v2` ref is unavailable. Do not base them on `dev`.
- Local `main` ref may not exist; use `v2` or `origin/v2` for diffs.
## Live V2 TUI Testing
@@ -402,6 +402,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
},
submit: {
stopping,
pending: submission.stopping,
working,
onSubmit: () => void submission.handleSubmit(new Event("submit")),
onStop: () => void submission.abort(),
+48 -34
View File
@@ -261,6 +261,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
() => prompt.capture(),
Math.floor(Math.random() * EXAMPLES.length),
)
createEffect(() => {
if (!working()) setStore("stopping", false)
})
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
const motion = (value: number) => ({
opacity: value,
@@ -283,9 +286,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
.join("")
return text.trim().length === 0 && imageAttachments().length === 0 && commentCount() === 0
})
const stopping = createMemo(() => working() && blank())
const stopAction = createMemo(() => working() && blank())
const tip = () => {
if (stopping()) {
if (store.stopping) return <span>{language.t("prompt.action.stop")}...</span>
if (stopAction()) {
return (
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.stop")}</span>
@@ -1198,36 +1202,46 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return permission.isAutoAccepting(id, sdk().directory)
})
const { abort, handleSubmit } =
props.submission ??
createPromptSubmit({
prompt,
info,
imageAttachments,
commentCount,
autoAccept: () => accepting(),
mode: () => store.mode,
working,
editor: () => editorRef,
queueScroll,
promptLength,
addToHistory,
resetHistoryNavigation: () => {
resetHistoryNavigation(true)
},
setMode: (mode) => setStore("mode", mode),
setPopover: (popover) => {
if (!popover) return closePopover()
setStore({ popover, slashMenu: false, slashMenuQuery: "" })
},
newSessionWorktree: () => props.newSessionWorktree,
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
shouldQueue: props.shouldQueue,
onQueue: props.onQueue,
onAbort: props.onAbort,
onSubmit: props.onSubmit,
model: props.controls.model.selection,
const {
abort: requestAbort,
handleSubmit,
stopping: requestStopping,
} = props.submission ??
createPromptSubmit({
prompt,
info,
imageAttachments,
commentCount,
autoAccept: () => accepting(),
mode: () => store.mode,
working,
editor: () => editorRef,
queueScroll,
promptLength,
addToHistory,
resetHistoryNavigation: () => {
resetHistoryNavigation(true)
},
setMode: (mode) => setStore("mode", mode),
setPopover: (popover) => {
if (!popover) return closePopover()
setStore({ popover, slashMenu: false, slashMenuQuery: "" })
},
newSessionWorktree: () => props.newSessionWorktree,
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
shouldQueue: props.shouldQueue,
onQueue: props.onQueue,
onAbort: props.onAbort,
onSubmit: props.onSubmit,
model: props.controls.model.selection,
})
const abort = () => {
if (store.stopping || requestStopping?.()) return Promise.resolve()
setStore("stopping", true)
return Promise.resolve(requestAbort()).finally(() => {
if (working()) setStore("stopping", false)
})
}
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") {
@@ -1579,12 +1593,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<IconButton
data-action="prompt-submit"
type="submit"
disabled={!working() && blank()}
disabled={store.stopping || (!working() && blank())}
tabIndex={store.mode === "normal" ? undefined : -1}
icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
icon={stopAction() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
variant="primary"
class="size-8"
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
aria-label={stopAction() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>
</div>
@@ -8,6 +8,7 @@ export type PromptInputState = ReturnType<typeof usePrompt>
export type PromptInputSubmission = {
abort: () => Promise<void> | void
handleSubmit: (event: Event) => Promise<void> | void
stopping?: () => boolean
}
export type PromptInputControls = {
@@ -46,6 +46,9 @@ let selected = "/repo/worktree-a"
let variant: string | undefined
let permissionServer = "server-a"
let createSessionGate: Promise<void> | undefined
let interruptGate: Promise<void> | undefined
let interruptCalls = 0
let interruptFailure = false
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const [promptStore, setPromptStore] = createStore<PromptStore>({
@@ -121,6 +124,11 @@ const clientFor = (directory: string) => {
shell: async (input: { sessionID: string; id?: string; command: string }) => {
sentShell.push(input)
},
interrupt: async () => {
interruptCalls++
await interruptGate
if (interruptFailure) throw new Error("interrupt failed")
},
},
},
session: {
@@ -310,11 +318,74 @@ beforeEach(() => {
variant = undefined
permissionServer = "server-a"
createSessionGate = undefined
interruptGate = undefined
interruptCalls = 0
interruptFailure = false
serverSessionSyncs = 0
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
})
describe("prompt submit worktree selection", () => {
test("reports stopping immediately and suppresses duplicate interrupts", async () => {
params = { id: "session-1" }
let release = () => {}
interruptGate = new Promise<void>((resolve) => {
release = resolve
})
let working = true
const submit = createPromptSubmit({
prompt,
info: () => ({ id: "session-1" }),
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => working,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: () => 0,
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
})
const first = submit.abort()
const second = submit.abort()
expect(submit.stopping()).toBe(true)
expect(interruptCalls).toBe(1)
release()
await Promise.all([first, second])
working = false
expect(submit.stopping()).toBe(false)
})
test("clears stopping when the interrupt request fails", async () => {
params = { id: "session-1" }
interruptFailure = true
const submit = createPromptSubmit({
prompt,
info: () => ({ id: "session-1" }),
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => true,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: () => 0,
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
})
await submit.abort()
expect(submit.stopping()).toBe(false)
})
test("reads the latest worktree accessor value per submit", async () => {
const submit = createPromptSubmit({
prompt,
@@ -4,7 +4,7 @@ import { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { Binary } from "@opencode-ai/core/util/binary"
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { batch, startTransition, type Accessor } from "solid-js"
import { batch, createSignal, startTransition, type Accessor } from "solid-js"
import { useTabs } from "@/context/tabs"
import { useServerSync, type ServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
@@ -263,6 +263,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const params = useParams()
const [search] = useSearchParams<{ draftId?: string }>()
const tabs = useTabs()
const [stopping, setStopping] = createSignal(false)
const isStopping = () => {
if (input.working()) return stopping()
setStopping(false)
return false
}
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
const errorMessage = (err: unknown) => {
@@ -276,8 +282,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
const abort = async () => {
if (isStopping()) return
const sessionID = params.id
if (!sessionID) return Promise.resolve()
setStopping(true)
serverSync().session.set("todo", sessionID, [])
@@ -289,11 +297,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
queued.abort.abort()
queued.cleanup()
pending.delete(key)
setStopping(false)
return Promise.resolve()
}
return sdk()
.api.session.interrupt({ sessionID })
.catch(() => {})
.catch(() => setStopping(false))
}
const restoreCommentItems = (
@@ -649,5 +658,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return {
abort,
handleSubmit,
stopping: isStopping,
}
}
@@ -12,6 +12,7 @@ export type PromptInputTransientState = {
draggingType: "image" | "@mention" | null
mode: "normal" | "shell"
applyingHistory: boolean
stopping: boolean
}
function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTransientState>) {
@@ -24,6 +25,7 @@ function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTr
draggingType: null,
mode: "normal",
applyingHistory: false,
stopping: false,
})
}
@@ -38,6 +40,7 @@ export function createPromptInputTransientState(identity: Accessor<unknown>, pla
draggingType: null,
mode: "normal",
applyingHistory: false,
stopping: false,
})
createComputed(on(identity, () => resetPromptInputTransientState(setStore), { defer: true }))
+4 -3
View File
@@ -10,6 +10,7 @@ import {
spawnServiceContender,
} from "../service-contender.js"
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
import { matchesVersion } from "../service-version.js"
export * from "../service.js"
/** Contents of the local service registration file. */
@@ -37,14 +38,14 @@ export const incumbent = Effect.fn("service.incumbent")(function* (
const info = yield* read(options.file)
const found = info === undefined ? undefined : yield* probe({ ...info, url: options.url })
if (found === undefined || found.legacy) return undefined
if (options.version !== undefined && found.version !== options.version) return undefined
if (!matchesVersion(found.version, options)) return undefined
return { endpoint: found.endpoint, state: found.state }
})
const discoverLocal = Effect.fnUntraced(function* (options: DiscoverOptions) {
const found = (yield* registered(options.file)).service
if (found?.state !== "ready") return undefined
if (options.version !== undefined && found.version !== options.version) return undefined
if (!matchesVersion(found.version, options)) return undefined
return found
})
@@ -93,7 +94,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
} else timeouts = undefined
if (service !== undefined) {
spawnDelay = timing.spawnDelay
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
const compatible = !service.legacy && matchesVersion(service.version, options)
if (compatible && service.state === "ready") return Option.some(service)
if (compatible && service.state === "failed")
return yield* Effect.fail(new Error("Background service failed to start"))
+3 -2
View File
@@ -9,6 +9,7 @@ import {
spawnServiceContender,
} from "../service-contender.js"
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
import { matchesVersion } from "../service-version.js"
import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js"
export * from "../service.js"
@@ -27,7 +28,7 @@ export async function discover(options: DiscoverOptions = {}) {
async function discoverLocal(options: DiscoverOptions) {
const found = (await registered(options.file)).service
if (found?.state !== "ready") return undefined
if (options.version !== undefined && found.version !== options.version) return undefined
if (!matchesVersion(found.version, options)) return undefined
return found
}
@@ -76,7 +77,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
if (registration.service !== undefined) {
spawnDelay = timing.spawnDelay
const service = registration.service
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
const compatible = !service.legacy && matchesVersion(service.version, options)
if (compatible && service.state === "ready") return service.endpoint
if (compatible && service.state === "failed") throw new Error("Background service failed to start")
if (!compatible) {
+8
View File
@@ -0,0 +1,8 @@
import type { DiscoverOptions } from "./service.js"
export function matchesVersion(version: string | undefined, options: DiscoverOptions) {
if (options.version === undefined) return true
if (version === undefined) return false
if (typeof options.version === "function") return options.version(version)
return version === options.version
}
+2 -2
View File
@@ -17,8 +17,8 @@ export type Endpoint = {
export type DiscoverOptions = {
/** Absolute registration file path. Defaults to the XDG state directory. */
readonly file?: string
/** Required service version. */
readonly version?: string
/** Required exact service version or compatibility predicate. */
readonly version?: string | ((version: string) => boolean)
}
/** Reason ensuring the service requires a new process. */
+15 -1
View File
@@ -1,6 +1,7 @@
import { Effect } from "effect"
import { OpenCode as EffectOpenCode, type AppApi as EffectApi } from "../src/effect"
import type { Session } from "@opencode-ai/schema/session"
import type { DiscoverOptions } from "../src/service"
type EffectClient = Effect.Success<ReturnType<typeof EffectOpenCode.make>>
type PromiseClient = ReturnType<typeof import("../src/promise").OpenCode.make>
@@ -8,6 +9,9 @@ type PromiseClient = ReturnType<typeof import("../src/promise").OpenCode.make>
declare const effectClient: EffectClient
declare const promiseClient: PromiseClient
const exactVersion: DiscoverOptions = { version: "2.0.0" }
const compatibleVersion: DiscoverOptions = { version: (version) => version.startsWith("2.") }
const effectApi: EffectApi<unknown> = effectClient
const effectSession: Effect.Effect<Session.Info, unknown> = effectClient.session.get({
@@ -42,4 +46,14 @@ const promiseRemove: Promise<void> = promiseClient.session.instructions.entry.re
key: "review-notes",
})
void [effectSession, effectList, effectPut, effectRemove, promiseList, promisePut, promiseRemove]
void [
effectSession,
effectList,
effectPut,
effectRemove,
promiseList,
promisePut,
promiseRemove,
exactVersion,
compatibleVersion,
]
+4 -1
View File
@@ -27,7 +27,10 @@ if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated" ||
}
let requests = 0
const version = mode === "old" || mode === "reject-stop" ? "old" : "test"
let version = "test"
if (mode === "old" || mode === "reject-stop") version = "old"
if (mode === "incompatible") version = "1.9.0"
if (mode === "compatible" || mode === "delayed-compatible") version = "2.1.0-next.1"
const id = crypto.randomUUID()
const server = Bun.serve({
port: 0,
@@ -25,6 +25,19 @@ test("discovers a registered service", async () => {
expect(await Service.discover({ file: registration, version: "other" })).toBeUndefined()
})
test("discovers a compatible registered service", async () => {
const registration = await setup("compatible")
expect(await Service.discover({ file: registration, version: "2.1.0" })).toBeUndefined()
expect(await Service.discover({ file: registration, version: "2.1.0-next.1" })).toEqual(
expect.objectContaining({ url: expect.stringMatching(/^http:\/\//) }),
)
expect(await Service.discover({ file: registration, version: (version) => version.startsWith("2.") })).toEqual(
expect.objectContaining({ url: expect.stringMatching(/^http:\/\//) }),
)
expect(await Service.discover({ file: registration, version: (version) => version.startsWith("3.") })).toBeUndefined()
})
test("ensures a missing service with native promises", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
+46
View File
@@ -47,6 +47,52 @@ test("a concurrent same-version start cannot invalidate a resolved endpoint", as
expect(await health(resolved.url)).toEqual({ healthy: true, version: "test", pid: original.pid })
})
test("reuses a compatible registered service", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const existing = spawn(registration, "compatible")
await waitForFile(registration)
const starts: EnsureReason[] = []
const endpoint = await run(
ensure({
file: registration,
version: (version) => version.startsWith("2."),
command: [],
onStart: (reason) => starts.push(reason),
}),
)
expect(endpoint.url).toBe((await Bun.file(registration).json()).url)
expect(starts).toEqual([])
expect(existing.exitCode).toBe(null)
})
test("replaces an incompatible registered service", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const existing = spawn(registration, "incompatible")
await waitForFile(registration)
const starts: EnsureReason[] = []
const endpoint = await run(
ensure({
file: registration,
version: (version) => version.startsWith("2."),
command: [process.execPath, fixture, registration, "delayed-compatible", "10"],
onStart: (reason) => starts.push(reason),
}),
)
const replacement = await Bun.file(registration).json()
expect(await existing.exited).toBe(0)
expect(replacement.version).toBe("2.1.0-next.1")
expect(endpoint.url).toBe(replacement.url)
expect(starts).toEqual(["version-mismatch"])
process.kill(replacement.pid, "SIGTERM")
await waitForExit(replacement.pid)
})
test("waits for a registered service to finish starting", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
+15 -59
View File
@@ -1,11 +1,10 @@
export * as MCPClient from "./client.js"
import path from "node:path"
import { execFile } from "node:child_process"
import { pathToFileURL } from "node:url"
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"
import { UnauthorizedError, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
import {
CallToolResultSchema,
@@ -30,13 +29,12 @@ import {
} from "@modelcontextprotocol/sdk/types.js"
import { Cause, Effect, Exit, Schema } from "effect"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import { MCPStdio } from "./stdio.js"
const DEFAULT_STARTUP_TIMEOUT = 30_000
const DEFAULT_CATALOG_TIMEOUT = 30_000
const DEFAULT_EXECUTION_TIMEOUT = 12 * 60 * 60 * 1_000 // 12 hours
type Transport = StdioClientTransport | StreamableHTTPClientTransport
// Some servers advertise tool outputSchemas the SDK's strict validator can't resolve; this drops
// only that field so a single bad schema doesn't blank out the whole tool list.
const TolerantListToolsResult = ListToolsResultSchema.extend({
@@ -176,7 +174,12 @@ export interface Connection {
readonly onResourcesChanged: (callback: () => void) => void
}
/** Connects an MCP server; closing the calling scope tears down the transport and any spawned process. */
/**
* Connects an MCP server; closing the calling scope tears down the transport and any spawned process.
*
* A stdio server is spawned through the location's `Environment`, so it runs on the same execution
* plane as the location's shell commands rather than always on the host.
*/
export const connect = Effect.fnUntraced(function* (
server: string,
config: typeof ConfigMCP.Server.Type,
@@ -190,13 +193,12 @@ export const connect = Effect.fnUntraced(function* (
const transport: Transport = yield* Effect.gen(function* () {
if (config.type === "local") {
const [command, ...args] = config.command
return new StdioClientTransport({
return yield* MCPStdio.make({
server,
command,
args,
cwd: config.cwd ? path.resolve(directory, config.cwd) : directory,
stderr: "pipe",
env: {
...(process.env as Record<string, string>),
environment: {
...(command === "opencode" ? { BUN_BE_BUN: "1" } : {}),
...config.environment,
},
@@ -233,9 +235,9 @@ export const connect = Effect.fnUntraced(function* (
catch: (error) => error,
}).pipe(Effect.exit)
if (Exit.isSuccess(exit)) {
yield* Effect.addFinalizer(() =>
cleanupStdioDescendants(transport).pipe(Effect.andThen(Effect.promise(() => client.close())), Effect.ignore),
)
// Closing the client closes the transport, which ends stdin and then kills through the spawner
// handle if the server does not exit cleanly. The process scope remains a final backstop.
yield* Effect.addFinalizer(() => Effect.promise(() => client.close()).pipe(Effect.ignore))
const catalogTimeout = config.timeout?.catalog ?? DEFAULT_CATALOG_TIMEOUT
const executionTimeout = config.timeout?.execution ?? DEFAULT_EXECUTION_TIMEOUT
return {
@@ -434,58 +436,12 @@ export const connect = Effect.fnUntraced(function* (
} satisfies Connection
}
yield* cleanupStdioDescendants(transport).pipe(Effect.andThen(Effect.promise(() => transport.close())), Effect.ignore)
yield* Effect.promise(() => transport.close()).pipe(Effect.ignore)
const error = Cause.squash(exit.cause)
if (error instanceof UnauthorizedError) return yield* new NeedsAuthError({ server })
return yield* new ConnectError({ server, message: error instanceof Error ? error.message : String(error) })
})
// SDK close stops the MCP process, but not child processes it spawned.
const cleanupStdioDescendants = (transport: Transport) =>
Effect.gen(function* () {
if (!(transport instanceof StdioClientTransport)) return
const pid = transport.pid
if (typeof pid !== "number") return
yield* Effect.forEach(
yield* descendantPids(pid),
(pid) =>
Effect.try({
try: () => process.kill(pid, "SIGTERM"),
catch: () => undefined,
}).pipe(Effect.ignore),
{ discard: true },
)
})
const descendantPids = Effect.fnUntraced(function* (root: number) {
if (process.platform === "win32") return []
const result: number[] = []
const queue = [root]
for (let index = 0; index < queue.length; index++) {
const parent = queue[index]
if (parent === undefined) return result
const children = (yield* childPids(parent)).filter((pid) => !result.includes(pid))
result.push(...children)
queue.push(...children)
}
return result
})
const childPids = (pid: number) =>
Effect.promise(
() =>
new Promise<number[]>((resolve) => {
execFile("pgrep", ["-P", String(pid)], { encoding: "utf8" }, (_error, stdout) => {
resolve(
stdout
.split("\n")
.map((line) => Number.parseInt(line, 10))
.filter((pid) => Number.isInteger(pid)),
)
})
}),
)
async function paginate<R extends { nextCursor?: string }, T>(
list: (cursor: string | undefined) => Promise<R>,
items: (result: R) => T[],
+7 -9
View File
@@ -12,6 +12,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Config } from "../config.js"
import { Credential } from "../credential.js"
import { Bus } from "../bus.js"
import { Environment } from "../environment/index.js"
import { Form } from "../form.js"
import { Integration } from "../integration.js"
import { KeyedMutex } from "../effect/keyed-mutex.js"
@@ -173,13 +174,13 @@ export const layer = (options?: Options) =>
Effect.gen(function* () {
const config = yield* Config.Service
const location = yield* Location.Service
const environment = yield* Environment.Service
const bus = yield* Bus.Service
const forms = yield* Form.Service
const integration = yield* Integration.Service
const credentials = yield* Credential.Service
const root = yield* Scope.make()
const root = yield* Effect.scope
const fork = yield* FiberSet.makeRuntime<never, void, never>()
yield* Effect.addFinalizer((exit) => Scope.close(root, exit))
const loadConfig = (entries: readonly Entry[]) => {
const documents = entries.filter((entry): entry is Document => entry.type === "document")
@@ -459,13 +460,8 @@ export const layer = (options?: Options) =>
connection.onClose(() =>
live(
Effect.gen(function* () {
entry.client = undefined
entry.tools = undefined
entry.prompts = undefined
entry.status = { status: "failed", error: "Connection closed" }
yield* bus.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
yield* bus.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)
yield* bus.publish(Command.Event.Updated, {}).pipe(Effect.ignore)
yield* stopServer(name, entry)
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
}),
),
@@ -520,6 +516,8 @@ export const layer = (options?: Options) =>
options?.clientInfo,
).pipe(
Effect.flatMap((connection) => connection.tools().pipe(Effect.map((tools) => ({ connection, tools })))),
// A stdio server is spawned on this location's execution plane, not the host's.
Effect.provideService(Environment.Service, environment),
Scope.provide(scope),
Effect.exit,
)
@@ -828,7 +826,7 @@ export function configured(options?: Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [Config.node, Location.node, Bus.node, Form.node, Integration.node, Credential.node],
deps: [Config.node, Location.node, Environment.node, Bus.node, Form.node, Integration.node, Credential.node],
})
}
+181
View File
@@ -0,0 +1,181 @@
export * as MCPStdio from "./stdio.js"
import { ReadBuffer, serializeMessage } from "@modelcontextprotocol/sdk/shared/stdio.js"
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"
import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"
import { Cause, Duration, Effect, Queue, Scope, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import type { ChildProcessHandle } from "effect/unstable/process/ChildProcessSpawner"
import { Environment } from "../environment/index.js"
/** Mirrors StdioClientTransport: wait this long for a graceful exit after stdin closes. */
const CLOSE_GRACE = Duration.seconds(2)
/** Mirrors StdioClientTransport: escalate SIGTERM to SIGKILL after this long. */
const FORCE_KILL_AFTER = Duration.seconds(2)
const OUTGOING_CAPACITY = 64
const MAX_FRAME_BYTES = 16 * 1024 * 1024
export interface Options {
/** Server name; only used to attribute logs. */
readonly server: string
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
/**
* Environment declared by the server config, and nothing else.
*
* The host environment is merged in by the spawner via `extendEnv`, which keeps the merge on the
* side that actually runs the process: the local driver extends with the host's `process.env`
* (what the MCP SDK's transport did), while a workspace driver extends with the sandbox's own
* environment. Host variables therefore never cross the seam into a remote workspace.
*/
readonly environment: Record<string, string>
}
/**
* MCP stdio transport that spawns its server through the location's `Environment` instead of the
* SDK's host-bound `StdioClientTransport`, so a workspace-backed location runs its MCP servers
* wherever the rest of its execution happens.
*
* The process is acquired in the calling scope: closing the scope kills it (the spawner kills the
* whole process group, so descendants go too) regardless of whether the transport was closed.
*/
export const make = Effect.fnUntraced(function* (options: Options) {
const environment = yield* Environment.Service
const scope = yield* Effect.scope
// Outgoing frames are queued rather than written to `handle.stdin` directly: the sink closes the
// stream it is run with, and stdin must stay open across the whole session.
const outgoing = yield* Queue.bounded<string, Cause.Done>(OUTGOING_CAPACITY)
const buffer = new ReadBuffer()
const state: { phase: "ready" | "starting" | "open" | "closed"; handle?: ChildProcessHandle } = { phase: "ready" }
let startup: Promise<void> | undefined
let closing: Promise<void> | undefined
let trailingBytes = 0
const stop = (handle: ChildProcessHandle) =>
Effect.gen(function* () {
const exit = yield* Effect.timeoutOption(handle.exitCode, CLOSE_GRACE)
if (exit._tag === "Some") return
const terminated = yield* Effect.timeoutOption(handle.kill({ killSignal: "SIGTERM" }), FORCE_KILL_AFTER)
if (terminated._tag === "None") yield* handle.kill({ killSignal: "SIGKILL" })
}).pipe(Effect.ignore)
const close = () =>
(closing ??= Effect.runPromise(
Effect.gen(function* () {
state.phase = "closed"
Queue.endUnsafe(outgoing)
if (startup) yield* Effect.promise(() => startup!.catch(() => undefined))
const handle = state.handle
if (!handle) return
state.handle = undefined
yield* stop(handle)
}).pipe(Effect.ensuring(Queue.shutdown(outgoing)), Effect.ensuring(Effect.sync(() => buffer.clear()))),
))
const transport: Transport = {
start: () => {
if (state.phase !== "ready") return Promise.reject(new Error("Stdio transport already started"))
state.phase = "starting"
startup = Effect.runPromise(
Effect.gen(function* () {
const handle = yield* environment.spawner.spawn(
ChildProcess.make(options.command, [...options.args], {
cwd: options.cwd,
env: options.environment,
extendEnv: true,
stdin: { stream: Stream.encodeText(Stream.fromQueue(outgoing)), endOnDone: true },
stdout: "pipe",
stderr: "pipe",
forceKillAfter: FORCE_KILL_AFTER,
}),
)
state.handle = handle
if (state.phase === "closed") {
state.handle = undefined
return yield* stop(handle)
}
state.phase = "open"
yield* startOutput(handle)
}).pipe(Scope.provide(scope)),
)
return startup
},
send: (message: JSONRPCMessage) =>
state.phase !== "open"
? Promise.reject(new Error("Not connected"))
: Effect.runPromise(
Queue.offer(outgoing, serializeMessage(message)).pipe(
Effect.flatMap((offered) => (offered ? Effect.void : Effect.fail(new Error("Not connected")))),
),
),
close,
}
const deliver = (chunk: Uint8Array) =>
Effect.gen(function* () {
for (const byte of chunk) {
trailingBytes = byte === 10 ? 0 : trailingBytes + 1
if (trailingBytes > MAX_FRAME_BYTES) return yield* Effect.fail(new Error("MCP stdio frame exceeded 16 MiB"))
}
buffer.append(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength))
while (true) {
// `undefined` means the frame failed to parse: the buffer has already advanced past it, so
// keep draining. `null` means the buffer holds no complete frame yet.
const message = yield* Effect.try({
try: () => buffer.readMessage(),
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
}).pipe(
Effect.catch((error) =>
Effect.sync(() => {
transport.onerror?.(error)
return undefined
}),
),
)
if (message === undefined) continue
if (message === null) return
transport.onmessage?.(message)
}
})
const startOutput = (handle: ChildProcessHandle) =>
Effect.gen(function* () {
yield* Effect.forkScoped(
Stream.runForEach(handle.stdout, deliver).pipe(
Effect.tapCause((cause) =>
Effect.sync(() => {
const error = Cause.squash(cause)
transport.onerror?.(error instanceof Error ? error : new Error(String(error)))
}),
),
Effect.ignore,
// stdout ending means the server is gone; the SDK transport reports that the same way.
Effect.ensuring(
Effect.gen(function* () {
const unexpected = state.phase !== "closed"
if (unexpected) yield* Effect.promise(close)
transport.onclose?.()
}),
),
),
)
// StdioClientTransport pipes stderr into a stream nobody reads. Drain chunks into the debug
// log so chatty servers cannot stall and newline-free output is not buffered without bound.
yield* Effect.forkScoped(
handle.stderr.pipe(
Stream.decodeText(),
Stream.runForEach((output) =>
output.trim() === ""
? Effect.void
: Effect.logDebug("mcp server stderr", { server: options.server, output }),
),
Effect.ignore,
),
)
})
return transport
})
+3 -3
View File
@@ -113,8 +113,8 @@ const layer = Layer.effect(
const compaction = yield* SessionCompaction.Service
const title = yield* SessionTitle.Service
const toolOutput = yield* ToolOutput.Service
// Title generation is a side effect of a successful step; it must not delay continuation.
// The in-flight set coalesces overlapping steps while title presence records success durably.
// Title generation starts once input is visible and must not delay model execution.
// The in-flight set coalesces overlapping prompts while title presence records success durably.
const titlesRunning = new Set<SessionSchema.ID>()
const forkTitle = yield* FiberSet.makeRuntime<never, void, never>()
/**
@@ -144,7 +144,6 @@ const layer = Layer.effect(
let step = 1
while (true) {
const result = yield* runStep(sessionID, promotable, step)
if (step === 1) yield* startTitle(sessionID)
yield* runPendingCompaction(sessionID)
if (!result.needsContinuation && !(yield* SessionPending.has(db, sessionID, "steer"))) return
promotable = "steer"
@@ -236,6 +235,7 @@ const layer = Layer.effect(
// a blocked first step leaves pending inputs untouched.
yield* InstructionState.prepare(db, bus, selected.instructions, selected.session.id)
const promoted = promotable ? yield* SessionPending.promote(db, bus, selected.session.id, promotable) : 0
if (promoted > 0) yield* startTitle(sessionID)
// Promoted input opens a fresh step allowance.
const currentStep = promoted > 0 ? 1 : step
const loaded = yield* context.load(selected)
-1
View File
@@ -286,7 +286,6 @@ export const Plugin = {
output,
content,
metadata: {
status: output.status,
truncated: output.truncated,
...("exit" in output && output.exit !== undefined ? { exit: output.exit } : {}),
...("shellID" in output && output.shellID !== undefined ? { shellID: output.shellID } : {}),
+35
View File
@@ -1,7 +1,42 @@
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Environment } from "@opencode-ai/core/environment/index"
import { Location } from "@opencode-ai/core/location"
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Effect, Layer } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
/**
* The host environment, without the workspace machinery: what a location with no `workspaceID`
* resolves to.
*/
export const hostEnvironmentLayer = Layer.effect(
Environment.Service,
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const driver = Environment.makeLocalDriver(spawner)
return Environment.Service.of({ files: Environment.makeFiles(driver), spawner: driver.spawner })
}),
).pipe(Layer.provide(LayerNode.compile(CrossSpawnSpawner.node)))
/**
* The host environment with its spawner wrapped so a test can assert on every command that crosses
* the seam. Spawning still really happens, so the process under test behaves normally.
*/
export const recordingEnvironmentLayer = (spawns: Array<ChildProcess.Command>) =>
Layer.effect(
Environment.Service,
Effect.gen(function* () {
const environment = yield* Environment.Service
return Environment.Service.of({
...environment,
spawner: ChildProcessSpawner.make((command) => {
spawns.push(command)
return environment.spawner.spawn(command)
}),
})
}),
).pipe(Layer.provide(hostEnvironmentLayer))
export type EnvironmentFilesTransform = (files: Environment.Files) => Partial<Environment.Files>
+147 -9
View File
@@ -22,19 +22,24 @@ import { Bus } from "@opencode-ai/core/bus"
import { ID, type Payload } from "@opencode-ai/schema/event"
import { Form } from "@opencode-ai/core/form"
import { Integration } from "@opencode-ai/core/integration"
import { Environment } from "@opencode-ai/core/environment/index"
import { Location } from "@opencode-ai/core/location"
import { MCP } from "@opencode-ai/core/mcp/index"
import { MCPClient } from "@opencode-ai/core/mcp/client"
import { MCPStdio } from "@opencode-ai/core/mcp/stdio"
import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { McpTool } from "@opencode-ai/core/tool/mcp"
import { Tool } from "@opencode-ai/core/tool"
import { DateTime, Deferred, Effect, Exit, Fiber, Layer, PubSub, Schedule, Schema, Stream } from "effect"
import { DateTime, Deferred, Effect, Exit, Fiber, Layer, PubSub, Schedule, Schema, Sink, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { ExitCode, makeHandle, ProcessId } from "effect/unstable/process/ChildProcessSpawner"
import { Image } from "@opencode-ai/core/image"
import { testEffect } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
import { location } from "./fixture/location"
import { hostEnvironmentLayer, recordingEnvironmentLayer } from "./fixture/environment"
import { executeTool, toolDefinitions, toolIdentity, waitForCodeModeTool, waitForTool } from "./lib/tool"
let assertion: Deferred.Deferred<Permission.AssertInput> | undefined
@@ -167,6 +172,7 @@ function resourceMcpLayer(
overrides?: {
entries?: Config.Interface["entries"]
subscribe?: Bus.Interface["subscribe"]
environment?: Layer.Layer<Environment.Service>
},
) {
const directory = AbsolutePath.make(import.meta.dir)
@@ -229,11 +235,15 @@ function resourceMcpLayer(
},
}),
Layer.mock(Credential.Service, {}),
overrides?.environment ?? hostEnvironmentLayer,
),
),
)
}
const connect = (server: string, config: typeof ConfigMCP.Server.Type, directory: string) =>
MCPClient.connect(server, config, directory).pipe(Effect.provide(hostEnvironmentLayer))
const mcp = Layer.mock(MCP.Service, {
tools: () =>
Effect.succeed([
@@ -407,7 +417,7 @@ test("retains output schemas across paginated MCP discovery", async () => {
const tools = await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const connection = yield* MCPClient.connect(
const connection = yield* connect(
"pagination",
new ConfigMCP.Local({
type: "local",
@@ -440,11 +450,139 @@ test("retains output schemas across paginated MCP discovery", async () => {
])
})
test("spawns local MCP servers through the location environment", async () => {
const spawns: Array<ChildProcess.Command> = []
const cwd = path.join(import.meta.dir, "fixture")
const config = new ConfigMCP.Local({
type: "local",
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
cwd: "fixture",
environment: { MCP_LOCATION_TEST: "configured" },
})
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const connection = yield* MCPClient.connect("environment", config, import.meta.dir)
yield* connection.tools()
}),
).pipe(Effect.provide(recordingEnvironmentLayer(spawns))),
)
expect(spawns).toHaveLength(1)
const command = spawns[0]
if (!command || !ChildProcess.isStandardCommand(command)) throw new Error("Expected a standard process command")
expect(command.command).toBe(process.execPath)
expect(command.options.cwd).toBe(cwd)
expect(command.options.extendEnv).toBe(true)
expect(command.options.env).toEqual({ MCP_LOCATION_TEST: "configured" })
})
test("rejects sends before the stdio transport is started", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const transport = yield* MCPStdio.make({
server: "not-started",
command: process.execPath,
args: [path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
cwd: import.meta.dir,
environment: {},
})
yield* Effect.tryPromise({
try: () => transport.send({ jsonrpc: "2.0", method: "notifications/initialized" }),
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
}).pipe(
Effect.flip,
Effect.tap((error) => Effect.sync(() => expect(error.message).toBe("Not connected"))),
)
}).pipe(Effect.provide(hostEnvironmentLayer)),
),
)
})
test("joins concurrent stdio transport closes", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const transport = yield* MCPStdio.make({
server: "concurrent-close",
command: "unused",
args: [],
cwd: import.meta.dir,
environment: {},
})
const first = transport.close()
expect(transport.close()).toBe(first)
yield* Effect.promise(() => first)
}).pipe(Effect.provide(hostEnvironmentLayer)),
),
)
})
test("closes a stdio process that finishes spawning after close", async () => {
const spawning = Deferred.makeUnsafe<void>()
const release = Deferred.makeUnsafe<void>()
const exited = Deferred.makeUnsafe<ExitCode>()
const signals: Array<string> = []
const driver = Environment.makeMemoryDriver()
const environment = Layer.succeed(
Environment.Service,
Environment.Service.of({
files: Environment.makeFiles(driver),
spawner: ChildProcessSpawner.make(() =>
Effect.gen(function* () {
yield* Deferred.succeed(spawning, undefined)
yield* Deferred.await(release)
return makeHandle({
pid: ProcessId(1),
exitCode: Deferred.await(exited),
isRunning: Deferred.isDone(exited).pipe(Effect.map((done) => !done)),
kill: (options) =>
Effect.gen(function* () {
signals.push(options?.killSignal ?? "SIGTERM")
yield* Deferred.succeed(exited, ExitCode(143))
}),
stdin: Sink.drain,
stdout: Stream.never,
stderr: Stream.empty,
all: Stream.never,
getInputFd: () => Sink.drain,
getOutputFd: () => Stream.empty,
unref: Effect.succeed(Effect.void),
})
}),
),
}),
)
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const transport = yield* MCPStdio.make({
server: "close-during-spawn",
command: "unused",
args: [],
cwd: import.meta.dir,
environment: {},
})
const start = transport.start()
yield* Deferred.await(spawning)
const close = transport.close()
yield* Deferred.succeed(release, undefined)
yield* Effect.promise(() => Promise.all([start, close]))
}).pipe(Effect.provide(environment)),
),
)
expect(signals).toEqual(["SIGTERM"])
})
test("applies the configured MCP catalog timeout", async () => {
const result = Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const connection = yield* MCPClient.connect(
const connection = yield* connect(
"catalog-timeout",
new ConfigMCP.Local({
type: "local",
@@ -466,7 +604,7 @@ test("applies the configured MCP execution timeout", async () => {
const result = Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const connection = yield* MCPClient.connect(
const connection = yield* connect(
"execution-timeout",
new ConfigMCP.Local({
type: "local",
@@ -487,7 +625,7 @@ test("applies the configured MCP execution timeout to prompts", async () => {
const result = Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const connection = yield* MCPClient.connect(
const connection = yield* connect(
"prompt-timeout",
new ConfigMCP.Local({
type: "local",
@@ -508,7 +646,7 @@ test("applies configured MCP timeouts to resource operations", async () => {
const catalog = Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const connection = yield* MCPClient.connect(
const connection = yield* connect(
"resource-catalog-timeout",
new ConfigMCP.Local({
type: "local",
@@ -527,7 +665,7 @@ test("applies configured MCP timeouts to resource operations", async () => {
const read = Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const connection = yield* MCPClient.connect(
const connection = yield* connect(
"resource-read-timeout",
new ConfigMCP.Local({
type: "local",
@@ -562,7 +700,7 @@ test("lists, reads, and reports MCP resource changes", async () => {
},
"templates-2": { items: [{ name: "Issue", uriTemplate: "issue://{id}", description: "Issue" }] },
}
const connection = yield* MCPClient.connect(
const connection = yield* connect(
"resources",
new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }),
import.meta.dir,
@@ -633,7 +771,7 @@ test("skips MCP resource requests when the capability is absent", async () => {
Effect.scoped(
Effect.gen(function* () {
const server = yield* resourceServer({ resources: false })
const connection = yield* MCPClient.connect(
const connection = yield* connect(
"resources",
new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }),
import.meta.dir,
+137 -8
View File
@@ -28,7 +28,7 @@ import { EventTable } from "@opencode-ai/core/event/sql"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { Form } from "@opencode-ai/core/form"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { SessionEvent } from "@opencode-ai/core/session/event"
@@ -355,6 +355,18 @@ const pluginSupervisor = Layer.succeed(
flush: Effect.suspend(() => pluginFlushHook),
}),
)
let snapshotCaptureHook: () => Effect.Effect<Snapshot.ID | undefined> = () => Effect.succeed(undefined)
let snapshotFilesHook: (input: Snapshot.CompareInput) => Effect.Effect<readonly RelativePath[], Snapshot.Error> = () =>
Effect.succeed([])
const snapshots = Layer.succeed(
Snapshot.Service,
Snapshot.Service.of({
capture: () => snapshotCaptureHook(),
files: (input) => snapshotFilesHook(input),
diff: () => Effect.succeed([]),
restore: () => Effect.void,
}),
)
const promptCatalog = Layer.mock(Catalog.Service, {
provider: {
get: () => Effect.succeed(undefined),
@@ -370,7 +382,7 @@ const promptCatalog = Layer.mock(Catalog.Service, {
},
})
const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
[Snapshot.node, Snapshot.noopLayer],
[Snapshot.node, snapshots],
[LayerNodePlatform.llmClient, client],
[SessionRunnerModel.node, models],
[InstructionBuiltIns.node, systemContext],
@@ -437,7 +449,7 @@ const it = testEffect(
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
[SkillInstructions.node, skillInstructions],
[ReferenceInstructions.node, referenceInstructions],
[Snapshot.node, Snapshot.noopLayer],
[Snapshot.node, snapshots],
[SessionExecution.node, execution],
[Config.node, config],
[PluginSupervisor.node, pluginSupervisor],
@@ -493,6 +505,8 @@ const setup = Effect.gen(function* () {
systemLoadHook = Effect.void
modelResolveHook = Effect.void
pluginFlushHook = Effect.void
snapshotCaptureHook = () => Effect.succeed(undefined)
snapshotFilesHook = () => Effect.succeed([])
currentModel = model
skillBaselines.clear()
toolBarrier = undefined
@@ -816,7 +830,7 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
})
describe("SessionRunnerLLM", () => {
it.effect("retries title generation from the first prompt after execution and title failures", () =>
it.effect("generates the title while the first model step is still running", () =>
Effect.gen(function* () {
const session = yield* setup
const agents = yield* Agent.Service
@@ -831,16 +845,48 @@ describe("SessionRunnerLLM", () => {
)
yield* admit(session, "First prompt")
yield* TestLLM.push(Stream.fail(invalidRequest()))
yield* TestLLM.push(TestLLM.text("Generated title", "text-title"), Stream.never)
const bus = yield* Bus.Service
const renamed = yield* bus.subscribe(SessionEvent.Renamed).pipe(
Stream.filter((event) => event.data.sessionID === sessionID),
Stream.take(1),
Stream.runDrain,
Effect.forkScoped({ startImmediately: true }),
)
const runner = yield* SessionRunner.Service
const fiber = yield* runner.drain({ sessionID, force: true }).pipe(Effect.forkChild)
yield* Fiber.join(renamed)
expect((yield* session.get(sessionID)).title).toBe("Generated title")
yield* Fiber.interrupt(fiber)
}),
)
it.effect("retries title generation from the first prompt after title and execution failures", () =>
Effect.gen(function* () {
const session = yield* setup
const agents = yield* Agent.Service
const { db } = yield* Database.Service
yield* db.update(SessionTable).set({ title: null }).where(eq(SessionTable.id, sessionID)).run().pipe(Effect.orDie)
yield* agents.transform((draft) =>
draft.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "Generate a title."
}),
)
yield* admit(session, "First prompt")
yield* TestLLM.push(Stream.fail(invalidRequest()), Stream.fail(invalidRequest()))
expect((yield* session.resume(sessionID).pipe(Effect.exit))._tag).toBe("Failure")
yield* admit(session, "Second prompt")
const titleFailed = yield* Deferred.make<void>()
yield* TestLLM.push(
TestLLM.text("Recovered", "text-recovered"),
Stream.make(LLMEvent.providerError({ message: "Title provider unavailable" })).pipe(
Stream.ensuring(Deferred.succeed(titleFailed, undefined)),
),
TestLLM.text("Recovered", "text-recovered"),
)
yield* session.resume(sessionID)
yield* Deferred.await(titleFailed)
@@ -856,13 +902,13 @@ describe("SessionRunnerLLM", () => {
)
yield* admit(session, "Third prompt")
yield* TestLLM.push(
TestLLM.text("Recovered again", "text-recovered-again"),
TestLLM.text("Generated title", "text-title"),
TestLLM.text("Recovered again", "text-recovered-again"),
)
yield* session.resume(sessionID)
yield* Fiber.join(renamed)
expect(requests).toHaveLength(5)
expect(requests).toHaveLength(6)
expect(requests[2]?.messages).toContainEqual(Message.user("First prompt"))
expect(requests[4]?.messages).toContainEqual(Message.user("First prompt"))
expect((yield* session.get(sessionID)).title).toBe("Generated title")
@@ -3709,6 +3755,89 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("waits for the end snapshot before interrupted settlement", () =>
Effect.gen(function* () {
const session = yield* setup
const endCaptureStarted = yield* Deferred.make<void>()
const releaseEndCapture = yield* Deferred.make<void>()
const runSettled = yield* Deferred.make<void>()
const interruptSettled = yield* Deferred.make<void>()
let captures = 0
snapshotCaptureHook = () => {
captures++
if (captures === 1) return Effect.succeed(Snapshot.ID.make("snapshot-start"))
return Deferred.succeed(endCaptureStarted, undefined).pipe(
Effect.andThen(Deferred.await(releaseEndCapture)),
Effect.as(Snapshot.ID.make("snapshot-end")),
)
}
snapshotFilesHook = () => Effect.succeed([RelativePath.make("changed.txt")])
yield* admit(session, "Interrupt during end snapshot")
const stream = yield* TestLLM.gate
const run = yield* session
.resume(sessionID)
.pipe(Effect.ensuring(Deferred.succeed(runSettled, undefined)), Effect.forkChild)
yield* stream.started
const interrupt = yield* session
.interrupt(sessionID)
.pipe(Effect.ensuring(Deferred.succeed(interruptSettled, undefined)), Effect.forkChild)
yield* Deferred.await(endCaptureStarted)
expect(yield* Deferred.isDone(interruptSettled)).toBe(false)
expect(yield* Deferred.isDone(runSettled)).toBe(false)
yield* Deferred.succeed(releaseEndCapture, undefined)
yield* Fiber.join(interrupt)
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
expect(requireAssistant(yield* session.context(sessionID))).toMatchObject({
finish: "error",
error: { type: "aborted", message: "Step interrupted" },
snapshot: {
start: "snapshot-start",
end: "snapshot-end",
files: ["changed.txt"],
},
})
}),
)
it.effect("waits for unrelated database transactions before interrupted settlement", () =>
Effect.gen(function* () {
const session = yield* setup
const { db } = yield* Database.Service
const transactionStarted = yield* Deferred.make<void>()
const releaseTransaction = yield* Deferred.make<void>()
const interruptSettled = yield* Deferred.make<void>()
yield* admit(session, "Interrupt during database contention")
const stream = yield* TestLLM.gate
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* stream.started
const transaction = yield* db
.transaction(() =>
Deferred.succeed(transactionStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseTransaction))),
)
.pipe(Effect.forkChild)
yield* Deferred.await(transactionStarted)
const interrupt = yield* session
.interrupt(sessionID)
.pipe(Effect.ensuring(Deferred.succeed(interruptSettled, undefined)), Effect.forkChild)
yield* Effect.yieldNow
expect(yield* Deferred.isDone(interruptSettled)).toBe(false)
yield* Deferred.succeed(releaseTransaction, undefined)
yield* Fiber.join(transaction)
yield* Fiber.join(interrupt)
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
expect(requireAssistant(yield* session.context(sessionID))).toMatchObject({
finish: "error",
error: { type: "aborted", message: "Step interrupted" },
})
}),
)
it.effect("durably fails blocked local tools when interrupted while awaiting settlement", () =>
Effect.gen(function* () {
const session = yield* setup
+39 -36
View File
@@ -143,44 +143,47 @@ describe("Snapshot", () => {
),
)
testEffect(Layer.empty).live("isolates snapshot indexes by canonical Git worktree", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
const project = path.join(tmp.path, "project")
const linked = path.join(tmp.path, "linked")
yield* Effect.promise(async () => {
await fs.mkdir(project)
await fs.writeFile(path.join(project, "tracked.txt"), "main\n")
await initGit(project, true)
await $`git -c core.fsmonitor=false worktree add --detach ${linked} HEAD`.cwd(project).quiet()
})
testEffect(Layer.empty).live(
"isolates snapshot indexes by canonical Git worktree",
() =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
const project = path.join(tmp.path, "project")
const linked = path.join(tmp.path, "linked")
yield* Effect.promise(async () => {
await fs.mkdir(project)
await fs.writeFile(path.join(project, "tracked.txt"), "main\n")
await initGit(project, true)
await $`git -c core.fsmonitor=false worktree add --detach ${linked} HEAD`.cwd(project).quiet()
})
const capture = (directory: string) =>
Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
return yield* snapshot.capture()
}).pipe(Effect.provide(snapshotLayer(tmp.path, directory)))
expect(yield* capture(project)).toBeDefined()
expect(yield* capture(linked)).toBeDefined()
const capture = (directory: string) =>
Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
return yield* snapshot.capture()
}).pipe(Effect.provide(snapshotLayer(tmp.path, directory)))
expect(yield* capture(project)).toBeDefined()
expect(yield* capture(linked)).toBeDefined()
const projectID = yield* Effect.gen(function* () {
return (yield* Location.Service).project.id
}).pipe(
Effect.provide(
AppNodeBuilder.build(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))),
),
)
expect(
yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(project)))),
).toBeDefined()
expect(
yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(linked)))),
).toBeDefined()
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
const projectID = yield* Effect.gen(function* () {
return (yield* Location.Service).project.id
}).pipe(
Effect.provide(
AppNodeBuilder.build(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))),
),
)
expect(
yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(project)))),
).toBeDefined()
expect(
yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(linked)))),
).toBeDefined()
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
{ timeout: 15_000 },
)
})
+81 -72
View File
@@ -387,57 +387,63 @@ describe("ShellTool", () => {
),
)
it.live("approves an explicit external workdir before shell execution", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
reset()
return withSession(active.path, (registry) =>
executeTool(registry, call({ command: cwdCommand, workdir: outside.path })),
).pipe(
Effect.andThen(
Effect.sync(() => {
expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"])
expect(assertions[0]).toMatchObject({
resources: [path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")],
})
}),
it.live(
"approves an explicit external workdir before shell execution",
() =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
reset()
return withSession(active.path, (registry) =>
executeTool(registry, call({ command: cwdCommand, workdir: outside.path })),
).pipe(
Effect.andThen(
Effect.sync(() => {
expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"])
expect(assertions[0]).toMatchObject({
resources: [path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")],
})
}),
),
)
},
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
)
},
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
),
),
{ timeout: 15_000 },
)
it.live("approves an external directory used by a directory-change command", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
reset()
const command = isWindows
? `Set-Location -LiteralPath '${outside.path}'; (Get-Location).Path`
: `cd '${outside.path}' && pwd`
return withSession(active.path, (registry) =>
executeTool(registry, call({ command }, "call-external-cd")),
).pipe(
Effect.andThen(
Effect.sync(() => {
expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"])
expect(assertions[0]).toMatchObject({
resources: [path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")],
})
}),
it.live(
"approves an external directory used by a directory-change command",
() =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
reset()
const command = isWindows
? `Set-Location -LiteralPath '${outside.path}'; (Get-Location).Path`
: `cd '${outside.path}' && pwd`
return withSession(active.path, (registry) =>
executeTool(registry, call({ command }, "call-external-cd")),
).pipe(
Effect.andThen(
Effect.sync(() => {
expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"])
expect(assertions[0]).toMatchObject({
resources: [path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")],
})
}),
),
)
},
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
)
},
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
),
),
{ timeout: 15_000 },
)
it.live("approves an expanded external home directory", () =>
@@ -459,28 +465,31 @@ describe("ShellTool", () => {
),
)
it.live("does not execute after external-directory or shell denial", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) =>
Effect.gen(function* () {
reset()
denyAction = "external_directory"
yield* withSession(active.path, (registry) =>
executeTool(registry, call({ command: cwdCommand, workdir: outside.path })),
)
expect(assertions.map((item) => item.action)).toEqual(["external_directory"])
it.live(
"does not execute after external-directory or shell denial",
() =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) =>
Effect.gen(function* () {
reset()
denyAction = "external_directory"
yield* withSession(active.path, (registry) =>
executeTool(registry, call({ command: cwdCommand, workdir: outside.path })),
)
expect(assertions.map((item) => item.action)).toEqual(["external_directory"])
reset()
denyAction = "shell"
yield* withSession(active.path, (registry) => executeTool(registry, call({ command: cwdCommand })))
expect(assertions.map((item) => item.action)).toEqual(["shell"])
}),
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
),
reset()
denyAction = "shell"
yield* withSession(active.path, (registry) => executeTool(registry, call({ command: cwdCommand })))
expect(assertions.map((item) => item.action)).toEqual(["shell"])
}),
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
),
{ timeout: 15_000 },
)
it.live("keeps non-zero exits useful", () =>
@@ -619,7 +628,7 @@ describe("ShellTool", () => {
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
{ timeout: 10_000 },
{ timeout: 15_000 },
)
it.live(
@@ -630,7 +639,7 @@ describe("ShellTool", () => {
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
executeTool(registry, call({ command: timeoutOutputCommand, timeout: isWindows ? 3_000 : 50 })),
executeTool(registry, call({ command: timeoutOutputCommand, timeout: isWindows ? 3_000 : 500 })),
).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
@@ -667,7 +676,7 @@ describe("ShellTool", () => {
)
const settled = yield* executeTool(registry, call({ command: idleCommand, timeout: 50, background: true }))
const shellID = typeof settled.metadata?.shellID === "string" ? settled.metadata.shellID : undefined
expect(settled.metadata).toMatchObject({ status: "running", truncated: false })
expect(settled.metadata).toMatchObject({ truncated: false })
expect(shellID).toStartWith("sh_")
const shell = yield* Shell.Service
@@ -752,7 +761,7 @@ describe("ShellTool", () => {
expect(yield* backgroundWhenReady()).toMatchObject([{ id: "call-background-signal", type: "shell" }])
const settled = yield* Fiber.join(waiting)
const shellID = typeof settled.metadata?.shellID === "string" ? settled.metadata.shellID : undefined
expect(settled.metadata).toMatchObject({ status: "running", truncated: false })
expect(settled.metadata).toMatchObject({ truncated: false })
expect(settled.content?.[0]).toEqual({
type: "text",
text: "The command was moved to the background.",
@@ -257,6 +257,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
<PromptInputV2SubmitButton
mode={state.mode}
stopping={view.submit.stopping()}
pending={view.submit.pending?.() ?? false}
disabled={!props.controller.canSubmit()}
sendLabel={i18n.t("ui.promptInput.send")}
stopLabel={i18n.t("ui.promptInput.stop")}
@@ -672,6 +673,7 @@ export function PromptInputV2Popover(props: {
export function PromptInputV2SubmitButton(props: {
mode: PromptInputV2Mode
stopping: boolean
pending: boolean
disabled: boolean
sendLabel: string
stopLabel: string
@@ -682,12 +684,12 @@ export function PromptInputV2SubmitButton(props: {
<TooltipV2
placement="top"
inactive={!props.stopping && props.disabled}
value={props.stopping ? props.stopLabel : props.sendLabel}
value={props.pending ? `${props.stopLabel}...` : props.stopping ? props.stopLabel : props.sendLabel}
>
<IconButton
data-action="prompt-submit"
type="button"
disabled={!props.stopping && props.disabled}
disabled={props.pending || (!props.stopping && props.disabled)}
tabIndex={props.mode === "normal" ? undefined : -1}
icon={props.stopping ? "stop" : props.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
variant="primary"
@@ -36,6 +36,7 @@ export type PromptInputV2ViewConfig = {
variant?: PromptInputV2SelectControl
submit: {
stopping: Accessor<boolean>
pending?: Accessor<boolean>
working?: Accessor<boolean>
onSubmit: () => void
onStop: () => void
+11 -4
View File
@@ -2,7 +2,7 @@ import { render, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { registerOpencodeSpinner } from "./component/register-spinner"
import { Deferred, Effect } from "effect"
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
import { OpenCode } from "@opencode-ai/client"
import { OpenCode, type SessionInfo } from "@opencode-ai/client"
import { Global } from "@opencode-ai/util/global"
import { ClipboardProvider, useClipboard } from "./context/clipboard"
import { LogProvider, useLog, type LogSink } from "./context/log"
@@ -69,7 +69,7 @@ import { DialogThemeList } from "./component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help"
import { DialogAgent } from "./component/dialog-agent"
import { DialogSessionList } from "./component/dialog-session-list"
import { DialogOpen } from "./component/dialog-open"
import { DialogOpen, DialogOpenKey, loadDialogOpen } from "./component/dialog-open"
import { SessionTabs } from "./component/session-tabs"
import { sessionTabsFitVertically } from "./ui/layout"
import { ThemeErrorToast } from "./component/theme-error-toast"
@@ -478,6 +478,7 @@ function App(props: { pair?: DialogPairCredentials }) {
const promptRef = usePromptRef()
const plugins = usePlugin()
const clipboard = useClipboard()
let openingOpen: Promise<SessionInfo[]> | undefined
// Toast once when an MCP server enters a failed or needs-auth state so the user knows to act,
// without having to open the status panel. Tracking the last alerted status avoids re-toasting
// the same problem on every refresh while still re-alerting if the state changes.
@@ -680,8 +681,14 @@ function App(props: { pair?: DialogPairCredentials }) {
title: "Open session or project",
category: "Session",
slash: { name: "open", aliases: ["projects", "project"] },
run: () => {
dialog.replace(() => <DialogOpen />)
run: async () => {
if (dialog.key === DialogOpenKey || openingOpen) return
const previous = dialog.stack.at(-1)
openingOpen = loadDialogOpen(data, client)
const sessions = await openingOpen
openingOpen = undefined
if (dialog.stack.at(-1) !== previous) return
dialog.replace(() => <DialogOpen sessions={sessions} />, undefined, { key: DialogOpenKey, size: "large" })
},
},
...Array.from({ length: 9 }, (_, i) => ({
+10 -1
View File
@@ -429,6 +429,7 @@ export function DevToolsBar() {
function BarItem(props: ParentProps<{ active: boolean; onClick: () => void }>) {
const theme = useTheme()
const renderer = useRenderer()
const [hovered, setHovered] = createSignal(false)
return (
<box
position="relative"
@@ -437,7 +438,15 @@ function BarItem(props: ParentProps<{ active: boolean; onClick: () => void }>) {
flexDirection="row"
paddingLeft={1}
paddingRight={1}
backgroundColor={props.active ? theme.background.action.primary.focused : undefined}
backgroundColor={
props.active
? theme.background.action.primary.focused
: hovered()
? theme.background.action.primary.hovered
: undefined
}
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
props.onClick()
+1 -10
View File
@@ -283,7 +283,7 @@ export const settings: Setting[] = [
keywords: ["selection", "clipboard"],
},
{
title: "DevTools",
title: "Developer tools",
category: "Debug",
path: ["debug", "devtools"],
default: false,
@@ -291,15 +291,6 @@ export const settings: Setting[] = [
labels: ["off", "on"],
keywords: ["debug bar", "developer tools"],
},
{
title: "Turn token usage",
category: "Debug",
path: ["debug", "turn_tokens"],
default: false,
values: [false, true, "verbose"],
labels: ["off", "on", "verbose"],
keywords: ["tokens", "usage", "debug"],
},
]
export function settingID(setting: Setting) {
+16 -17
View File
@@ -1,4 +1,4 @@
import { createMemo, createResource, createSignal, onMount } from "solid-js"
import { createMemo, createResource, createSignal } from "solid-js"
import type { SessionInfo } from "@opencode-ai/client"
import { useTerminalDimensions } from "@opentui/solid"
import { dialogWidth, useDialog } from "../ui/dialog"
@@ -20,10 +20,22 @@ import { Spinner } from "./spinner"
import { projectName } from "../util/project"
const RECENT_LIMIT = 8
export const DialogOpenKey = Symbol("DialogOpen")
type OpenTarget = { type: "session"; sessionID: string } | { type: "project"; directory: string }
export function DialogOpen() {
export async function loadDialogOpen(data: ReturnType<typeof useData>, client: ReturnType<typeof useClient>) {
const [, sessions] = await Promise.all([
data.project.sync().catch(() => {}),
client.api.session
.list({ limit: 50, order: "desc", parentID: null })
.then((response) => response.data)
.catch(() => [] as SessionInfo[]),
])
return sessions
}
export function DialogOpen(props: { sessions: SessionInfo[] }) {
const dialog = useDialog()
const route = useRoute()
const data = useData()
@@ -39,18 +51,6 @@ export function DialogOpen() {
const [filter, setFilter] = createSignal("")
const [selectionMoved, setSelectionMoved] = createSignal(false)
void data.project.sync().catch(() => {})
// One background fetch fills in recent sessions from other projects; the menu renders
// immediately from the local store and never blocks on the network.
const [fetched] = createResource(
() =>
client.api.session
.list({ limit: 50, order: "desc", parentID: null })
.then((response) => response.data)
.catch(() => [] as SessionInfo[]),
{ initialValue: [] },
)
const [matched] = createResource(
() => {
const value = filter().trim()
@@ -72,7 +72,7 @@ export function DialogOpen() {
const sessions = createMemo(() => {
const seen = new Set<string>()
const match = matched()
return [...data.session.list(), ...fetched(), ...(match ? [match] : [])]
return [...data.session.list(), ...props.sessions, ...(match ? [match] : [])]
.filter((session) => {
if (session.parentID || seen.has(session.id)) return false
seen.add(session.id)
@@ -142,8 +142,6 @@ export function DialogOpen() {
return [...sessionOptions, ...projectOptions]
})
onMount(() => dialog.setSize("large"))
return (
<DialogSelect
title="Open"
@@ -151,6 +149,7 @@ export function DialogOpen() {
options={options()}
current={currentSessionID() ? ({ type: "session", sessionID: currentSessionID()! } as OpenTarget) : undefined}
focusCurrent={false}
sectionNavigation={true}
preserveSelection={selectionMoved()}
onMove={() => setSelectionMoved(true)}
onFilter={setFilter}
+27 -4
View File
@@ -167,6 +167,16 @@ export function Prompt(props: PromptProps) {
const dialog = useDialog()
const toast = useToast()
const status = createMemo(() => data.session.status(props.sessionID ?? ""))
const [stoppingSession, setStoppingSession] = createSignal<string>()
const stopping = createMemo(() => stoppingSession() === props.sessionID && status() === "running")
createEffect(
on(
() => [props.sessionID, status()] as const,
([, current]) => {
if (current === "idle") setStoppingSession(undefined)
},
),
)
const history = usePromptHistory()
const stash = usePromptStash()
const keymap = Keymap.use()
@@ -466,7 +476,7 @@ export function Prompt(props: PromptProps) {
name: "session.interrupt",
category: "Session",
palette: undefined,
enabled: status() === "running",
enabled: status() === "running" && !stopping(),
run: () => {
if (auto()?.visible) return
if (!input.focused) return
@@ -484,9 +494,12 @@ export function Prompt(props: PromptProps) {
}, 5000)
if (store.interrupt >= 2) {
void client.api.session.interrupt({
sessionID: props.sessionID,
})
setStoppingSession(props.sessionID)
void client.api.session
.interrupt({
sessionID: props.sessionID,
})
.catch(() => setStoppingSession(undefined))
setStore("interrupt", 0)
}
dialog.clear()
@@ -1797,6 +1810,16 @@ export function Prompt(props: PromptProps) {
<Slot path="prompt.footer.status" input={footerInput()}>
<box flexGrow={1} flexShrink={1} minWidth={0}>
<Switch>
<Match when={stopping()}>
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
<box marginLeft={1}>
<Show when={config.animations ?? true} fallback={<text fg={theme.text.subdued}>[]</text>}>
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
</Show>
</box>
<text fg={theme.text.subdued}>Stopping...</text>
</box>
</Match>
<Match when={status() === "running"}>
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
<box marginLeft={1}>
+282 -38
View File
@@ -1,4 +1,4 @@
import { RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
import { RGBA, ScrollBoxRenderable, TextAttributes, type MouseEvent } from "@opentui/core"
import { For, Show, createComputed, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
import { useTerminalDimensions } from "@opentui/solid"
import { useConfig } from "../config"
@@ -18,12 +18,15 @@ import {
} from "../context/session-tabs-model"
import { createAnimatable, spring, tween } from "../ui/animation"
import { Locale } from "../util/locale"
import { stringWidth } from "../util/string-width"
import { TabPulse, unreadGlowIntensity } from "./tab-pulse"
import { tint } from "../theme/color"
import { SESSION_SIDEBAR_WIDTH } from "../ui/layout"
import { projectName } from "../util/project"
import { marqueeText } from "../util/marquee"
import { marqueeCycleWidth, marqueeOverflows, marqueeText } from "../util/marquee"
import { useDialog } from "../ui/dialog"
import { DialogSessionRename } from "./dialog-session-rename"
import { Keymap } from "../context/keymap"
import { moveSelection } from "../ui/select-controller"
// A long title fades out over its last cells instead of cutting hard.
const FADE_WIDTH = 4
@@ -31,6 +34,15 @@ const FADE_WIDTH = 4
const ADD_TAB_WIDTH = 3
const MARQUEE_DELAY = 600
const MARQUEE_INTERVAL = 100
const CONTEXT_MENU_WIDTH = 16
const RIGHT_MOUSE_BUTTON = 2
type TabContextMenuState = {
x: number
y: number
sessionID?: string
title?: string
}
type ContextController = ReturnType<typeof useSessionTabs>
export type SessionTabsStatus = Omit<ReturnType<ContextController["status"]>, "unread"> & {
@@ -60,27 +72,188 @@ function fadeTitleColor(color: RGBA, background: RGBA, index: number, length: nu
return opacity === 0 ? color : tint(color, background, opacity)
}
function createMarquee(hovered: () => string | undefined, animations: () => boolean) {
function createMarquee(animations: () => boolean) {
const [offset, setOffset] = createSignal(0)
const [active, setActive] = createSignal<string>()
const leading = createAnimatable({ opacity: 0 }, { enabled: animations, transition: tween({ duration: 0.25 }) })
let delay: ReturnType<typeof setTimeout> | undefined
let interval: ReturnType<typeof setInterval> | undefined
let cycleWidth = 0
let returning = false
createEffect(() => {
const clear = () => {
if (delay) clearTimeout(delay)
if (interval) clearInterval(interval)
delay = undefined
interval = undefined
}
const scroll = () => {
interval = setInterval(() => setOffset((value) => (value + 1) % cycleWidth), MARQUEE_INTERVAL)
}
const enter = (sessionID: string, title: string, width: number) => {
if (active() === sessionID && !returning) return
clear()
if (active() === sessionID) {
returning = false
return scroll()
}
if (!marqueeOverflows(title, width)) return
cycleWidth = marqueeCycleWidth(title)
setActive(sessionID)
setOffset(0)
returning = false
leading.jump({ opacity: 0 })
if (!hovered()) return
let interval: ReturnType<typeof setInterval> | undefined
const delay = setTimeout(() => {
delay = setTimeout(() => {
setOffset(1)
leading.animate({ opacity: 1 })
interval = setInterval(() => setOffset((value) => value + 1), MARQUEE_INTERVAL)
scroll()
}, MARQUEE_DELAY)
onCleanup(() => {
clearTimeout(delay)
if (interval) clearInterval(interval)
}
const leave = (sessionID: string) => {
if (active() !== sessionID) return
clear()
if (offset() === 0) {
setActive(undefined)
return
}
returning = true
interval = setInterval(() => {
setOffset((value) => {
const next = (value + 1) % cycleWidth
if (next !== 0) return next
clear()
returning = false
setActive(undefined)
leading.animate({ opacity: 0 })
return 0
})
}, MARQUEE_INTERVAL)
}
const reset = () => {
clear()
returning = false
setActive(undefined)
setOffset(0)
leading.jump({ opacity: 0 })
}
onCleanup(clear)
return { offset, active, enter, leave, reset, leading: () => leading.value().opacity }
}
function createTabMarquee(animations: () => boolean) {
const [hovered, setHovered] = createSignal<string>()
const marquee = createMarquee(animations)
let hoverClear: ReturnType<typeof setTimeout> | undefined
const enter = (sessionID: string, title: string, width: number) => {
if (hoverClear) clearTimeout(hoverClear)
setHovered(sessionID)
marquee.enter(sessionID, title, width)
}
const leave = (sessionID: string) => {
if (hoverClear) clearTimeout(hoverClear)
hoverClear = setTimeout(() => {
if (hovered() !== sessionID) return
setHovered(undefined)
marquee.leave(sessionID)
})
}
onCleanup(() => {
if (hoverClear) clearTimeout(hoverClear)
})
return { offset, leading: () => leading.value().opacity }
return { ...marquee, hovered, enter, leave }
}
function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsController; onClose: () => void }) {
const dimensions = useTerminalDimensions()
const theme = useTheme("elevated")
const dialog = useDialog()
const keymap = Keymap.use()
const actions = createMemo(() => {
const sessionID = props.state.sessionID
return [
...(props.tabs.add ? [{ title: "New tab", run: () => props.tabs.add?.() }] : []),
...(sessionID
? [
{
title: "Rename",
run: () => DialogSessionRename.show(dialog, sessionID, props.state.title),
},
{ title: "Close", run: () => props.tabs.close(sessionID) },
]
: []),
]
})
const [selected, setSelected] = createSignal(0)
const top = () => Math.max(0, Math.min(props.state.y + 1, dimensions().height - actions().length))
const left = () => Math.max(0, Math.min(props.state.x, dimensions().width - CONTEXT_MENU_WIDTH))
const run = (index: number) => {
props.onClose()
actions()[index]?.run()
}
createEffect(() => {
const popMode = keymap.mode.push("modal")
onCleanup(popMode)
})
Keymap.createLayer(() => ({
mode: "modal",
commands: [
{ bind: "escape", title: "Close tab menu", group: "Tabs", run: props.onClose },
{
bind: "up",
title: "Previous tab menu item",
group: "Tabs",
run: () => setSelected(moveSelection(selected(), { count: actions().length, delta: -1, policy: "wrap" })),
},
{
bind: "down",
title: "Next tab menu item",
group: "Tabs",
run: () => setSelected(moveSelection(selected(), { count: actions().length, delta: 1, policy: "wrap" })),
},
{ bind: "return", title: "Select tab menu item", group: "Tabs", run: () => run(selected()) },
],
}))
return (
<box
position="absolute"
left={left()}
top={top()}
height={actions().length}
width={CONTEXT_MENU_WIDTH}
zIndex={2500}
flexDirection="column"
backgroundColor={theme.background.default}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
>
<For each={actions()}>
{(action, index) => (
<box
width="100%"
paddingLeft={1}
paddingRight={1}
backgroundColor={selected() === index() ? theme.background.action.primary.hovered : undefined}
onMouseOver={() => setSelected(index())}
onMouseUp={(event) => {
event.preventDefault()
event.stopPropagation()
run(index())
}}
>
<text fg={theme.text.default} selectable={false}>
{action.title}
</text>
</box>
)}
</For>
</box>
)
}
export function SessionTabs(
@@ -105,11 +278,12 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const idleNumber = () => tint(theme.text.subdued, theme.background.default, 0.35)
const separatorUpperPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.04))
const separatorLowerPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.05))
const [hovered, setHovered] = createSignal<string>()
const [addHovered, setAddHovered] = createSignal(false)
const marquee = createMarquee(hovered, animations)
const marquee = createTabMarquee(animations)
const hovered = marquee.hovered
const [dragging, setDragging] = createSignal<string>()
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
const [contextMenu, setContextMenu] = createSignal<TabContextMenuState>()
const newTab = () => tabs.newTab?.() ?? false
const activeID = createMemo(() => (newTab() ? undefined : tabs.current()))
const ordered = createMemo(() => {
@@ -118,6 +292,10 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
return moveSessionTab(tabs.tabs(), pending.sessionID, pending.index)
})
const items = ordered
createEffect(() => {
const active = marquee.active()
if (active && !items().some((tab) => tab.sessionID === active)) marquee.reset()
})
const statuses = createMemo(
() =>
new Map(
@@ -137,7 +315,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
),
)
const itemStatus = (tab: SessionTab) => statuses().get(tab.sessionID)!
let rail: { screenY: number } | undefined
let rail: { screenX: number; screenY: number } | undefined
let scroll: ScrollBoxRenderable | undefined
createEffect(() => {
@@ -167,6 +345,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
height="100%"
flexShrink={0}
flexDirection="column"
position="relative"
paddingTop={1}
backgroundColor={theme.background.default}
>
@@ -183,16 +362,19 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
return value ? data.project.get(value.projectID) : undefined
})
const numberWidth = () => 2
const titleWidth = () => Math.max(1, width() - numberWidth() - 2 - (hovered() === tab.sessionID ? 1 : 0))
const restingTitleWidth = () => Math.max(1, width() - numberWidth() - 2)
const titleWidth = () => Math.max(1, restingTitleWidth() - (hovered() === tab.sessionID ? 1 : 0))
const title = () => tab.title ?? "Untitled session"
const scrolling = () => hovered() === tab.sessionID && marquee.offset() > 0
const scrolling = () => marquee.active() === tab.sessionID && marquee.offset() > 0
const visibleTitle = createMemo(() =>
scrolling()
? marqueeText(title(), titleWidth(), marquee.offset())
: Locale.takeWidth(title(), titleWidth()),
)
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
const titleFades = createMemo(() => stringWidth(title()) >= titleWidth() && titleWidth() > FADE_WIDTH)
const titleFades = createMemo(
() => marqueeOverflows(title(), restingTitleWidth()) && titleWidth() > FADE_WIDTH,
)
const detail = createMemo(() => {
const value = session()
return Locale.takeWidth(projectName(project(), value?.location.directory) ?? "", titleWidth())
@@ -274,13 +456,29 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
position="relative"
flexDirection="column"
backgroundColor={background()}
onMouseOver={() => setHovered(tab.sessionID)}
onMouseOut={() => setHovered(undefined)}
onMouseDown={() => {
setHovered(tab.sessionID)
onMouseOver={() => marquee.enter(tab.sessionID, title(), restingTitleWidth())}
onMouseOut={() => marquee.leave(tab.sessionID)}
onMouseDown={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) {
setDragging(undefined)
if (!rail) return
setContextMenu({
x: event.x - rail.screenX,
y: event.y - rail.screenY,
sessionID: tab.sessionID,
title: tab.title,
})
event.preventDefault()
event.stopPropagation()
return
}
marquee.enter(tab.sessionID, title(), restingTitleWidth())
setDragging(tab.sessionID)
}}
onMouseUp={release}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
release()
}}
onMouseDrag={(event) => {
if (!rail) return
const target = Math.max(
@@ -389,6 +587,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
fg={theme.text.subdued}
selectable={false}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
if (hovered() !== tab.sessionID) return
event.stopPropagation()
tabs.close(tab.sessionID)
@@ -440,7 +639,15 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
}
onMouseOver={() => setAddHovered(true)}
onMouseOut={() => setAddHovered(false)}
onMouseUp={() => {
onMouseDown={(event: MouseEvent) => {
if (event.button !== RIGHT_MOUSE_BUTTON) return
if (!rail) return
setContextMenu({ x: event.x - rail.screenX, y: event.y - rail.screenY })
event.preventDefault()
event.stopPropagation()
}}
onMouseUp={(event: MouseEvent) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
if (!newTab()) tabs.add?.()
}}
>
@@ -469,6 +676,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
fg={theme.text.subdued}
selectable={false}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
if (!addHovered()) return
event.stopPropagation()
tabs.close()
@@ -481,6 +689,9 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
</Show>
</box>
</scrollbox>
<Show when={contextMenu()}>
{(state) => <TabContextMenu state={state()} tabs={tabs} onClose={() => setContextMenu(undefined)} />}
</Show>
</box>
)
}
@@ -492,15 +703,16 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
const { mode } = useThemes()
const config = useConfig().data
const animations = () => props.animations ?? config.animations ?? true
const [hovered, setHovered] = createSignal<string>()
const [addHovered, setAddHovered] = createSignal(false)
const marquee = createMarquee(hovered, animations)
const marquee = createTabMarquee(animations)
const hovered = marquee.hovered
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,
// so the strip never flashes the pre-drag order while the write is in flight.
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
let strip: { screenX: number } | undefined
const [contextMenu, setContextMenu] = createSignal<TabContextMenuState>()
let strip: { screenX: number; screenY: number } | undefined
const hueStep = () => (mode() === "light" ? 800 : 200)
const accent = () => theme.hue.accent[hueStep()]
const activeNumber = () => theme.hue.interactive[hueStep()]
@@ -530,6 +742,10 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
previous?.start,
),
)
createEffect(() => {
const active = marquee.active()
if (active && !layout().tabs.some((tab) => tab.sessionID === active)) marquee.reset()
})
const statuses = createMemo(
() =>
new Map(
@@ -680,9 +896,9 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
// Shortcut labels stay one cell wide: 1-9, 0 for ten, then a neutral dot.
const numberWidth = () => 2
// Hovering reveals the close mark, so the title's right bound shifts left of it.
const availableTitleWidth = () =>
Math.max(1, width() - 1 - numberWidth() - (hovered() === tab.sessionID ? 2 : 0))
const scrolling = () => hovered() === tab.sessionID && marquee.offset() > 0
const restingTitleWidth = () => Math.max(1, width() - 1 - numberWidth())
const availableTitleWidth = () => Math.max(1, restingTitleWidth() - (hovered() === tab.sessionID ? 2 : 0))
const scrolling = () => marquee.active() === tab.sessionID && marquee.offset() > 0
const visibleTitle = createMemo(() =>
scrolling()
? marqueeText(title(), availableTitleWidth(), marquee.offset())
@@ -690,7 +906,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
)
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
const titleFades = createMemo(
() => stringWidth(title()) >= availableTitleWidth() && availableTitleWidth() > FADE_WIDTH,
() => marqueeOverflows(title(), restingTitleWidth()) && availableTitleWidth() > FADE_WIDTH,
)
const foreground = () => {
if (hovered() === tab.sessionID) return theme.text.default
@@ -741,13 +957,28 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
position="relative"
flexDirection="row"
backgroundColor={background()}
onMouseOver={() => setHovered(tab.sessionID)}
onMouseOut={() => setHovered(undefined)}
onMouseDown={() => {
setHovered(tab.sessionID)
onMouseOver={() => marquee.enter(tab.sessionID, title(), restingTitleWidth())}
onMouseOut={() => marquee.leave(tab.sessionID)}
onMouseDown={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) {
setDragging(undefined)
setContextMenu({
x: event.x - (strip?.screenX ?? 0),
y: event.y - (strip?.screenY ?? 0),
sessionID: tab === NEW_SESSION_TAB ? undefined : tab.sessionID,
title: tab === NEW_SESSION_TAB ? undefined : tab.title,
})
event.preventDefault()
event.stopPropagation()
return
}
marquee.enter(tab.sessionID, title(), restingTitleWidth())
setDragging(tab.sessionID)
}}
onMouseUp={release}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
release()
}}
onMouseDrag={(event) => {
if (tab === NEW_SESSION_TAB) return
const slot = slotAt(event.x)
@@ -798,6 +1029,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
fg={closeColor()}
selectable={false}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) 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
@@ -825,11 +1057,23 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
selectable={false}
onMouseOver={() => setAddHovered(true)}
onMouseOut={() => setAddHovered(false)}
onMouseUp={() => tabs.add?.()}
onMouseDown={(event) => {
if (event.button !== RIGHT_MOUSE_BUTTON) return
setContextMenu({ x: event.x - (strip?.screenX ?? 0), y: event.y - (strip?.screenY ?? 0) })
event.preventDefault()
event.stopPropagation()
}}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
tabs.add?.()
}}
>
{" + "}
</text>
</Show>
<Show when={contextMenu()}>
{(state) => <TabContextMenu state={state()} tabs={tabs} onClose={() => setContextMenu(undefined)} />}
</Show>
</box>
)
}
+15 -11
View File
@@ -96,13 +96,15 @@ 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"),
"session.tab.next": keybind("ctrl+tab,<leader>right", "Switch to next open session tab"),
"session.tab.previous": keybind("ctrl+shift+tab,<leader>left", "Switch to previous open session tab"),
"session.tab.history.back": keybind("ctrl+o", "Go back in session tab history"),
"open.menu": keybind("ctrl+o", "Open recent sessions and projects"),
"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"),
"session.tab.history.forward": keybind("ctrl+i", "Go forward in session tab history"),
"session.tab.next_unread": keybind("<leader>down", "Switch to next unread session tab"),
"session.tab.previous_unread": keybind("<leader>up", "Switch to previous unread session tab"),
"session.tab.next_unread": keybind("alt+shift+down", "Switch to next unread session tab"),
"session.tab.previous_unread": keybind("alt+shift+up", "Switch to previous unread session tab"),
"session.tab.close": keybind("<leader>w", "Close current session tab"),
"session.tab.reopen": keybind("ctrl+shift+t", "Reopen last closed session tab"),
"session.timeline": keybind("<leader>g", "Show session timeline"),
"session.fork": keybind("none", "Fork session from message"),
"session.rename": keybind("ctrl+r", "Rename session"),
@@ -139,6 +141,7 @@ export const Definitions = {
"session.tab.select.7": keybind("<leader>7,ctrl+7", "Switch to session tab 7"),
"session.tab.select.8": keybind("<leader>8,ctrl+8", "Switch to session tab 8"),
"session.tab.select.9": keybind("<leader>9,ctrl+9", "Switch to session tab 9"),
"session.tab.select.10": keybind("<leader>0,ctrl+0", "Switch to session tab 10"),
"stash.delete": keybind("ctrl+d", "Delete stash entry"),
"model.dialog.provider": keybind("ctrl+a", "Open provider list from model dialog"),
@@ -164,10 +167,10 @@ export const Definitions = {
"session.half.page.down": keybind("ctrl+alt+d", "Scroll messages down by half page"),
"session.first": keybind("ctrl+g,home,alt+home", "Navigate to first message"),
"session.last": keybind("ctrl+alt+g,end", "Navigate to last message"),
"session.message.next": keybind("alt+down", "Navigate to next message"),
"session.message.previous": keybind("alt+up", "Navigate to previous message"),
"session.message.user.next": keybind("alt+shift+down", "Navigate to next user message"),
"session.message.user.previous": keybind("alt+shift+up", "Navigate to previous user message"),
"session.message.next": keybind("none", "Navigate to next message"),
"session.message.previous": keybind("none", "Navigate to previous message"),
"session.message.user.next": keybind("none", "Navigate to next user message"),
"session.message.user.previous": keybind("none", "Navigate to previous user message"),
"session.messages_last_user": keybind("alt+end", "Navigate to last user message"),
"messages.copy": keybind("<leader>y", "Copy message"),
"session.undo": keybind("<leader>u", "Undo message"),
@@ -177,6 +180,7 @@ export const Definitions = {
"prompt.submit": keybind("none", "Submit prompt"),
"prompt.queue": keybind("alt+return", "Queue prompt"),
"prompt.editor_context.clear": keybind("none", "Clear editor context"),
"prompt.images.view": keybind("<leader>i", "View image attachments"),
"prompt.skills": keybind("none", "Open skill selector"),
"prompt.stash": keybind("none", "Stash prompt"),
"prompt.stash.pop": keybind("none", "Pop stashed prompt"),
@@ -202,8 +206,8 @@ export const Definitions = {
"input.visual.line.end": keybind("alt+e", "Move to end of visual line in input"),
"input.select.visual.line.home": keybind("alt+shift+a", "Select to start of visual line in input"),
"input.select.visual.line.end": keybind("alt+shift+e", "Select to end of visual line in input"),
"input.buffer.home": keybind("home", "Move to start of buffer in input"),
"input.buffer.end": keybind("end", "Move to end of buffer in input"),
"input.buffer.home": keybind("none", "Move to start of buffer in input"),
"input.buffer.end": keybind("none", "Move to end of buffer in input"),
"input.select.buffer.home": keybind("shift+home", "Select to start of buffer in input"),
"input.select.buffer.end": keybind("shift+end", "Select to end of buffer in input"),
"input.delete.line": keybind("ctrl+shift+d", "Delete line in input"),
+2 -3
View File
@@ -1983,6 +1983,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
border={["left"]}
borderColor={delivery() ? theme.border.default : color()}
customBorderChars={SplitBorder.customBorderChars}
backgroundColor={theme.background.default}
>
<SessionImages images={images()} paddingLeft={2} />
<box
@@ -2779,9 +2780,7 @@ function Shell(props: ToolProps) {
const permission = useToolPermission(() => props.part)
const color = createMemo(() => (permission() ? theme.text.feedback.warning.default : theme.text.default))
const shellID = createMemo(() => stringValue(props.metadata.shellID))
const background = createMemo(
() => props.part.state.status === "completed" && props.metadata.status === "running",
)
const background = createMemo(() => Boolean(shellID()) && props.part.state.status !== "running")
const backgroundRunning = createMemo(() => {
const id = shellID()
return Boolean(id && data.shell.get(id))
+26
View File
@@ -40,6 +40,7 @@ export interface DialogSelectProps<T> {
bindings?: readonly KeymapCommand[]
current?: T
focusCurrent?: boolean
sectionNavigation?: boolean
}
type DialogSelectActionBase<T> = {
@@ -327,6 +328,15 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
moveTo(moveSelection(store.selected, { count: flat().length, delta: direction, policy: "wrap" }), true)
}
function moveSection(direction: 1 | -1) {
if (props.locked) return
const sections = grouped().filter(([_, options]) => options.length > 0)
if (sections.length === 0) return
const current = sections.findIndex(([category]) => category === selected()?.category)
const section = sections[(current + direction + sections.length) % sections.length]
moveTo(flat().indexOf(section[1][0]), true)
}
function moveTo(next: number, center = false, preserve = true) {
setFocusedAction(undefined)
setStore("selected", next)
@@ -488,6 +498,22 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
]
: []),
...(props.bindings ?? []),
...(props.sectionNavigation
? [
{
bind: "alt+up",
title: "Previous section",
group: "Dialog",
run: () => moveSection(-1),
},
{
bind: "alt+down",
title: "Next section",
group: "Dialog",
run: () => moveSection(1),
},
]
: []),
],
}
})
+16 -9
View File
@@ -75,6 +75,7 @@ function init() {
stack: [] as {
element: JSX.Element
onClose?: () => void
key?: unknown
}[],
size: "medium" as DialogSize,
centered: false,
@@ -155,7 +156,7 @@ function init() {
})
refocus()
},
replace(input: any, onClose?: () => void) {
replace(input: any, onClose?: () => void, options?: { key?: unknown; size?: DialogSize }) {
if (store.stack.length === 0) {
focus = renderer.currentFocusedRenderable
focus?.blur()
@@ -163,14 +164,17 @@ function init() {
for (const item of store.stack) {
if (item.onClose) item.onClose()
}
setStore("size", "medium")
setStore("centered", false)
setStore("stack", [
{
element: input,
onClose,
},
])
batch(() => {
setStore("size", options?.size ?? "medium")
setStore("centered", false)
setStore("stack", [
{
element: input,
onClose,
key: options?.key,
},
])
})
},
get stack() {
return store.stack
@@ -181,6 +185,9 @@ function init() {
get centered() {
return store.centered
},
get key() {
return store.stack.at(-1)?.key
},
setSize(size: "medium" | "large" | "xlarge") {
setStore("size", size)
},
+10 -2
View File
@@ -1,14 +1,22 @@
import { Locale } from "./locale"
import { stringWidth } from "./string-width"
const GAP = " "
const GAP = " · "
export function marqueeCycleWidth(value: string) {
return stringWidth(value + GAP)
}
export function marqueeOverflows(value: string, width: number) {
return stringWidth(value) > width
}
export function marqueeText(value: string, width: number, offset: number) {
if (width <= 0) return ""
if (stringWidth(value) <= width || offset <= 0) return Locale.takeWidth(value, width)
const loop = value + GAP
const cursor = offset % stringWidth(loop)
const cursor = offset % marqueeCycleWidth(value)
const segments = Locale.graphemes(loop + loop)
const start = segments.reduce(
(state, segment, index) =>
+101 -7
View File
@@ -2,9 +2,9 @@
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import { onMount } from "solid-js"
import { DialogOpen } from "../../../src/component/dialog-open"
import { DialogOpen, DialogOpenKey, loadDialogOpen } from "../../../src/component/dialog-open"
import { ConfigProvider } from "../../../src/config"
import { ClientProvider } from "../../../src/context/client"
import { ClientProvider, useClient } from "../../../src/context/client"
import { DataProvider, useData } from "../../../src/context/data"
import { Keymap } from "../../../src/context/keymap"
import { LocationProvider, useLocation } from "../../../src/context/location"
@@ -131,7 +131,7 @@ test("shows the current project and opens its root", async () => {
}
})
test("preserves a moved project when sessions arrive", async () => {
test("waits for sessions before showing the populated picker", async () => {
let resolveSessions!: (response: Response) => void
const sessions = new Promise<Response>((resolve) => (resolveSessions = resolve))
const fixture = await renderOpen((url) => {
@@ -157,8 +157,8 @@ test("preserves a moved project when sessions arrive", async () => {
})
try {
await fixture.app.waitForFrame((frame) => frame.includes("Second project"))
fixture.app.mockInput.pressArrow("down")
await fixture.app.renderOnce()
expect(fixture.app.captureCharFrame()).not.toContain("Search sessions and projects")
resolveSessions(
json({
@@ -176,7 +176,9 @@ test("preserves a moved project when sessions arrive", async () => {
cursor: {},
}),
)
await fixture.app.waitForFrame((frame) => frame.includes("Recent session"))
await fixture.app.waitForFrame((frame) => frame.includes("Recent session") && frame.includes("Second project"))
fixture.app.mockInput.pressArrow("down")
fixture.app.mockInput.pressArrow("down")
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "home")
@@ -186,6 +188,94 @@ test("preserves a moved project when sessions arrive", async () => {
}
})
test("option arrows jump between sections", async () => {
const handler: FetchHandler = (url) => {
if (url.pathname === "/api/session")
return json({
data: [
{
id: "ses_recent",
projectID: "proj_recent",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 2 },
title: "Recent session",
location: { directory: "/tmp/opencode/recent" },
},
],
cursor: {},
})
if (url.pathname === "/api/project")
return json([
{
id: "proj_recent",
canonical: "/tmp/opencode/recent",
name: "Recent project",
time: { created: 1, updated: 2 },
sandboxes: [],
},
])
return undefined
}
const next = await renderOpen(handler)
try {
await next.app.waitForFrame((frame) => frame.includes("Recent session") && frame.includes("Recent project"))
next.app.mockInput.pressArrow("down", { meta: true })
next.app.mockInput.pressEnter()
await next.app.waitFor(() => next.route.data.type === "home")
expect(next.route.data).toEqual({ type: "home", location: { directory: "/tmp/opencode/recent" } })
} finally {
await next.dispose()
}
const previous = await renderOpen(handler)
try {
await previous.app.waitForFrame((frame) => frame.includes("Recent session") && frame.includes("Recent project"))
previous.app.mockInput.pressArrow("up", { meta: true })
previous.app.mockInput.pressEnter()
await previous.app.waitFor(() => previous.route.data.type === "home")
expect(previous.route.data).toEqual({ type: "home", location: { directory: "/tmp/opencode/recent" } })
} finally {
await previous.dispose()
}
})
test("option arrows stay in the only visible section", 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_effect",
canonical: "/tmp/effect",
name: "Effect",
time: { created: 1, updated: 2 },
sandboxes: [],
},
{
id: "proj_opencode",
canonical: "/tmp/opencode",
name: "OpenCode",
time: { created: 1, updated: 1 },
sandboxes: [],
},
])
})
try {
await fixture.app.waitForFrame((frame) => frame.includes("Effect") && frame.includes("OpenCode"))
await fixture.app.mockInput.typeText("Effect")
await fixture.app.waitForFrame((frame) => frame.includes("Effect") && !frame.includes("OpenCode"))
fixture.app.mockInput.pressArrow("down", { meta: true })
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "home")
expect(fixture.route.data).toEqual({ type: "home", location: { directory: "/tmp/effect" } })
} finally {
await fixture.dispose()
}
})
async function renderOpen(
handler: FetchHandler,
beforeOpen?: (contexts: {
@@ -204,12 +294,16 @@ async function renderOpen(
function Probe() {
const dialog = useDialog()
const client = useClient()
route = useRoute()
location = useLocation()
data = useData()
storage = useStorage()
onMount(
() => void Promise.resolve(beforeOpen?.({ data, location })).then(() => dialog.replace(() => <DialogOpen />)),
() =>
void Promise.all([beforeOpen?.({ data, location }), loadDialogOpen(data, client)]).then(([, sessions]) =>
dialog.replace(() => <DialogOpen sessions={sessions} />, undefined, { key: DialogOpenKey, size: "large" }),
),
)
return null
}
@@ -1,10 +1,6 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { RGBA } from "@opentui/core"
import { testRender } from "@opentui/solid"
import { createSignal } from "solid-js"
import {
TabPulse,
blendTabPulseColor,
completionPulseOpacity,
glowIgnitionLevel,
@@ -12,50 +8,6 @@ import {
} from "../../src/component/tab-pulse"
import { tint } from "../../src/theme/color"
test("a prompt pulse restarts the neutral edge flash while the tab remains busy", async () => {
const background = RGBA.fromHex("#101010")
const flash = RGBA.fromHex("#f0f0f0")
const [promptPulse, setPromptPulse] = createSignal(0)
const app = await testRender(
() => (
<box width={8} height={1} backgroundColor={background}>
<TabPulse
active={true}
promptPulse={promptPulse()}
color={background}
flashColor={flash}
backgroundColor={background}
/>
</box>
),
{ width: 8, height: 1 },
)
const firstBackground = () => app.captureSpans().lines[0]?.spans[0]?.bg
try {
await app.renderOnce()
expect(firstBackground()?.equals(background)).toBeTrue()
setPromptPulse(1)
await Bun.sleep(80)
await app.renderOnce()
expect(firstBackground()?.equals(background)).toBeFalse()
expect(firstBackground()?.r ?? 0).toBeGreaterThan(0.17)
await Bun.sleep(800)
await app.renderOnce()
expect(firstBackground()?.equals(background)).toBeTrue()
setPromptPulse(2)
await Bun.sleep(80)
await app.renderOnce()
expect(firstBackground()?.equals(background)).toBeFalse()
} finally {
app.renderer.destroy()
}
})
test("completion pulse rises quickly and fades over the remaining duration", () => {
expect(completionPulseOpacity(0)).toBe(0)
expect(completionPulseOpacity(0.06)).toBeCloseTo(0.5)
+24
View File
@@ -74,6 +74,25 @@ test("uses command IDs as keybind keys", () => {
).toBe(true)
})
test("preserves current navigation defaults", () => {
const config = resolve({}, { terminalSuspend: true })
expect(config.keybinds.get("open.menu")).toMatchObject([{ key: "ctrl+o" }])
expect(config.keybinds.get("session.tab.next")).toMatchObject([{ key: "ctrl+tab,alt+down" }])
expect(config.keybinds.get("session.tab.previous")).toMatchObject([{ key: "ctrl+shift+tab,alt+up" }])
expect(config.keybinds.get("session.tab.next_unread")).toMatchObject([{ key: "alt+shift+down" }])
expect(config.keybinds.get("session.tab.previous_unread")).toMatchObject([{ key: "alt+shift+up" }])
expect(config.keybinds.get("session.tab.reopen")).toMatchObject([{ key: "ctrl+shift+t" }])
expect(config.keybinds.get("session.tab.select.10")).toMatchObject([{ key: "<leader>0,ctrl+0" }])
expect(config.keybinds.get("session.message.next")).toEqual([])
expect(config.keybinds.get("session.message.previous")).toEqual([])
expect(config.keybinds.get("session.message.user.next")).toEqual([])
expect(config.keybinds.get("session.message.user.previous")).toEqual([])
expect(config.keybinds.get("input.buffer.home")).toEqual([])
expect(config.keybinds.get("input.buffer.end")).toEqual([])
expect(config.keybinds.get("prompt.images.view")).toMatchObject([{ key: "<leader>i" }])
})
test("preserves migrated v1 keybind defaults", () => {
const pairs = [
["app.exit", "app_exit"],
@@ -161,6 +180,11 @@ test("uses ctrl+z for input undo when terminal suspend is unavailable", () => {
expect(overridden.keybinds.get("input.undo")).toMatchObject([{ key: "ctrl+u" }])
})
test("keeps turn token usage inside developer tools", () => {
expect(settings.find((setting) => setting.path.join(".") === "debug.devtools")?.title).toBe("Developer tools")
expect(settings.some((setting) => setting.path.join(".") === "debug.turn_tokens")).toBe(false)
})
test("provides config and its host interface", async () => {
const config = resolve({}, { terminalSuspend: true })
let current = {}
+14 -3
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { marqueeText } from "../../src/util/marquee"
import { marqueeCycleWidth, marqueeOverflows, marqueeText } from "../../src/util/marquee"
import { stringWidth } from "../../src/util/string-width"
describe("marquee text", () => {
@@ -7,11 +7,22 @@ describe("marquee text", () => {
expect(marqueeText("Short", 10, 8)).toBe("Short")
})
test("does not classify an exact fit as overflow", () => {
expect(marqueeOverflows("Exact fit", 9)).toBe(false)
expect(marqueeOverflows("Exact fit", 8)).toBe(true)
})
test("starts clipped and scrolls through a long title", () => {
expect(marqueeText("A long session title", 8, 0)).toBe("A long s")
expect(marqueeText("A long session title", 8, 2)).toBe("long ses")
expect(marqueeText("A long session title", 8, 15)).toBe("title ")
expect(marqueeText("A long session title", 8, 20)).toBe(" A lo")
expect(marqueeText("A long session title", 8, 15)).toBe("title · ")
expect(marqueeText("A long session title", 8, 20)).toBe(" · A lon")
})
test("loops after one spaced dot separator", () => {
const title = "A long session title"
expect(marqueeText(title, 8, marqueeCycleWidth(title) - 3)).toBe(" · A lon")
expect(marqueeText(title, 8, marqueeCycleWidth(title))).toBe("A long s")
})
test("clips wide graphemes to terminal cells", () => {
+4 -3
View File
@@ -94,13 +94,14 @@ const client = OpenCode.make({
const health = await client.health.get()
```
`Service.ensure()` accepts an optional registration file, required version,
service command, and `onStart` callback:
`Service.ensure()` accepts an optional registration file, version, service
command, and `onStart` callback. `version` accepts either an exact value or a
compatibility predicate:
```ts
const endpoint = await Service.ensure({
file: "/var/run/opencode/service.json",
version: "2.0.0",
version: (version) => version.startsWith("2."),
command: ["opencode", "serve", "--service"],
onStart(reason, previousVersion) {
console.log(reason, previousVersion)