Compare commits

...
29 Commits
Author SHA1 Message Date
opencode-agent[bot]andBrendonovich ea3e0dde19 feat(desktop): restore CLI installer menu item (#43906)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-21 15:49:55 +00:00
opencode-agent[bot]andthdxr b731b11184 fix(core): recover unknown finish responses (#43900)
Co-authored-by: thdxr <826656+thdxr@users.noreply.github.com>
2026-08-21 11:18:58 -04:00
Shoubhit Dash 8676dcf705 feat(ai): enable azure responses websocket (#43896) 2026-08-21 20:25:11 +05:30
Shoubhit Dash 2636797c65 feat(ai): enable xai responses websocket (#43889) 2026-08-21 19:58:01 +05:30
opencode-agent[bot]andBrendonovich e673807e39 fix(app): hide built-in plugins (#43890)
Co-authored-by: Brendonovich <Brendonovich@users.noreply.github.com>
2026-08-21 22:18:48 +08:00
Shoubhit Dash 9a3a1732f1 refactor(ai): declare responses websocket capability (#43885) 2026-08-21 19:24:34 +05:30
e03a147b71 test(app): route e2e mocks through HttpApi (#43138)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-08-21 21:41:43 +08:00
opencode-agent[bot] e461fdc2d0 chore: update nix node_modules hashes 2026-08-21 13:40:43 +00:00
Shoubhit Dash e4178886fa fix(core): align websocket network policy (#43875) 2026-08-21 18:49:57 +05:30
Filip 1e6bfaf3d7 feat: /skills command (#43869) 2026-08-21 14:31:17 +02:00
876a4a2586 fix(stats): merge renamed model data (#43812)
Co-authored-by: fwang <83515+fwang@users.noreply.github.com>
Co-authored-by: Frank <frank@anoma.ly>
2026-08-21 08:48:12 +00:00
Brendan Allan 5e77c494c7 fix(app): show folder names in file tree (#43835) 2026-08-21 16:40:07 +08:00
Brendan Allan 9be9dd737c fix(app): restore project menu spacing (#43810) 2026-08-21 15:59:32 +08:00
Brendan Allan 4d22d4e75f fix(desktop): use Effect platform subpath imports (#43817) 2026-08-21 15:32:55 +08:00
Aiden Cline 8b93bc395d feat(core): allow plan mode to write/edit PLAN files exclusively (#43710) 2026-08-21 02:29:50 -05:00
Aiden Cline e756e497c2 fix(ai): merge parallel gemini tool results into one turn (#43814) 2026-08-21 02:27:40 -05:00
Aiden Cline 0d2684b673 fix(ai): align Vertex Anthropic version headers (#43804) 2026-08-21 01:49:30 -05:00
opencode-agent[bot]andAiden 858caa6848 fix(ai): classify network error variants (#43807)
Co-authored-by: Aiden <rekram1-node@users.noreply.github.com>
2026-08-21 01:47:34 -05:00
Brendan Allan 9a89851cea fix(app): preserve timeline bottom after session switch (#43801) 2026-08-21 06:32:25 +00:00
Brendan Allan 876459788f feat(app): warn about V1 servers (#43790) 2026-08-21 06:30:58 +00:00
Aiden Cline b0ab1e2992 fix(core): forward PDF prompt attachments (#43799) 2026-08-21 01:16:05 -05:00
Aiden Cline d158f2cd39 fix(ai): scrub Anthropic tool call IDs (#43796) 2026-08-21 00:54:31 -05:00
Luke Parker 9be3aa92b5 refactor(app): organize product slices (#43789) 2026-08-21 15:48:04 +10:00
Aiden Cline d19f58c5df fix(core): use small model for titles (#43702) 2026-08-21 00:28:27 -05:00
b7343edaf3 fix(core): skip equal snapshot comparisons (#43781)
Co-authored-by: Aiden <rekram1-node@users.noreply.github.com>
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-08-20 23:54:39 -05:00
Aiden Cline b84f5ad2fb fix(core): honor provider retry header (#43773) 2026-08-20 23:54:39 -05:00
Aiden Cline d993f1b8ed fix(ai): ignore unknown Anthropic SSE events (#43767) 2026-08-20 23:54:33 -05:00
Luke Parker f6fcbaad5e refactor(app): establish composer flow (#43768) 2026-08-21 14:23:14 +10:00
Aiden Cline 5c4f6ef1e3 fix(core): continue interrupted model streams (#43757) 2026-08-20 23:22:21 -05:00
584 changed files with 8659 additions and 13354 deletions
+2
View File
@@ -380,6 +380,8 @@
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
"htmlparser2": "8.0.2",
"http-proxy-agent": "7.0.2",
"https-proxy-agent": "7.0.6",
"ignore": "7.0.5",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-PuNZrtSgh5F3KpXSM+bd+rYQuyzwWd+wCOnMJSDS2Z0=",
"aarch64-linux": "sha256-RYy8ZRf59FE/3+gICjvsZv3ekQvn+DTZaT9jefbK+0g=",
"aarch64-darwin": "sha256-1AsDK8xNj3RlzX2efbuEDEwaOLAgjFYaEvk7EkQkh4w=",
"x86_64-darwin": "sha256-8ONeOu9UmM0GRxVeOO3Uhk1yAOuW6R8tqBYswOVEkME="
"x86_64-linux": "sha256-8pRvkbUX2aZhFTFtFuUM6mPqZZhfC4mFd1+BXVMzEJk=",
"aarch64-linux": "sha256-df25TWdjjLKeLZJEfrDpgVaV8ZAZhHWPoV2IPIQ4U2w=",
"aarch64-darwin": "sha256-VjbOx7Zi9eTiPxqpKN3+EQWweBfJHf7y36sGSN1peg0=",
"x86_64-darwin": "sha256-q7nW4AR2OnepnDcPDtYECgcsXI+JRHOCWhPsAX8t7q0="
}
}
-1
View File
@@ -27,7 +27,6 @@
"prepare": "husky",
"random": "echo 'Random script'",
"sso": "aws sso login --sso-session=opencode --no-browser",
"translate:app": "bun run script/translate-app.ts",
"test": "echo 'do not run tests from root' && exit 1"
},
"workspaces": {
@@ -33,6 +33,18 @@ export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1"
export const PATH = "/messages"
export const DEFAULT_MAX_TOKENS = 32_000
const SSE_EVENTS = new Set([
"message",
"message_start",
"message_delta",
"message_stop",
"content_block_start",
"content_block_delta",
"content_block_stop",
"error",
])
export const framing = Framing.sseEvents(SSE_EVENTS)
export type ThinkingInput =
| {
readonly type: "adaptive"
@@ -362,16 +374,18 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
tool: (name) => ({ type: "tool" as const, name }),
})
const scrubToolCallID = (id: string) => id.replace(/[^a-zA-Z0-9_-]/g, "_")
const lowerToolCall = (part: ToolCallPart): AnthropicToolUseBlock => ({
type: "tool_use",
id: part.id,
id: scrubToolCallID(part.id),
name: part.name,
input: part.input,
})
const lowerServerToolCall = (part: ToolCallPart): AnthropicServerToolUseBlock => ({
type: "server_tool_use",
id: part.id,
id: scrubToolCallID(part.id),
name: part.name,
input: part.input,
})
@@ -393,7 +407,7 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult
// Prefer the provider-owned replay payload; fall back to the result value for
// histories constructed directly from provider events.
const payload = part.providerMetadata?.anthropic?.["result"] ?? part.result.value
return { type: wireType, tool_use_id: part.id, content: payload } satisfies AnthropicServerToolResultBlock
return { type: wireType, tool_use_id: scrubToolCallID(part.id), content: payload } satisfies AnthropicServerToolResultBlock
})
const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: MediaPart) {
@@ -575,7 +589,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
return yield* ProviderShared.unsupportedContent("Anthropic Messages", "tool", ["tool-result"])
content.push({
type: "tool_result",
tool_use_id: part.id,
tool_use_id: scrubToolCallID(part.id),
content: yield* lowerToolResultContent(part),
is_error: part.result.type === "error" ? true : undefined,
cache_control: cacheControl(breakpoints, part.cache),
@@ -1039,7 +1053,7 @@ export const route = Route.make({
protocol,
endpoint: Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }),
auth: Auth.none,
framing: Framing.sse,
framing,
headers: () => ({ "anthropic-version": "2023-06-01" }),
})
+6 -1
View File
@@ -379,7 +379,12 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
},
})
}
contents.push({ role: "user", parts })
// Gemini requires every response to a parallel call batch in one user turn,
// so consecutive tool results join the open function-response turn.
const previous = contents.at(-1)
if (previous?.role === "user" && previous.parts.some((item) => "functionResponse" in item))
contents[contents.length - 1] = { role: "user", parts: [...previous.parts, ...parts] }
else contents.push({ role: "user", parts })
}
return contents
@@ -22,6 +22,8 @@ export interface Options {
readonly id: string
readonly name: string
readonly rotateAfterMs?: number
readonly enabled?: (url: string) => boolean
readonly url?: (url: string) => string
readonly headers?: (headers: Headers.Headers) => Headers.Headers
readonly driver?: (input: {
readonly request: Readonly<Record<string, unknown>>
@@ -147,18 +149,19 @@ export const transport = <Body>(options: Options): Transport<Body, Prepared, str
Effect.gen(function* () {
const parts = yield* HttpTransport.jsonRequestParts(input)
const headers = Headers.remove(options.headers?.(parts.headers) ?? parts.headers, "content-length")
const channel = input.webSocket
? yield* Effect.gen(function* () {
const create = yield* message(parts.jsonBody)
const base = driver(options, create.message)
return {
url: yield* WebSocketTransport.toWebSocketUrl(parts.url),
headers,
rotateAfterMs: options.rotateAfterMs,
driver: options.driver?.({ request: create.request, message: create.message, base }) ?? base,
}
})
: undefined
const channel =
input.webSocket && (options.enabled?.(parts.url) ?? true)
? yield* Effect.gen(function* () {
const create = yield* message(parts.jsonBody)
const base = driver(options, create.message)
return {
url: yield* WebSocketTransport.toWebSocketUrl(options.url?.(parts.url) ?? parts.url),
headers,
rotateAfterMs: options.rotateAfterMs,
driver: options.driver?.({ request: create.request, message: create.message, base }) ?? base,
}
})
: undefined
return {
http: {
request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
@@ -11,7 +11,7 @@ import { optionalArray, ProviderShared } from "./shared.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { OpenAIImage } from "./utils/openai-image.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { OpenResponsesChannel } from "./open-responses-channel.js"
import { OpenResponsesChannel, type Options } from "./open-responses-channel.js"
import { OpenAIResponsesChannel } from "./openai-responses-channel.js"
const ADAPTER = "openai-responses"
@@ -247,12 +247,16 @@ const endpoint = Endpoint.path<OpenAIResponsesBody>(PATH, { baseURL: DEFAULT_BAS
const auth = Auth.none
export const httpTransport = HttpTransport.sseJson.with<OpenAIResponsesBody>()
export const transport = OpenResponsesChannel.transport<OpenAIResponsesBody>({
export const channelTransport = (options: Omit<Options, "driver">) =>
OpenResponsesChannel.transport<OpenAIResponsesBody>({
...options,
driver: (input) => OpenAIResponsesChannel.driver({ id: options.id, name: options.name, ...input }),
})
export const transport = channelTransport({
id: ADAPTER,
name: NAME,
rotateAfterMs: WEBSOCKET_ROTATE_AFTER_MS,
headers: (headers) => Headers.set(headers, "openai-beta", headers["openai-beta"] ?? WEBSOCKET_PROTOCOL_HEADER),
driver: (input) => OpenAIResponsesChannel.driver({ id: ADAPTER, name: NAME, ...input }),
})
export const route = Route.make({
+13 -4
View File
@@ -197,19 +197,28 @@ export const errorText = (error: unknown) => {
/**
* `framing` step for Server-Sent Events. Decodes UTF-8, runs the SSE channel
* decoder, and drops empty / `[DONE]` keep-alive events so the protocol event
* schema sees one JSON string per element. The SSE channel emits a
* decoder, optionally filters named events, and drops empty / `[DONE]`
* keep-alive events so the protocol event schema sees one JSON string per
* element. The SSE channel emits a
* `Retry` control event on its error channel; we drop it here (we don't
* implement client-driven retries). Decoder failures become provider output
* errors so the public error channel stays `AIError`.
*/
export const sseFraming = (bytes: Stream.Stream<Uint8Array, AIError>): Stream.Stream<string, AIError> =>
export const sseFraming = (
bytes: Stream.Stream<Uint8Array, AIError>,
events?: ReadonlySet<string>,
): Stream.Stream<string, AIError> =>
bytes.pipe(
Stream.decodeText(),
Stream.pipeThroughChannel(Sse.decode()),
Stream.catchTag("Retry", () => Stream.empty),
Stream.catchTag("SseError", (error) => Stream.fail(eventError("sse", error.message))),
Stream.filter((event) => event.data.length > 0 && event.data !== "[DONE]"),
Stream.filter(
(event) =>
(events === undefined || events.has(event.event)) &&
event.data.length > 0 &&
(event.data !== "[DONE]" || (events !== undefined && event.event !== "message")),
),
Stream.map((event) => event.data),
)
+2
View File
@@ -74,6 +74,7 @@ const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error"
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i
const NETWORK_ERROR_TEXT = /network[-_\s]error/i
export interface ProviderFailure {
readonly message: string
@@ -127,6 +128,7 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
retryAfterMs: input.retryAfterMs,
rateLimit: input.rateLimit,
})
if (NETWORK_ERROR_TEXT.test(text)) return new ProviderInternalReason({ ...common, status: input.status })
if (codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable")))
return new ProviderInternalReason({
...common,
+26
View File
@@ -1,3 +1,4 @@
import { Headers } from "effect/unstable/http"
import { Auth } from "../route/auth.js"
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route as RouteDef, RouteDefaultsInput } from "../route/client.js"
@@ -10,6 +11,7 @@ import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-opt
export const id = ProviderID.make("azure")
const routeAuth = Auth.remove("authorization")
const RESPONSES_WEBSOCKET_ROTATE_AFTER_MS = 55 * 60 * 1000
// Azure needs the customer's resource URL; supply either `resourceName`
// (helper builds the URL) or `baseURL` directly.
@@ -40,6 +42,30 @@ const responsesRoute = OpenAIResponses.route.with({
id: "azure-openai-responses",
provider: id,
auth: routeAuth,
transport: OpenAIResponses.channelTransport({
id: "azure-openai-responses",
name: "Azure OpenAI Responses",
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
enabled: (value) => {
const url = new URL(value)
return (
url.protocol === "https:" &&
url.hostname.endsWith(".openai.azure.com") &&
url.pathname.endsWith("/openai/v1/responses") &&
url.searchParams.get("api-version") === "v1"
)
},
url: (value) => {
const url = new URL(value)
url.searchParams.delete("api-version")
return url.toString()
},
headers: (headers) => {
const apiKey = headers["api-key"]
if (!apiKey) return headers
return Headers.remove(Headers.set(headers, "authorization", `Bearer ${apiKey}`), "api-key")
},
}),
})
const chatRoute = OpenAIChat.route.with({
@@ -4,7 +4,6 @@ import { AnthropicMessages } from "../protocols/anthropic-messages.js"
import { Auth } from "../route/auth.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { Protocol } from "../route/protocol.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { GoogleVertexShared } from "./google-vertex-shared.js"
@@ -14,6 +13,7 @@ export type AnthropicProviderOptionsInput = AnthropicMessages.ProviderOptionsInp
export type AnthropicThinkingInput = AnthropicMessages.ThinkingInput
const VERSION = "vertex-2023-10-16" as const
const HEADER_VERSION = "2023-06-01" as const
export const id = ProviderID.make("google-vertex")
@@ -57,7 +57,8 @@ const route = Route.make({
}),
endpoint: Endpoint.path(({ request }) => `/${request.model.id}:streamRawPredict`),
auth: Auth.none,
framing: Framing.sse,
framing: AnthropicMessages.framing,
headers: () => ({ "anthropic-version": HEADER_VERSION }),
})
export const routes = [route]
+7 -1
View File
@@ -28,13 +28,19 @@ export interface Settings extends ProviderPackage.Settings {
export type { XAIImageOptions } from "../protocols/xai-images.js"
const RESPONSES_WEBSOCKET_ROTATE_AFTER_MS = 24 * 60 * 1000
const responsesRoute = Route.make({
id: "openai-responses",
provider: id,
providerMetadataKey: "xai",
protocol: OpenAIResponses.protocol,
endpoint: Endpoint.path("/responses", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
transport: OpenAIResponses.httpTransport,
transport: OpenAIResponses.channelTransport({
id: "openai-responses",
name: "xAI Responses",
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
}),
defaults: { providerOptions: { store: false } },
})
+6
View File
@@ -24,4 +24,10 @@ export interface Definition<Frame> {
/** Server-Sent Events framing. Used by every JSON-streaming HTTP provider. */
export const sse: Definition<string> = { id: "sse", frame: ProviderShared.sseFraming }
/** SSE framing restricted to protocol-recognized event names. */
export const sseEvents = (events: ReadonlySet<string>): Definition<string> => ({
id: "sse",
frame: (bytes) => ProviderShared.sseFraming(bytes, events),
})
export * as Framing from "./framing.js"
+3
View File
@@ -10,6 +10,9 @@ export const sseEvents = (...chunks: ReadonlyArray<unknown>): string =>
const formatChunk = (chunk: unknown) => `data: ${typeof chunk === "string" ? chunk : JSON.stringify(chunk)}\n\n`
export const sseNamedEvent = (event: string, data: unknown): string =>
`event: ${event}\ndata: ${typeof data === "string" ? data : JSON.stringify(data)}`
/**
* Build an SSE body from already-serialized strings (used when the chunk shape
* itself is part of what's being tested, e.g. malformed chunks).
+8
View File
@@ -82,6 +82,14 @@ describe("provider error classification", () => {
])
})
test("classifies network error text as provider internal", () => {
expect(
["network error", "network-error", "network_error"].map(
(message) => classifyProviderFailure({ message })._tag,
),
).toEqual(["ProviderInternal", "ProviderInternal", "ProviderInternal"])
})
test("classifies nested provider codes when a top-level code is also present", () => {
expect(
[
@@ -8,7 +8,7 @@ import * as AnthropicMessages from "../../src/protocols/anthropic-messages.js"
import { continuationRequest, nativeAnthropicMessagesContinuation } from "../continuation-scenarios.js"
import { it } from "../lib/effect.js"
import { dynamicResponse, fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
import { sseEvents, sseNamedEvent, sseRaw } from "../lib/sse.js"
const model = AnthropicMessages.route
.with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") })
@@ -327,6 +327,29 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("scrubs outbound tool call IDs without truncating them", () =>
Effect.gen(function* () {
const id = `functions.lookup:1|${"x".repeat(64)}`
const scrubbed = `functions_lookup_1_${"x".repeat(64)}`
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([ToolCallPart.make({ id, name: "lookup", input: {} })]),
Message.tool({ id, name: "lookup", result: "done" }),
],
cache: "none",
}),
)
expect(prepared.body.messages).toMatchObject([
{ role: "assistant", content: [{ type: "tool_use", id: scrubbed, name: "lookup", input: {} }] },
{ role: "user", content: [{ type: "tool_result", tool_use_id: scrubbed }] },
])
expect(scrubbed.length).toBeGreaterThan(64)
}),
)
it.effect("batches parallel tool results into one Anthropic user message", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -640,6 +663,59 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("ignores unknown named SSE events", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseRaw(
sseNamedEvent("message_start", {
type: "message_start",
message: { usage: { input_tokens: 5 } },
}),
sseNamedEvent("proxy.stats", "not json"),
sseNamedEvent("content_block_start", {
type: "content_block_start",
index: 0,
content_block: { type: "text", text: "" },
}),
sseNamedEvent("content_block_delta", {
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: "Hello" },
}),
sseNamedEvent("content_block_stop", { type: "content_block_stop", index: 0 }),
sseNamedEvent("message_delta", {
type: "message_delta",
delta: { stop_reason: "end_turn" },
usage: { output_tokens: 1 },
}),
sseNamedEvent("message_stop", { type: "message_stop" }),
sseNamedEvent("proxy.done", "still not json"),
),
),
),
)
expect(response.message.content).toEqual([{ type: "text", text: "Hello" }])
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
}),
)
it.effect("rejects malformed recognized SSE events", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseRaw(sseNamedEvent("message_start", "[DONE]")))),
Effect.flip,
)
expect(error.reason).toMatchObject({
_tag: "InvalidProviderOutput",
message: "Invalid anthropic/anthropic-messages stream event",
})
}),
)
it.effect("maps nullable input tokens and preserves unknown Anthropic usage fields", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
@@ -1340,14 +1416,14 @@ describe("Anthropic Messages route", () => {
Message.assistant([
{
type: "tool-call",
id: "srvtoolu_abc",
id: "srvtoolu.abc",
name: "web_search",
input: { query: "effect 4" },
providerExecuted: true,
},
{
type: "tool-result",
id: "srvtoolu_abc",
id: "srvtoolu.abc",
name: "web_search",
result: { type: "json", value: [{ url: "https://example.com" }] },
providerExecuted: true,
+47
View File
@@ -181,6 +181,53 @@ describe("Gemini route", () => {
}),
)
it.effect("merges parallel tool results into one function-response turn", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } }),
ToolCallPart.make({ id: "call_2", name: "lookup", input: { query: "time" } }),
]),
Message.tool({ id: "call_1", name: "lookup", result: "sunny", resultType: "text" }),
Message.tool({ id: "call_2", name: "lookup", result: "noon", resultType: "text" }),
],
}),
)
expect(prepared.body.contents).toEqual([
{
role: "model",
parts: [
{ functionCall: { id: undefined, name: "lookup", args: { query: "weather" } } },
{ functionCall: { id: undefined, name: "lookup", args: { query: "time" } } },
],
},
{
role: "user",
parts: [
{
functionResponse: {
id: undefined,
name: "lookup",
response: { name: "lookup", content: "sunny" },
},
},
{
functionResponse: {
id: undefined,
name: "lookup",
response: { name: "lookup", content: "noon" },
},
},
],
},
])
}),
)
it.effect("prepares multimodal user input and tool history", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -96,7 +96,7 @@ describe("Google Vertex providers", () => {
"https://aiplatform.eu.rep.googleapis.com/v1/projects/vertex-project/locations/eu/publishers/anthropic/models/claude-sonnet-4-6:streamRawPredict",
)
expect(request.headers.get("authorization")).toBe("Bearer vertex-token")
expect(request.headers.get("anthropic-version")).toBeNull()
expect(request.headers.get("anthropic-version")).toBe("2023-06-01")
const body = yield* Effect.promise(() => request.json())
expect(body).toMatchObject({
anthropic_version: "vertex-2023-10-16",
@@ -691,6 +691,134 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("builds xAI WebSocket requests without OpenAI handshake headers", () =>
Effect.gen(function* () {
const deps = Layer.succeed(
RequestExecutor.Service,
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
)
const response = yield* LLMClient.generate(LLM.request({ model: xaiModel, prompt: "Say hello." }), {
webSocket: {
execute: (exchange) =>
Effect.gen(function* () {
expect(exchange.connect.url).toBe("wss://api.x.ai/v1/responses")
expect(exchange.connect.rotateAfterMs).toBe(24 * 60 * 1000)
expect(exchange.connect.headers.authorization).toBe("Bearer test")
expect(exchange.connect.headers["openai-beta"]).toBeUndefined()
expect(JSON.parse((yield* exchange.driver.create(undefined)).message)).toMatchObject({
type: "response.create",
model: "grok-4.5",
store: false,
})
return {
frames: Stream.make(
JSON.stringify({ type: "response.created", response: { id: "resp_xai" } }),
JSON.stringify({ type: "response.completed", response: { id: "resp_xai" } }),
),
complete: Effect.void,
}
}),
},
}).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))))
expect(response.finishReason.normalized).toBe("stop")
}),
)
it.effect("builds Azure WebSocket requests with v1 URLs and bearer auth", () =>
Effect.gen(function* () {
const deps = Layer.succeed(
RequestExecutor.Service,
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
)
const cases = [
{
model: Azure.configure({ resourceName: "opencode-test", apiKey: "azure-key" }).responses("deployment"),
authorization: "Bearer azure-key",
},
{
model: Azure.configure({ resourceName: "opencode-test", auth: Auth.bearer("entra-token") }).responses(
"deployment",
),
authorization: "Bearer entra-token",
},
]
yield* Effect.forEach(cases, (item) =>
LLMClient.generate(LLM.request({ model: item.model, prompt: "Say hello." }), {
webSocket: {
execute: (exchange) =>
Effect.gen(function* () {
expect(exchange.connect.url).toBe("wss://opencode-test.openai.azure.com/openai/v1/responses")
expect(exchange.connect.rotateAfterMs).toBe(55 * 60 * 1000)
expect(exchange.connect.headers.authorization).toBe(item.authorization)
expect(exchange.connect.headers["api-key"]).toBeUndefined()
expect(exchange.connect.headers["openai-beta"]).toBeUndefined()
expect(JSON.parse((yield* exchange.driver.create(undefined)).message)).toMatchObject({
type: "response.create",
model: "deployment",
store: false,
})
return {
frames: Stream.make(
JSON.stringify({ type: "response.created", response: { id: "resp_azure" } }),
JSON.stringify({ type: "response.completed", response: { id: "resp_azure" } }),
),
complete: Effect.void,
}
}),
},
}).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps)))),
)
}),
)
it.effect("keeps unsupported Azure endpoints and API versions on HTTP", () =>
Effect.gen(function* () {
const cases = [
{
model: Azure.configure({
resourceName: "opencode-test",
apiKey: "azure-key",
apiVersion: "2025-04-01-preview",
}).responses("deployment"),
url: "https://opencode-test.openai.azure.com/openai/v1/responses?api-version=2025-04-01-preview",
},
{
model: Azure.configure({
resourceName: "opencode-test",
apiKey: "azure-key",
useDeploymentBasedUrls: true,
}).responses("deployment"),
url: "https://opencode-test.openai.azure.com/openai/deployments/deployment/responses?api-version=v1",
},
{
model: Azure.configure({ baseURL: "https://gateway.example/azure", apiKey: "azure-key" }).responses(
"deployment",
),
url: "https://gateway.example/azure/responses",
},
]
yield* Effect.forEach(cases, (item) =>
LLMClient.generate(LLM.request({ model: item.model, prompt: "Say hello." }), {
webSocket: { execute: () => Effect.die("unexpected WebSocket request") },
}).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
expect(input.request.url).toBe(item.url)
return input.respond(sseEvents({ type: "response.completed", response: {} }), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
),
)
}),
)
it.effect("uses exactly one HTTP request when no WebSocket executor is supplied", () =>
Effect.gen(function* () {
const attempts = yield* Ref.make(0)
@@ -11,7 +11,7 @@ import {
} from "./timeline-test-helpers"
import { waitForStableTimeline } from "./session-tab-switch-probe"
const contentSelector = '[data-message-id], [data-component="prompt-input"]'
const contentSelector = '[data-message-id], [data-component="composer-editor"]'
const draftID = "draft_first_navigation"
benchmark.describe("performance: first navigation paint", () => {
@@ -41,11 +41,11 @@ benchmark.describe("performance: first navigation paint", () => {
href,
destinationPath: href,
sourceSelector: messageSelector(fixture.expected.sourceMessageIDs.at(-1)!),
destinationSelector: '[data-component="prompt-input"]',
destinationSelector: '[data-component="composer-editor"]',
contentSelector,
navigate: async () => {
await page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first().click()
await expect(page.locator('[data-component="prompt-input"]')).toBeVisible()
await expect(page.locator('[data-component="composer-editor"]')).toBeVisible()
},
})
report(result)
@@ -5,9 +5,11 @@ import { mockOpenCodeServer } from "../../utils/mock-server"
test("applies message latency after a list response gate is released", async () => {
const events: string[] = []
const gate = Promise.withResolvers<void>()
const started = Promise.withResolvers<void>()
let handler: ((route: Route) => Promise<void>) | undefined
const page = {
addInitScript: () => Promise.resolve(),
on: () => page,
route: (_url: string, callback: (route: Route) => Promise<void>) => {
handler = callback
return Promise.resolve()
@@ -21,6 +23,7 @@ test("applies message latency after a list response gate is released", async ()
messageDelay: 25,
beforeMessagesResponse: () => {
events.push("before")
started.resolve()
return gate.promise
},
onMessages: (request) => events.push(request.phase),
@@ -31,12 +34,18 @@ test("applies message latency after a list response gate is released", async ()
})
const response = handler!({
request: () => ({ url: () => "http://127.0.0.1:4096/api/session/session/message" }),
request: () => ({
url: () => "http://127.0.0.1:4096/api/session/session/message",
method: () => "GET",
headers: () => ({}),
postDataBuffer: () => null,
}),
fulfill: () => {
events.push("fulfill")
return Promise.resolve()
},
} as unknown as Route)
await started.promise
expect(events).toEqual(["start", "before"])
const released = performance.now()
@@ -45,3 +54,42 @@ test("applies message latency after a list response gate is released", async ()
expect(performance.now() - released).toBeGreaterThanOrEqual(20)
expect(events).toEqual(["start", "before", "page", "end", "fulfill"])
})
test("routes requests through the HttpApi contract", async () => {
const connected = Promise.withResolvers<{ integrationID: string; body: unknown }>()
let handler: ((route: Route) => Promise<void>) | undefined
const page = {
addInitScript: () => Promise.resolve(),
on: () => page,
route: (_url: string, callback: (route: Route) => Promise<void>) => {
handler = callback
return Promise.resolve()
},
} as unknown as Page
await mockOpenCodeServer(page, {
provider: {},
directory: "C:/OpenCode",
project: {},
sessions: [],
pageMessages: () => ({ items: [] }),
onConnectKey: connected.resolve,
})
const body = Buffer.from(JSON.stringify({ key: "secret" }))
let status: number | undefined
await handler!({
request: () => ({
url: () => "http://127.0.0.1:4096/api/integration/anthropic/connect/key",
method: () => "POST",
headers: () => ({ "content-type": "application/json" }),
postDataBuffer: () => body,
}),
fulfill: (response: Parameters<Route["fulfill"]>[0]) => {
status = response?.status
return Promise.resolve()
},
} as unknown as Route)
expect(status).toBe(204)
expect(await connected.promise).toEqual({ integrationID: "anthropic", body: { key: "secret" } })
})
@@ -3,9 +3,9 @@ import { base64Encode } from "@opencode-ai/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const directory = "C:/OpenCode/PromptInputV2Editing"
const projectID = "proj_prompt_input_v2_editing"
const sessionID = "ses_prompt_input_v2_editing"
const directory = "C:/OpenCode/ComposerEditing"
const projectID = "proj_composer_editing"
const sessionID = "ses_composer_editing"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test("preserves the draft when a populated command menu triggers a built-in", async ({ page }) => {
@@ -15,7 +15,7 @@ test("preserves the draft when a populated command menu triggers a built-in", as
id: projectID,
worktree: directory,
vcs: "git",
name: "prompt-input-v2-editing",
name: "composer-editing",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
@@ -23,10 +23,10 @@ test("preserves the draft when a populated command menu triggers a built-in", as
sessions: [
{
id: sessionID,
slug: "prompt-input-v2-editing",
slug: "composer-editing",
projectID,
directory,
title: "Prompt input V2 editing",
title: "Composer editing",
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
@@ -34,11 +34,11 @@ test("preserves the draft when a populated command menu triggers a built-in", as
pageMessages: () => ({ items: [] }),
})
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
const composer = page.locator('[data-component="prompt-input-v2"]')
const input = composer.locator('[data-component="prompt-input"]')
await expect.poll(() => input.evaluate((element) => getComputedStyle(element, "::before").content)).toBe(
`"${String.fromCodePoint(0x200b)}"`,
)
const composer = page.locator('[data-component="composer"]')
const input = composer.locator('[data-component="composer-editor"]')
await expect
.poll(() => input.evaluate((element) => getComputedStyle(element, "::before").content))
.toBe(`"${String.fromCodePoint(0x200b)}"`)
await expectAppVisible(composer)
await input.fill("keep me")
@@ -46,7 +46,7 @@ test("matches the rounded panel corners to the dark new-session background", asy
)
await page.goto(`/new-session?draftId=${draftID}`)
await expectAppVisible(page.locator('[data-component="prompt-input"]'))
await expectAppVisible(page.locator('[data-component="composer-editor"]'))
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
const panel = page.locator('main div[class*="rounded-[10px]"][class*="overflow-hidden"]')
await expect(panel).toHaveCount(1)
@@ -60,7 +60,7 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
if (path) return []
return [
{
name: "frontend",
name: "",
path: "frontend\\",
absolute: `${directory}/frontend`,
type: "directory" as const,
@@ -116,6 +116,7 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
const frontendRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend"]')
await expect(frontendRow).toBeVisible()
await expect(frontendRow.getByText("frontend", { exact: true })).toBeVisible()
await expect(frontendRow).toHaveAttribute("aria-expanded", "false")
await frontendRow.click()
await expect(frontendRow).toHaveAttribute("aria-expanded", "true")
@@ -8,7 +8,7 @@ const projectID = "proj_prompt_thinking_level_regression"
const sessionID = "ses_prompt_thinking_level_regression"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test("shows the V2 thinking level control while relevant", async ({ page }) => {
test("shows the thinking level control while relevant", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
@@ -51,8 +51,8 @@ test("shows the V2 thinking level control while relevant", async ({ page }) => {
pageMessages: () => ({ items: [] }),
})
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
const composer = page.locator('[data-component="prompt-input-v2"]')
const input = composer.locator('[data-component="prompt-input"]')
const composer = page.locator('[data-component="composer"]')
const input = composer.locator('[data-component="composer-editor"]')
const control = composer.getByRole("button", { name: "Choose model variant" })
await expectAppVisible(composer)
@@ -22,7 +22,7 @@ test("session settings use the remote server context", async ({ page }) => {
await expect(page.getByRole("heading", { name: sessionB.title, exact: true })).toBeVisible()
await page.keyboard.press("Control+,")
const dialog = page.locator(".settings-v2-dialog")
const dialog = page.locator(".settings-dialog")
const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]')
const input = autoAccept.getByRole("switch")
await expect(autoAccept).toBeVisible()
@@ -63,7 +63,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
await expect(page.getByRole("heading", { name: sessionA.title, exact: true })).toBeVisible()
await page.keyboard.press("Control+,")
const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]')
const autoAccept = page.locator(".settings-dialog").locator('[data-action="settings-auto-accept-permissions"]')
await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(autoAccept.getByRole("switch")).toBeChecked()
await expect
@@ -224,6 +224,8 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`))
return json(route, { data: [], cursor: {} })
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/inbox`))
return json(route, { data: [] })
if (url.pathname === "/api/location") return json(route, { directory })
if (url.pathname === "/api/vcs")
return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
@@ -0,0 +1,79 @@
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { base64Encode } from "@opencode-ai/util/encode"
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/SessionMessageRevert"
const projectID = "proj_session_message_revert"
const sessionID = "ses_session_message_revert"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const messages = [
{ id: "msg_first", type: "user", text: "First prompt", time: { created: 1 } },
{
id: "msg_first_reply",
type: "assistant",
agent: "build",
model: { id: "test", providerID: "opencode" },
content: [{ type: "text", text: "First reply" }],
time: { created: 2, completed: 3 },
},
{ id: "msg_second", type: "user", text: "Second prompt", time: { created: 4 } },
] satisfies SessionMessageInfo[]
test("reverts directly to the selected user message", async ({ page }) => {
const staged: { sessionID: string; messageID: string }[] = []
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
canonical: directory,
vcs: "git",
name: "session-message-revert",
time: { created: 1, updated: 1 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", variants: {}, limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
},
sessions: [
{
id: sessionID,
slug: "session-message-revert",
projectID,
directory,
title: "Session message revert",
agent: "build",
model: { id: "test", providerID: "opencode" },
version: "dev",
time: { created: 1, updated: 4 },
},
],
pageMessages: () => ({ items: messages }),
onRevertStage: (input) => staged.push(input),
})
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, "Session message revert")
const message = page.locator('[data-message-id="msg_second"]')
await message.hover()
const response = page.waitForResponse(
(response) =>
response.request().method() === "POST" &&
new URL(response.url()).pathname === `/api/session/${sessionID}/revert/stage`,
)
await message.getByRole("button", { name: "Revert message" }).click()
expect((await response).ok()).toBe(true)
await expect(page.getByRole("textbox", { name: "Prompt" })).toHaveText("Second prompt")
expect(staged).toEqual([{ sessionID, messageID: "msg_second" }])
})
@@ -120,7 +120,7 @@ test("restores the draft caret before typing after a request dock closes", async
await transport.waitForConnection()
await expectSessionTitle(page, title)
const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]')
const editor = page.locator('[data-component="composer-editor"][contenteditable="true"]')
const draft = "keep the caret at the end"
await editor.fill(draft)
await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())))
@@ -103,7 +103,7 @@ test("labels completed searches with result counts", async ({ page }) => {
await expect(rows.nth(1)).toContainText("(12 matches)")
})
test("labels V2 read tools from their path input", async ({ page }) => {
test("labels read tools from their path input", async ({ page }) => {
const id = "prt_read_path"
await setupTimeline(page, {
messages: [userMessage(), assistantMessage([toolPart(id, "read", "completed", { path: "src/a.ts" })])],
@@ -114,7 +114,7 @@ test("labels V2 read tools from their path input", async ({ page }) => {
await expect(group.locator('[data-slot="basic-tool-tool-subtitle"]')).toHaveText("a.ts")
})
test("labels V2 skill tools from IDs and result metadata", async ({ page }) => {
test("labels skill tools from IDs and result metadata", async ({ page }) => {
const pending = "prt_skill_id"
const completed = "prt_skill_name"
await setupTimeline(page, {
@@ -131,9 +131,10 @@ test("labels V2 skill tools from IDs and result metadata", async ({ page }) => {
"aria-label",
"sample-skill",
)
await expect(
page.locator(`[data-timeline-part-id="${completed}"] [data-component="text-shimmer"]`),
).toHaveAttribute("aria-label", "OpenCode")
await expect(page.locator(`[data-timeline-part-id="${completed}"] [data-component="text-shimmer"]`)).toHaveAttribute(
"aria-label",
"OpenCode",
)
for (const id of [pending, completed]) {
const skill = page.locator(`[data-timeline-part-id="${id}"]`)
await expect(skill.locator('[data-slot="skill-tool-label"]')).toHaveText("Skill")
@@ -152,8 +153,7 @@ function errorInput(tool: string) {
if (tool === "patch") return { patchText: "Update src/error.ts" }
if (tool === "webfetch") return { url: "https://example.com" }
if (tool === "websearch") return { query: "failure" }
if (tool === "subagent")
return { description: "Fail subagent", agent: "explore", prompt: "Inspect the failure." }
if (tool === "subagent") return { description: "Fail subagent", agent: "explore", prompt: "Inspect the failure." }
if (tool === "skill") return { name: "failure" }
return { target: "failure" }
}
@@ -87,7 +87,7 @@ test("reconnects after a stream error", async ({ page }) => {
expect((await timeline.transport.connections())[0]?.endedBy).toBe("error")
})
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
test("does not request replay when reconnecting the volatile event stream", async ({ page }) => {
const timeline = await setupTimeline(page, { eventRetry: 10 })
const events = partUpdated(textPart("prt_transport_id", "event with id"))
const first = (
@@ -77,7 +77,7 @@ test("routes typing to the composer unless the open terminal is focused", async
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, "Terminal composer focus")
const composer = page.locator('[data-component="prompt-input"]')
const composer = page.locator('[data-component="composer-editor"]')
const terminal = page.locator('[data-component="terminal"]')
await page.keyboard.press("Control+Backquote")
await expect(terminal).toBeVisible()
@@ -116,7 +116,7 @@ test("keeps composer focus when a cached terminal finishes mounting", async ({ p
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`, { waitUntil: "commit" })
await expectSessionTitle(page, "Terminal composer focus")
const composer = page.locator('[data-component="prompt-input"]')
const composer = page.locator('[data-component="composer-editor"]')
const terminal = page.locator('[data-component="terminal"]')
await expect(terminal).toBeVisible()
expect(created.count).toBe(0)
@@ -142,7 +142,7 @@ test("keeps newer composer focus while an explicit terminal open finishes", asyn
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, "Terminal composer focus")
const composer = page.locator('[data-component="prompt-input"]')
const composer = page.locator('[data-component="composer-editor"]')
const terminal = page.locator('[data-component="terminal"]')
await page.keyboard.press("Control+Backquote")
await expect(terminal).toBeVisible()
@@ -187,7 +187,7 @@ test("focuses a terminal created from the new-terminal button", async ({ page })
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, "Terminal composer focus")
const composer = page.locator('[data-component="prompt-input"]')
const composer = page.locator('[data-component="composer-editor"]')
const terminal = page.locator('[data-component="terminal"]')
await page.keyboard.press("Control+Backquote")
await expect(terminal.locator("textarea")).toHaveCount(1)
@@ -78,9 +78,9 @@ test("creates a session in a new project and selects its model", async ({ page }
await selectFolder.click()
await page.locator('[data-action="home-new-session"]').click()
await expectAppVisible(page.locator('[data-component="prompt-input-v2"]'))
await expectAppVisible(page.locator('[data-component="composer"]'))
const modelControl = page.locator('[data-action="prompt-model"]')
const modelControl = page.locator('[data-action="composer-model"]')
await modelControl.click()
await expect(page.locator('[data-option-key="opencode:free-model"]')).toBeVisible()
const goModel = page.locator('[data-option-key="opencode-go:go-model-1"]')
+249
View File
@@ -0,0 +1,249 @@
import { Schema, SchemaGetter } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
const Json = Schema.Json.pipe(
Schema.decodeTo(Schema.Unknown, {
decode: SchemaGetter.passthrough(),
encode: SchemaGetter.transform(jsonValue),
}),
HttpApiSchema.asJson(),
)
const JsonPayload = Schema.Unknown.pipe(HttpApiSchema.asJson())
const Query = Schema.Struct({
directory: Schema.optional(Schema.String),
parentID: Schema.optional(Schema.String),
search: Schema.optional(Schema.String),
order: Schema.optional(Schema.String),
cursor: Schema.optional(Schema.String),
limit: Schema.optional(Schema.NumberFromString),
path: Schema.optional(Schema.String),
query: Schema.optional(Schema.String),
type: Schema.optional(Schema.String),
})
const SessionParams = { sessionID: Schema.String }
const NoContent = HttpApiSchema.NoContent
export class MockNotFound extends Schema.TaggedError<MockNotFound>()("MockNotFound", {
message: Schema.String,
}) {}
export class MockBadRequest extends Schema.TaggedError<MockBadRequest>()("MockBadRequest", {
message: Schema.String,
}) {}
const Group = HttpApiGroup.make("mock")
.add(HttpApiEndpoint.get("health", "/api/health", { success: Json }))
.add(
HttpApiEndpoint.get("event", "/api/event", {
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })),
}),
)
.add(HttpApiEndpoint.get("reference", "/api/reference", { success: Json }))
.add(HttpApiEndpoint.get("agent", "/api/agent", { success: Json }))
.add(HttpApiEndpoint.get("provider", "/api/provider", { success: Json }))
.add(HttpApiEndpoint.get("model", "/api/model", { success: Json }))
.add(HttpApiEndpoint.get("modelDefault", "/api/model/default", { success: Json }))
.add(HttpApiEndpoint.get("integrationList", "/api/integration", { success: Json }))
.add(
HttpApiEndpoint.get("integrationGet", "/api/integration/:integrationID", {
params: { integrationID: Schema.String },
success: Json,
}),
)
.add(
HttpApiEndpoint.post("integrationConnect", "/api/integration/:integrationID/connect/key", {
params: { integrationID: Schema.String },
payload: JsonPayload,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.delete("credentialRemove", "/api/credential/:credentialID", {
params: { credentialID: Schema.String },
success: NoContent,
}),
)
.add(HttpApiEndpoint.get("command", "/api/command", { success: Json }))
.add(HttpApiEndpoint.get("skill", "/api/skill", { success: Json }))
.add(HttpApiEndpoint.get("plugin", "/api/plugin", { success: Json }))
.add(HttpApiEndpoint.get("mcp", "/api/mcp", { success: Json }))
.add(HttpApiEndpoint.get("mcpResource", "/api/mcp/resource", { success: Json }))
.add(HttpApiEndpoint.get("projectList", "/api/project", { success: Json }))
.add(HttpApiEndpoint.get("projectCurrent", "/api/project/current", { success: Json }))
.add(
HttpApiEndpoint.get("worktreeList", "/api/worktree/:projectID", {
params: { projectID: Schema.String },
success: Json,
}),
)
.add(
HttpApiEndpoint.post("worktreeCreate", "/api/worktree/:projectID", {
params: { projectID: Schema.String },
payload: JsonPayload,
success: Json,
}),
)
.add(
HttpApiEndpoint.delete("worktreeRemove", "/api/worktree/:projectID", {
params: { projectID: Schema.String },
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("worktreeRefresh", "/api/worktree/:projectID/refresh", {
params: { projectID: Schema.String },
success: NoContent,
}),
)
.add(HttpApiEndpoint.get("location", "/api/location", { success: Json }))
.add(HttpApiEndpoint.get("permissionRequests", "/api/permission/request", { success: Json }))
.add(HttpApiEndpoint.get("formRequests", "/api/form/request", { success: Json }))
.add(HttpApiEndpoint.get("vcs", "/api/vcs", { success: Json }))
.add(HttpApiEndpoint.get("vcsStatus", "/api/vcs/status", { success: Json }))
.add(HttpApiEndpoint.get("vcsDiff", "/api/vcs/diff", { success: Json }))
.add(HttpApiEndpoint.get("fsList", "/api/fs/list", { query: Query, success: Json }))
.add(
HttpApiEndpoint.get("fsRead", "/api/fs/read/*", {
success: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),
}),
)
.add(HttpApiEndpoint.get("fsFind", "/api/fs/find", { query: Query, success: Json }))
.add(HttpApiEndpoint.get("shell", "/api/shell", { success: Json }))
.add(
HttpApiEndpoint.get("ptyConnectToken", "/api/pty/:ptyID/connect-token", {
params: { ptyID: Schema.String },
success: Json,
}),
)
.add(
HttpApiEndpoint.get("sessionList", "/api/session", {
query: Query,
success: Json,
error: MockBadRequest.pipe(HttpApiSchema.status(400)),
}),
)
.add(HttpApiEndpoint.post("sessionCreate", "/api/session", { payload: JsonPayload, success: Json }))
.add(HttpApiEndpoint.get("sessionActive", "/api/session/active", { success: Json }))
.add(
HttpApiEndpoint.get("sessionGet", "/api/session/:sessionID", {
params: SessionParams,
success: Json,
error: MockNotFound.pipe(HttpApiSchema.status(404)),
}),
)
.add(
HttpApiEndpoint.delete("sessionRemove", "/api/session/:sessionID", {
params: SessionParams,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionShell", "/api/session/:sessionID/shell", {
params: SessionParams,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.get("sessionForm", "/api/session/:sessionID/form", {
params: SessionParams,
success: Json,
}),
)
.add(
HttpApiEndpoint.post("sessionFormReply", "/api/session/:sessionID/form/:formID/reply", {
params: { ...SessionParams, formID: Schema.String },
payload: JsonPayload,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionFormCancel", "/api/session/:sessionID/form/:formID/cancel", {
params: { ...SessionParams, formID: Schema.String },
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionBackground", "/api/session/:sessionID/background", {
params: SessionParams,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.get("sessionInbox", "/api/session/:sessionID/inbox", {
params: SessionParams,
success: Json,
}),
)
.add(
HttpApiEndpoint.get("sessionPermission", "/api/session/:sessionID/permission", {
params: SessionParams,
success: Json,
}),
)
.add(
HttpApiEndpoint.post("sessionPermissionReply", "/api/session/:sessionID/permission/:permissionID/reply", {
params: { ...SessionParams, permissionID: Schema.String },
payload: JsonPayload,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionRename", "/api/session/:sessionID/rename", {
params: SessionParams,
payload: JsonPayload,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionInterrupt", "/api/session/:sessionID/interrupt", {
params: SessionParams,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionRevertStage", "/api/session/:sessionID/revert/stage", {
params: SessionParams,
payload: JsonPayload,
success: Json,
error: MockBadRequest.pipe(HttpApiSchema.status(400)),
}),
)
.add(
HttpApiEndpoint.post("sessionRevertClear", "/api/session/:sessionID/revert/clear", {
params: SessionParams,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionRevertCommit", "/api/session/:sessionID/revert/commit", {
params: SessionParams,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.get("messageGet", "/api/session/:sessionID/message/:messageID", {
params: { ...SessionParams, messageID: Schema.String },
success: Json,
error: MockNotFound.pipe(HttpApiSchema.status(404)),
}),
)
.add(
HttpApiEndpoint.get("messageList", "/api/session/:sessionID/message", {
params: SessionParams,
query: Query,
success: Json,
error: MockBadRequest.pipe(HttpApiSchema.status(400)),
}),
)
export const MockApi = HttpApi.make("mock").add(Group)
function jsonValue(value: unknown): Schema.Json {
if (value === null || typeof value === "string" || typeof value === "boolean") return value
if (typeof value === "number") return Number.isFinite(value) ? value : null
if (Array.isArray(value)) return value.map(jsonValue)
if (!value || typeof value !== "object") return null
return Object.fromEntries(
Object.entries(value).flatMap(([key, item]) => (item === undefined ? [] : [[key, jsonValue(item)]])),
)
}
+320 -313
View File
@@ -1,5 +1,9 @@
import type { Page, Route } from "@playwright/test"
import type { Page } from "@playwright/test"
import type { JsonValue, OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise"
import { Duration, Effect, Layer } from "effect"
import { HttpRouter, HttpServer, HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { MockApi, MockBadRequest, MockNotFound } from "./mock-api"
export interface MockServerConfig {
provider: unknown | (() => unknown)
@@ -22,6 +26,7 @@ export interface MockServerConfig {
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
message?: (sessionID: string, messageID: string) => SessionMessageInfo | undefined
onMessage?: (input: { sessionID: string; messageID: string }) => void
onRevertStage?: (input: { sessionID: string; messageID: string }) => void
events?: () => OpenCodeEvent[]
eventRetry?: number
permissions?: unknown[] | (() => unknown[])
@@ -38,9 +43,8 @@ type MockStreamWindow = Window & {
}
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const cursors = new Map<string, string>()
const state = { cursors: new Map<string, string>(), nextCursor: 0 }
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
let nextCursor = 0
await page.addInitScript(
({ server, retry }) => {
@@ -127,307 +131,331 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
}, 50)
page.on("close", () => clearInterval(timer))
}
const transport = HttpRouter.toWebHandler(
HttpApiBuilder.layer(MockApi).pipe(
Layer.provide(mockHandlers(config, state)),
Layer.provide(HttpServer.layerServices),
),
{ disableLogger: true },
)
page.on("close", () => void transport.dispose())
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
const appPort = new URL(
process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${process.env.PLAYWRIGHT_PORT ?? "3000"}`,
).port
if (url.origin !== server && url.port !== appPort) return route.fallback()
const path = url.pathname
if (path === "/api/event") {
const events = config.events?.()
return sse(
route,
[{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events ?? [])],
config.eventRetry,
)
if (route.request().method() === "OPTIONS") {
return route.fulfill({ status: 204, headers: corsHeaders })
}
if (path === "/api/health") return json(route, { healthy: true, version: "2.0.0", pid: 1 })
if (path === "/api/reference")
return json(route, {
location: {
directory: config.directory,
project: {
const body = route.request().postDataBuffer()
const response = await transport.handler(
new Request(url, {
method: route.request().method(),
headers: route.request().headers(),
body: body ? Uint8Array.from(body) : undefined,
}),
)
if (response.status === 404 && url.origin !== server) return route.fallback()
return route.fulfill({
status: response.status,
headers: { ...Object.fromEntries(response.headers), ...corsHeaders },
body: Buffer.from(await response.arrayBuffer()),
})
})
}
const corsHeaders = {
"access-control-allow-origin": "*",
"access-control-allow-headers": "*",
"access-control-allow-methods": "GET, POST, PUT, DELETE, OPTIONS",
"access-control-expose-headers": "x-next-cursor",
}
function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, string>; nextCursor: number }) {
const noContent = Effect.succeed(HttpApiSchema.NoContent.make())
const delay = config.messageDelay === undefined ? Effect.void : Effect.sleep(Duration.millis(config.messageDelay))
return HttpApiBuilder.group(MockApi, "mock", (handlers) =>
handlers
.handleRaw("event", () => {
const events = config.events?.()
const retry = config.eventRetry === undefined ? "" : `retry: ${config.eventRetry}\n\n`
const body = [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events ?? [])]
.map((event) => `data: ${JSON.stringify(event)}\n\n`)
.join("")
return Effect.succeed(HttpServerResponse.text(retry + body, { contentType: "text/event-stream" }))
})
.handleRaw("fsRead", (ctx) =>
Effect.gen(function* () {
const path = decodeURIComponent(new URL(ctx.request.url, "http://localhost").pathname.slice(13))
const value = yield* Effect.promise(() => Promise.resolve(config.fileContent?.(path)))
const content =
value && typeof value === "object" && "content" in value ? String(value.content) : String(value ?? "")
return HttpServerResponse.uint8Array(new TextEncoder().encode(content))
}),
)
.handleAll({
health: () => Effect.succeed({ healthy: true, version: "2.0.0", pid: 1 }),
reference: () =>
Effect.succeed({
location: {
directory: config.directory,
project: {
id: (config.project as { id?: string }).id,
directory: config.directory,
canonical: config.directory,
},
},
data: [],
}),
agent: () =>
Effect.succeed({
location: location(config),
data: [
{
id: "build",
name: "Build",
mode: "primary",
hidden: false,
request: { settings: {}, headers: {}, body: {} },
permissions: [],
},
],
}),
provider: () => Effect.succeed({ location: location(config), data: currentProviders(providerConfig(config)) }),
model: () => Effect.succeed({ location: location(config), data: currentModels(providerConfig(config)) }),
modelDefault: () =>
Effect.succeed({ location: location(config), data: currentDefaultModel(providerConfig(config)) }),
integrationList: () => Effect.succeed({ location: location(config), data: [] }),
integrationGet: (ctx) =>
Effect.succeed({
location: location(config),
data: {
id: ctx.params.integrationID,
name: ctx.params.integrationID,
methods: config.integrationMethods?.[ctx.params.integrationID] ?? [{ type: "key", label: "API key" }],
connections: [],
},
}),
integrationConnect: (ctx) =>
Effect.sync(() => config.onConnectKey?.({ integrationID: ctx.params.integrationID, body: ctx.payload })).pipe(
Effect.andThen(noContent),
),
credentialRemove: () => noContent,
command: () => Effect.succeed({ location: location(config), data: [] }),
skill: () => Effect.succeed({ location: location(config), data: [] }),
plugin: () => Effect.succeed({ location: location(config), data: [] }),
mcp: () => Effect.succeed({ location: location(config), data: [] }),
mcpResource: () => Effect.succeed({ location: location(config), data: { resources: [], templates: [] } }),
projectList: () => {
const project = config.project as typeof config.project & { canonical?: string; worktree?: string }
return Effect.succeed([{ ...project, canonical: project.canonical ?? project.worktree ?? config.directory }])
},
projectCurrent: () =>
Effect.succeed({
id: (config.project as { id?: string }).id,
directory: config.directory,
canonical: config.directory,
},
}),
worktreeList: () =>
Effect.succeed([
{ directory: config.directory },
...((config.project as { sandboxes?: string[] }).sandboxes ?? []).map((directory) => ({
directory,
strategy: "git",
})),
]),
worktreeCreate: (ctx) => {
const input = record(ctx.payload) ? ctx.payload : {}
return Effect.succeed({
directory: `${typeof input.directory === "string" ? input.directory : config.directory}/${
typeof input.name === "string" ? input.name : "copy"
}`,
})
},
data: [],
})
if (path === "/api/agent")
return json(route, {
location: location(config),
data: [
{
id: "build",
name: "Build",
mode: "primary",
hidden: false,
request: { settings: {}, headers: {}, body: {} },
permissions: [],
},
],
})
if (path === "/api/provider")
return json(route, {
location: location(config),
data: currentProviders(providerConfig(config)),
})
if (path === "/api/model")
return json(route, { location: location(config), data: currentModels(providerConfig(config)) })
if (path === "/api/model/default")
return json(route, { location: location(config), data: currentDefaultModel(providerConfig(config)) })
if (path === "/api/integration") return json(route, { location: location(config), data: [] })
if (path === "/api/command") return json(route, { location: location(config), data: [] })
if (path === "/api/skill") return json(route, { location: location(config), data: [] })
if (path === "/api/plugin") return json(route, { location: location(config), data: [] })
if (path === "/api/mcp") return json(route, { location: location(config), data: [] })
if (path === "/api/mcp/resource")
return json(route, { location: location(config), data: { resources: [], templates: [] } })
const integration = path.match(/^\/api\/integration\/([^/]+)$/)?.[1]
if (integration && route.request().method() === "GET")
return json(route, {
location: location(config),
data: {
id: integration,
name: integration,
methods: config.integrationMethods?.[integration] ?? [{ type: "key", label: "API key" }],
connections: [],
},
})
const integrationConnect = path.match(/^\/api\/integration\/([^/]+)\/connect\/key$/)?.[1]
if (integrationConnect && route.request().method() === "POST") {
config.onConnectKey?.({ integrationID: integrationConnect, body: route.request().postDataJSON() })
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/api\/credential\/[^/]+$/.test(path) && route.request().method() === "DELETE")
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (path === "/api/project") {
const project = config.project as typeof config.project & { canonical?: string; worktree?: string }
return json(route, [
{
...project,
canonical: project.canonical ?? project.worktree ?? config.directory,
},
])
}
if (path === "/api/project/current")
return json(route, {
id: (config.project as { id?: string }).id,
directory: config.directory,
canonical: config.directory,
})
const worktree = path.match(/^\/api\/worktree\/([^/]+)$/)?.[1]
if (worktree && route.request().method() === "GET")
return json(route, [
{ directory: config.directory },
...((config.project as { sandboxes?: string[] }).sandboxes ?? []).map((directory) => ({
directory,
strategy: "git",
})),
])
if (path === "/api/location") return json(route, location(config))
if (worktree && route.request().method() === "POST") {
const input = route.request().postDataJSON() as { directory: string; name?: string }
return json(route, { directory: `${input.directory}/${input.name ?? "copy"}` })
}
if (worktree && route.request().method() === "DELETE")
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (/^\/api\/worktree\/[^/]+\/refresh$/.test(path))
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (path === "/api/permission/request")
return json(route, {
location: location(config),
data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map(
currentPermission,
),
})
if (path === "/api/form/request")
return json(route, {
location: location(config),
data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []),
})
if (path === "/api/vcs")
return json(route, { location: location(config), data: { branch: { current: "main", default: "main" } } })
if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] })
if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] })
if (path === "/api/fs/list" && config.fileList)
return json(route, {
location: location(config),
data: await config.fileList(url.searchParams.get("path") ?? ""),
})
const fileRead = path.match(/^\/api\/fs\/read\/(.+)$/)?.[1]
if (fileRead && config.fileContent) {
const value = await config.fileContent(decodeURIComponent(fileRead))
const content =
value && typeof value === "object" && "content" in value ? String(value.content) : String(value ?? "")
return route.fulfill({ status: 200, body: content, headers: { "content-type": "application/octet-stream" } })
}
if (path === "/api/fs/find" && config.findFiles) {
const entries = await config.findFiles({
query: url.searchParams.get("query") ?? "",
dirs: url.searchParams.get("type") ?? undefined,
limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined,
})
return json(route, {
location: location(config),
data: Array.isArray(entries)
? entries.map((entry) =>
typeof entry === "string"
? {
name: entry.split(/[\\/]/).at(-1) ?? entry,
path: entry,
absolute: `${config.directory}/${entry}`,
type: "directory",
ignored: false,
}
: entry,
)
: entries,
})
}
if (path === "/api/shell" && route.request().method() === "GET")
return json(route, { location: location(config), data: [] })
if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path))
return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } })
if (path === "/api/session") {
if (route.request().method() === "POST") {
const payload = route.request().postDataJSON() as Record<string, unknown>
const created = currentSession(
{
id: "ses_mock_created",
projectID: (config.project as { id?: string }).id,
title: typeof payload.title === "string" ? payload.title : "New session",
parentID: typeof payload.parentID === "string" ? payload.parentID : undefined,
},
config.directory,
)
config.sessions.push(created)
return json(route, { data: created })
}
if (route.request().method() !== "GET") return route.fallback()
const directory = url.searchParams.get("directory")
const parentID = url.searchParams.get("parentID")
const limit = Number(url.searchParams.get("limit") ?? 50)
const offset = Number(url.searchParams.get("cursor") ?? 0)
const sessions = config.sessions
.filter((session) => {
const location = session.location as { directory?: string } | undefined
return !directory || location?.directory === directory || session.directory === directory
})
.filter((session) => {
if (parentID === null) return true
if (parentID === "null") return session.parentID === undefined
return session.parentID === parentID
})
.filter((session) => {
const search = url.searchParams.get("search")?.toLowerCase()
return (
!search ||
String(session.title ?? "")
.toLowerCase()
.includes(search)
)
})
const ordered = url.searchParams.get("order") === "asc" ? sessions : sessions.toReversed()
const data = ordered.slice(offset, offset + limit)
const next = offset + limit < ordered.length ? String(offset + limit) : undefined
return json(route, {
data: data.map((session) => currentSession(session, config.directory)),
cursor: { next },
})
}
if (path === "/api/session/active") {
const statuses = (
typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {})
) as Record<string, { type?: string }>
return json(route, {
data: Object.fromEntries(
Object.entries(statuses).flatMap(([id, status]) =>
status.type === "idle" ? [] : [[id, { type: "running" }]],
worktreeRemove: () => noContent,
worktreeRefresh: () => noContent,
location: () => Effect.succeed(location(config)),
permissionRequests: () =>
Effect.succeed({
location: location(config),
data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map(
currentPermission,
),
}),
formRequests: () =>
Effect.succeed({
location: location(config),
data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []),
}),
vcs: () =>
Effect.succeed({ location: location(config), data: { branch: { current: "main", default: "main" } } }),
vcsStatus: () => Effect.succeed({ location: location(config), data: [] }),
vcsDiff: () => Effect.succeed({ location: location(config), data: config.vcsDiff ?? [] }),
fsList: (ctx) =>
Effect.promise(() => Promise.resolve(config.fileList?.(ctx.query.path ?? ""))).pipe(
Effect.map((data) => ({ location: location(config), data })),
),
),
})
}
if (/^\/api\/session\/[^/]+\/shell$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
const sessionForm = path.match(/^\/api\/session\/([^/]+)\/form$/)?.[1]
if (sessionForm && route.request().method() === "GET") {
const forms = typeof config.forms === "function" ? config.forms() : (config.forms ?? [])
return json(route, { data: forms.filter((form) => (form as { sessionID?: string }).sessionID === sessionForm) })
}
if (/^\/api\/session\/[^/]+\/form\/[^/]+\/(reply|cancel)$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/api\/session\/[^/]+\/background$/.test(path) && route.request().method() === "POST")
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (/^\/api\/session\/[^/]+\/inbox$/.test(path) && route.request().method() === "GET")
return json(route, { data: [] })
const sessionPermission = path.match(/^\/api\/session\/([^/]+)\/permission$/)?.[1]
if (sessionPermission && route.request().method() === "GET") {
const permissions = typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])
return json(route, {
data: permissions.map(currentPermission).filter((permission) => permission.sessionID === sessionPermission),
})
}
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (
/^\/api\/session\/[^/]+\/(rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
route.request().method() === "POST"
) {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/)
if (currentSessionMatch) {
const session = config.sessions.find((item) => item.id === currentSessionMatch[1])
if (!session) return json(route, { error: "Session not found" }, undefined, 404)
return json(route, {
data: currentSession(session, config.directory),
})
}
const messageMatch = path.match(/^\/api\/session\/([^/]+)\/message\/([^/]+)$/)
if (messageMatch) {
config.onMessage?.({ sessionID: messageMatch[1]!, messageID: messageMatch[2]! })
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
const message =
config.message?.(messageMatch[1]!, messageMatch[2]!) ??
config.pageMessages(messageMatch[1]!, Number.MAX_SAFE_INTEGER).items.find((item) => item.id === messageMatch[2])
if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404)
return json(route, { data: message })
}
const messagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/)
if (messagesMatch) {
const token = url.searchParams.get("cursor") ?? undefined
const before = token ? cursors.get(token) : undefined
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
await config.beforeMessagesResponse?.({ sessionID: messagesMatch[1]!, before })
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
const pageData = config.pageMessages(messagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before)
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined
if (cursor) cursors.set(cursor, pageData.cursor!)
return json(route, {
data: url.searchParams.get("order") === "asc" ? pageData.items : pageData.items.toReversed(),
cursor: { next: cursor },
})
}
if (url.port === targetPort && targetPort !== appPort)
return json(route, { error: `Unhandled mock route: ${path}` }, undefined, 404)
return route.fallback()
})
fsFind: (ctx) =>
Effect.promise(() =>
Promise.resolve(
config.findFiles?.({ query: ctx.query.query ?? "", dirs: ctx.query.type, limit: ctx.query.limit }),
),
).pipe(
Effect.map((entries) => ({
location: location(config),
data: Array.isArray(entries)
? entries.map((entry) =>
typeof entry === "string"
? {
name: entry.split(/[\\/]/).at(-1) ?? entry,
path: entry,
absolute: `${config.directory}/${entry}`,
type: "directory",
ignored: false,
}
: entry,
)
: entries,
})),
),
shell: () => Effect.succeed({ location: location(config), data: [] }),
ptyConnectToken: () =>
Effect.succeed({ location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } }),
sessionList: (ctx) => {
const sessions = config.sessions
.filter((session) => {
const location = session.location as { directory?: string } | undefined
return (
!ctx.query.directory ||
location?.directory === ctx.query.directory ||
session.directory === ctx.query.directory
)
})
.filter((session) => {
if (ctx.query.parentID === undefined) return true
if (ctx.query.parentID === "null") return session.parentID === undefined
return session.parentID === ctx.query.parentID
})
.filter((session) =>
ctx.query.search === undefined
? true
: String(session.title ?? "")
.toLowerCase()
.includes(ctx.query.search.toLowerCase()),
)
const ordered = ctx.query.order === "asc" ? sessions : sessions.toReversed()
const offset = Number(ctx.query.cursor ?? 0)
const limit = ctx.query.limit ?? 50
const data = ordered.slice(offset, offset + limit)
return Effect.succeed({
data: data.map((session) => currentSession(session, config.directory)),
cursor: { next: offset + limit < ordered.length ? String(offset + limit) : undefined },
})
},
sessionCreate: (ctx) => {
const payload = record(ctx.payload) ? ctx.payload : {}
const created = currentSession(
{
id: "ses_mock_created",
projectID: (config.project as { id?: string }).id,
title: typeof payload.title === "string" ? payload.title : "New session",
parentID: typeof payload.parentID === "string" ? payload.parentID : undefined,
},
config.directory,
)
return Effect.sync(() => config.sessions.push(created)).pipe(Effect.as({ data: created }))
},
sessionActive: () => {
const statuses = (
typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {})
) as Record<string, { type?: string }>
return Effect.succeed({
data: Object.fromEntries(
Object.entries(statuses).flatMap(([id, status]) =>
status.type === "idle" ? [] : [[id, { type: "running" }]],
),
),
})
},
sessionGet: (ctx) => {
const session = config.sessions.find((item) => item.id === ctx.params.sessionID)
return session
? Effect.succeed({ data: currentSession(session, config.directory) })
: Effect.fail(new MockNotFound({ message: "Session not found" }))
},
sessionRemove: () => noContent,
sessionShell: () => noContent,
sessionForm: (ctx) => {
const forms = typeof config.forms === "function" ? config.forms() : (config.forms ?? [])
return Effect.succeed({
data: forms.filter((form) => (form as { sessionID?: string }).sessionID === ctx.params.sessionID),
})
},
sessionFormReply: () => noContent,
sessionFormCancel: () => noContent,
sessionBackground: () => noContent,
sessionInbox: () => Effect.succeed({ data: [] }),
sessionPermission: (ctx) => {
const permissions =
typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])
return Effect.succeed({
data: permissions
.map(currentPermission)
.filter((permission) => permission.sessionID === ctx.params.sessionID),
})
},
sessionPermissionReply: () => noContent,
sessionRename: () => noContent,
sessionInterrupt: () => noContent,
sessionRevertStage: (ctx) => {
const payload = record(ctx.payload) ? ctx.payload : {}
const messageID = payload.messageID
if (typeof messageID !== "string") {
return Effect.fail(new MockBadRequest({ message: "Invalid revert request" }))
}
return Effect.sync(() => config.onRevertStage?.({ sessionID: ctx.params.sessionID, messageID })).pipe(
Effect.as({ data: { messageID } }),
)
},
sessionRevertClear: () => noContent,
sessionRevertCommit: () => noContent,
messageGet: (ctx) =>
Effect.gen(function* () {
config.onMessage?.({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID })
yield* delay
const message =
config.message?.(ctx.params.sessionID, ctx.params.messageID) ??
config
.pageMessages(ctx.params.sessionID, Number.MAX_SAFE_INTEGER)
.items.find((item) => item.id === ctx.params.messageID)
if (!message) return yield* new MockNotFound({ message: "Message not found" })
return { data: message }
}),
messageList: (ctx) => {
const token = ctx.query.cursor
const before = token ? state.cursors.get(token) : undefined
if (token && !before) return Effect.fail(new MockBadRequest({ message: "Invalid cursor" }))
return Effect.gen(function* () {
config.onMessages?.({ sessionID: ctx.params.sessionID, before, phase: "start" })
if (config.beforeMessagesResponse) {
yield* Effect.promise(() => config.beforeMessagesResponse!({ sessionID: ctx.params.sessionID, before }))
}
yield* delay
const pageData = config.pageMessages(ctx.params.sessionID, ctx.query.limit ?? 50, before)
config.onMessages?.({ sessionID: ctx.params.sessionID, before, phase: "end" })
const cursor = pageData.cursor ? `cursor_${++state.nextCursor}` : undefined
if (cursor) state.cursors.set(cursor, pageData.cursor!)
return {
data: ctx.query.order === "asc" ? pageData.items : pageData.items.toReversed(),
cursor: { next: cursor },
}
})
},
}),
)
}
function location(config: MockServerConfig) {
@@ -585,24 +613,3 @@ function jsonValue(value: unknown): JsonValue | undefined {
function record(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
return route.fulfill({
status,
contentType: "application/json",
headers: {
"access-control-allow-origin": "*",
"access-control-expose-headers": "x-next-cursor",
...headers,
},
body: JSON.stringify(body ?? null),
})
}
function sse(route: Route, events?: unknown[], retry?: number) {
return route.fulfill({
status: 200,
contentType: "text/event-stream",
body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`,
})
}
+4 -4
View File
@@ -6,10 +6,10 @@
"exports": {
".": "./src/index.ts",
"./desktop": "./src/desktop.ts",
"./desktop-menu": "./src/desktop-menu.ts",
"./i18n/desktop-native": "./src/i18n/desktop-native.ts",
"./updater": "./src/updater.ts",
"./wsl/types": "./src/wsl/types.ts",
"./desktop-menu": "./src/shell/commands/desktop-menu.ts",
"./i18n/desktop-native": "./src/runtime/i18n/desktop-native.ts",
"./updater": "./src/shell/updates/types.ts",
"./wsl/types": "./src/servers/wsl/types.ts",
"./vite": "./vite.js",
"./index.css": "./src/index.css"
},
+15 -105
View File
@@ -4,72 +4,26 @@ import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { Font } from "@opencode-ai/ui/font"
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Route, Router, useParams } from "@solidjs/router"
import { type BaseRouterProps, Router } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import {
type Component,
createMemo,
createRenderEffect,
ErrorBoundary,
type JSX,
lazy,
type ParentProps,
Show,
} from "solid-js"
import { type Component, createRenderEffect, ErrorBoundary, type JSX, type ParentProps } from "solid-js"
import { Dynamic } from "solid-js/web"
import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
import { GlobalProvider, useGlobal } from "@/context/global"
import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, UiI18nBridge, type Locale, useLanguage } from "@/context/language"
import { LayoutProvider } from "@/context/layout"
import { usePlatform } from "@/context/platform"
import { ServerConnection, ServersProvider } from "@/context/servers"
import { SettingsProvider } from "@/context/settings"
import { TabsProvider } from "@/context/tabs"
import { WslServersProvider } from "@/wsl/context"
import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error"
import { requireServerKey } from "./utils/session-route"
import { CommandProvider } from "@/shell/commands/command"
import { DesktopCommands } from "@/shell/commands/desktop"
import { GlobalProvider } from "@/runtime/server/runtime"
import { HighlightsProvider } from "@/shell/updates/highlights"
import { LanguageProvider, UiI18nBridge, type Locale } from "@/runtime/i18n/language"
import { ServerConnection, ServersProvider } from "@/runtime/server/registry"
import { SettingsProvider } from "@/settings/model"
import { TabsProvider } from "@/shell/tabs/tabs"
import { WslServersProvider } from "@/servers/wsl/context"
import { ErrorPage } from "@/shell/errors/error"
import { AppRoutes, File, preloadRoute } from "@/shell/routes/routes"
import { Home } from "@/pages/home"
import { ServerProvider } from "./context/server"
const File = lazy(() => import("@opencode-ai/session-ui/file").then((module) => ({ default: module.File })))
const loadDraftRoute = () => Promise.all([import("@/pages/draft-route"), File.preload()]).then(([module]) => module)
const loadSessionRoute = () => Promise.all([import("@/session/route"), File.preload()]).then(([module]) => module)
const DraftRoute = lazy(() => loadDraftRoute().then((module) => ({ default: module.DraftRoute })))
const TargetSessionRouteContent = lazy(() =>
loadSessionRoute().then((module) => ({ default: module.TargetSessionRouteContent })),
)
export function preloadRoute(url: string) {
const pathname = url.split(/[?#]/, 1)[0]
if (pathname === "/new-session") return DraftRoute.preload().then(() => undefined)
if (/^\/server\/[^/]+\/session\/[^/]+$/.test(pathname))
return TargetSessionRouteContent.preload().then(() => undefined)
return Promise.resolve()
}
function TargetServerRoute(props: ParentProps) {
const params = useParams<{ serverKey: string }>()
const global = useGlobal()
const conn = createMemo(() =>
global.servers.list().find((item) => ServerConnection.key(item) === requireServerKey(params.serverKey)),
)
return (
// Owns the server-identity remount. Session changes must not remount this subtree.
<Show when={conn()} keyed>
{(conn) => <ServerProvider conn={conn}>{props.children}</ServerProvider>}
</Show>
)
}
export { preloadRoute }
declare global {
interface Window {
__OPENCODE__?: {
deepLinks?: string[]
}
api?: {
setTitlebar?: (theme: { mode: "light" | "dark"; scheme?: "system" | "light" | "dark" }) => Promise<void>
exportDebugLogs?: () => Promise<string>
@@ -100,39 +54,6 @@ function BodyTypography() {
return null
}
// Server-agnostic providers shared across every route. These live in the shared
// shell (router root) so they stay mounted regardless of the active server/route.
function DesktopCommands() {
const command = useCommand()
const language = useLanguage()
const platform = usePlatform()
command.register("desktop", () => {
const commands: CommandOption[] = []
if (platform.platform === "desktop" && platform.exportDebugLogs) {
commands.push({
id: "logs.export",
title: language.t("command.logs.export"),
category: language.t("command.category.settings"),
onSelect: () => {
void platform.exportDebugLogs?.()
},
})
}
return commands
})
return null
}
function AppLayout(props: ParentProps) {
return (
<LayoutProvider>
<Layout>{props.children}</Layout>
</LayoutProvider>
)
}
export function AppBaseProviders(
props: ParentProps<{
locale?: Locale
@@ -204,18 +125,7 @@ export function AppInterface(props: {
<SettingsProvider>
<GlobalProvider>
<Dynamic component={props.router ?? Router} root={Root}>
<Route component={AppLayout}>
<Route path="/" component={Home} />
<Route
path="/server/:serverKey/session/:id"
component={() => (
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
)}
/>
<Route path="/new-session" component={DraftRoute} />
</Route>
<AppRoutes />
</Dynamic>
</GlobalProvider>
</SettingsProvider>
@@ -1,152 +0,0 @@
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog, DialogBody, DialogHeader, DialogTitle } from "@opencode-ai/ui/dialog"
import { List, type ListRef } from "@opencode-ai/ui/list"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Badge } from "@opencode-ai/ui/badge"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { type Component, Show } from "solid-js"
import { useLocal } from "@/context/local"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language"
import { decode64 } from "@/utils/base64"
type ModelState = ReturnType<typeof useLocal>["model"]
export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props) => {
const local = useLocal()
const model = props.model ?? local.model
const dialog = useDialog()
const directory = () => decode64(local.slug())
const providers = useProviders(directory)
const language = useLanguage()
const openProviders = (provider?: string) => {
void import("./dialog-connect-provider").then((x) => {
const controller = x.useProviderConnectController()
controller.select(provider)
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory()} />)
})
}
const connect = (provider: string) => openProviders(provider)
const all = () => openProviders()
let listRef: ListRef | undefined
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") return
listRef?.onKeyDown(e)
}
return (
<Dialog class="overflow-y-auto [&_[data-slot=dialog-body]]:overflow-visible [&_[data-slot=dialog-body]]:flex-none">
<DialogHeader>
<DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle>
</DialogHeader>
<DialogBody>
<div class="flex flex-col gap-3 px-2.5" onKeyDown={handleKeyDown}>
<div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div>
<List
class="px-3 [&_[data-slot=list-scroll]]:overflow-visible"
ref={(ref) => (listRef = ref)}
items={model.list}
current={model.current()}
key={(x) => `${x.provider.id}:${x.id}`}
itemWrapper={(item, node) => (
<Tooltip
appearance="standard"
class="w-full"
placement="right-start"
gutter={12}
value={
<ModelTooltip
model={item}
latest={item.latest}
free={item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)}
/>
}
>
{node}
</Tooltip>
)}
onSelect={(x) => {
model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
recent: true,
})
dialog.close()
}}
>
{(i) => (
<div class="w-full flex items-center gap-x-2.5">
<span>{i.name}</span>
<Badge appearance="standard">{language.t("model.tag.free")}</Badge>
<Show when={i.latest}>
<Badge appearance="standard">{language.t("model.tag.latest")}</Badge>
</Show>
</div>
)}
</List>
</div>
<div class="px-1.5 pb-1.5">
<div class="w-full rounded-sm border border-border-weak-base bg-surface-raised-base">
<div class="w-full flex flex-col items-start gap-4 px-1.5 pt-4 pb-4">
<div class="px-2 text-14-medium text-text-base">{language.t("dialog.model.unpaid.addMore.title")}</div>
<div class="w-full">
<List
class="w-full px-3"
key={(p) => p.id}
items={providers.popular}
activeIcon="plus-small"
sortBy={(a, b) => {
if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
return a.name.localeCompare(b.name)
}}
onSelect={(x) => {
if (!x) return
connect(x.id)
}}
>
{(i) => (
<div class="w-full flex items-center gap-x-3">
<ProviderIcon data-slot="list-item-extra-icon" id={i.id} />
<span>{i.name}</span>
<Show when={i.id === "opencode"}>
<div class="text-14-regular text-text-weak">
{language.t("dialog.provider.opencode.tagline")}
</div>
</Show>
<Show when={i.id === "opencode"}>
<Badge appearance="standard">{language.t("dialog.provider.tag.recommended")}</Badge>
</Show>
<Show when={i.id === "opencode-go"}>
<>
<div class="text-14-regular text-text-weak">
{language.t("dialog.provider.opencodeGo.tagline")}
</div>
<Badge appearance="standard">{language.t("dialog.provider.tag.recommended")}</Badge>
</>
</Show>
<Show when={i.id === "anthropic"}>
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.anthropic.note")}</div>
</Show>
</div>
)}
</List>
<Button
variant="ghost"
class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium"
icon="dot-grid"
onClick={all}
>
{language.t("dialog.provider.viewAll")}
</Button>
</div>
</div>
</div>
</div>
</DialogBody>
</Dialog>
)
}
@@ -1,261 +0,0 @@
import { Button } from "@opencode-ai/ui/button"
import { Menu } from "@opencode-ai/ui/menu"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { List } from "@opencode-ai/ui/list"
import { TextField } from "@opencode-ai/ui/text-field"
import { Show } from "solid-js"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/servers"
import { ServerCollectionController } from "@/components/server/server-management-controller"
type ServerConnectionFormController = {
state: {
adding: () => boolean
busy: () => boolean
value: () => string
name: () => string
username: () => string
password: () => string
error: () => string
status: () => boolean | undefined
}
change: {
value: (value: string) => void
name: (value: string) => void
username: (value: string) => void
password: (value: string) => void
}
reset: () => void
submit: () => void
}
interface ServerFormProps {
value: string
name: string
username: string
password: string
placeholder: string
busy: boolean
error: string
status: boolean | undefined
onChange: (value: string) => void
onNameChange: (value: string) => void
onUsernameChange: (value: string) => void
onPasswordChange: (value: string) => void
onSubmit: () => void
onBack: () => void
}
function ServerForm(props: ServerFormProps) {
const language = useLanguage()
const keyDown = (event: KeyboardEvent) => {
event.stopPropagation()
if (event.key === "Escape") {
event.preventDefault()
props.onBack()
return
}
if (event.key !== "Enter" || event.isComposing) return
event.preventDefault()
props.onSubmit()
}
return (
<div>
<div class="bg-surface-base rounded-md p-5 flex flex-col gap-3">
<div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
<TextField
type="text"
label={language.t("dialog.server.add.url")}
placeholder={props.placeholder}
value={props.value}
autofocus
validationState={props.error ? "invalid" : "valid"}
error={props.error}
disabled={props.busy}
onChange={props.onChange}
onKeyDown={keyDown}
/>
</div>
<TextField
type="text"
label={language.t("dialog.server.add.name")}
placeholder={language.t("dialog.server.add.namePlaceholder")}
defaultValue={props.name}
disabled={props.busy}
onChange={props.onNameChange}
onKeyDown={keyDown}
/>
<div class="grid grid-cols-2 gap-2 min-w-0">
<TextField
type="text"
label={language.t("dialog.server.add.username")}
placeholder={language.t("dialog.server.add.usernamePlaceholder")}
defaultValue={props.username}
disabled={props.busy}
onChange={props.onUsernameChange}
onKeyDown={keyDown}
/>
<TextField
type="password"
label={language.t("dialog.server.add.password")}
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
defaultValue={props.password}
disabled={props.busy}
onChange={props.onPasswordChange}
onKeyDown={keyDown}
/>
</div>
</div>
</div>
)
}
export function ServerConnectionList(props: {
domain: ServerCollectionController
onAdd: () => void
onEdit: (server: ServerConnection.Http) => void
}) {
const language = useLanguage()
return (
<div class="flex flex-1 min-h-0 flex-col gap-4">
<List
class="flex-1 min-h-0 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent"
search={{
placeholder: language.t("dialog.server.search.placeholder"),
autofocus: false,
}}
noInitialSelection
emptyMessage={language.t("dialog.server.empty")}
items={props.domain.collection.items}
key={(x) => x.http.url}
divider={true}
>
{(i) => {
const key = ServerConnection.key(i)
return (
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
<div class="flex flex-col h-full items-center w-5">
<ServerHealthIndicator health={props.domain.collection.health()[key]} />
</div>
<ServerRow
conn={i}
dimmed={props.domain.collection.health()[key]?.healthy === false}
status={props.domain.collection.health()[key]}
class="flex items-center gap-3 min-w-0 flex-1"
badge={
<Show when={props.domain.defaults.key() === ServerConnection.key(i)}>
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")}
</span>
</Show>
}
showCredentials
/>
<div class="flex items-center justify-center gap-4 pl-4">
<Show when={i.type === "http"}>
<Menu appearance="standard">
<Menu.Trigger
as={IconButton}
icon={<Icon name="dot-grid" />}
variant="ghost"
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
onClick={(e: MouseEvent) => e.stopPropagation()}
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
/>
<Menu.Portal>
<Menu.Content class="mt-1">
<Menu.Item
onSelect={() => {
if (i.type !== "http") return
props.onEdit(i)
}}
>
{language.t("dialog.server.menu.edit")}
</Menu.Item>
<Show when={props.domain.defaults.available() && props.domain.defaults.key() !== key}>
<Menu.Item onSelect={() => props.domain.defaults.set(key)}>
{language.t("dialog.server.menu.default")}
</Menu.Item>
</Show>
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
<Menu.Item onSelect={() => props.domain.defaults.set(null)}>
{language.t("dialog.server.menu.defaultRemove")}
</Menu.Item>
</Show>
<Show when={props.domain.connection.canRemove(key)}>
<Menu.Separator />
<Menu.Item
onSelect={() => props.domain.connection.remove(key)}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
{language.t("dialog.server.menu.delete")}
</Menu.Item>
</Show>
</Menu.Content>
</Menu.Portal>
</Menu>
</Show>
</div>
</div>
)
}}
</List>
<div class="shrink-0 pb-5">
<Button
variant="neutral"
icon="plus-small"
size="large"
onClick={props.onAdd}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
>
{language.t("dialog.server.add.button")}
</Button>
</div>
</div>
)
}
export function ServerConnectionForm(props: { form: ServerConnectionFormController }) {
const language = useLanguage()
return (
<div class="flex flex-1 min-h-0 flex-col gap-4">
<ServerForm
value={props.form.state.value()}
name={props.form.state.name()}
username={props.form.state.username()}
password={props.form.state.password()}
placeholder={language.t("dialog.server.add.placeholder")}
busy={props.form.state.busy()}
error={props.form.state.error()}
status={props.form.state.status()}
onChange={props.form.change.value}
onNameChange={props.form.change.name}
onUsernameChange={props.form.change.username}
onPasswordChange={props.form.change.password}
onSubmit={props.form.submit}
onBack={props.form.reset}
/>
<div class="shrink-0 pb-5">
<Button
variant="contrast"
size="large"
onClick={props.form.submit}
disabled={props.form.state.busy()}
class="px-3 py-1.5"
>
{props.form.state.busy()
? language.t("dialog.server.add.checking")
: props.form.state.adding()
? language.t("dialog.server.add.button")
: language.t("common.save")}
</Button>
</div>
</div>
)
}
@@ -1,17 +0,0 @@
import type { Component } from "solid-js"
import { PromptInputV2Composer, usePromptInputV2Controller } from "./prompt-input-v2"
import { createPromptInputHistory, type PromptInputHistory } from "./prompt-input/history-store"
import type {
PromptInputControls,
PromptInputProps,
PromptInputState,
PromptInputSubmission,
} from "./prompt-input/contracts"
export { createPromptInputHistory }
export type { PromptInputControls, PromptInputHistory, PromptInputProps, PromptInputState, PromptInputSubmission }
export const PromptInput: Component<PromptInputProps> = (props) => {
const controller = usePromptInputV2Controller(props)
return <PromptInputV2Composer class={props.class} controller={controller} />
}
@@ -1,108 +0,0 @@
import { describe, expect, test } from "bun:test"
import { attachmentMime, pickAttachmentFiles } from "./files"
import { pasteMode } from "./paste"
describe("attachmentMime", () => {
test("keeps PDFs when the browser reports the mime", async () => {
const file = new File(["%PDF-1.7"], "guide.pdf", { type: "application/pdf" })
expect(await attachmentMime(file)).toBe("application/pdf")
})
test("normalizes structured text types to text/plain", async () => {
const file = new File(['{"ok":true}\n'], "data.json", { type: "application/json" })
expect(await attachmentMime(file)).toBe("text/plain")
})
test("accepts text files even with a misleading browser mime", async () => {
const file = new File(["export const x = 1\n"], "main.ts", { type: "video/mp2t" })
expect(await attachmentMime(file)).toBe("text/plain")
})
test("rejects binary files", async () => {
const file = new File([Uint8Array.of(0, 255, 1, 2)], "blob.bin", { type: "application/octet-stream" })
expect(await attachmentMime(file)).toBeUndefined()
})
})
describe("pickAttachmentFiles", () => {
test("reads the current project directory for every native picker invocation", async () => {
const paths: string[] = []
const files: File[] = []
const file = new File(["hello"], "hello.txt", { type: "text/plain" })
let directory = "C:\\Projects\\LoremIpsum"
const picker = async (options?: { defaultPath?: string }, onFile?: (file: File) => Promise<unknown>) => {
paths.push(options?.defaultPath ?? "")
await onFile?.(file)
}
pickAttachmentFiles({
picker,
directory: () => directory,
fallback: () => undefined,
onFile: async (selected) => files.push(selected),
onError: () => undefined,
})
await Promise.resolve()
directory = "C:\\Projects\\DolorSit"
pickAttachmentFiles({
picker,
directory: () => directory,
fallback: () => undefined,
onFile: async (selected) => files.push(selected),
onError: () => undefined,
})
await Promise.resolve()
expect(files).toEqual([file, file])
expect(paths).toEqual(["C:\\Projects\\LoremIpsum", "C:\\Projects\\DolorSit"])
})
test("uses the browser file input when no native picker exists", async () => {
let fallback = 0
pickAttachmentFiles({
directory: () => "/projects/consectetur-adipiscing",
fallback: () => {
fallback += 1
},
onFile: async () => undefined,
onError: () => undefined,
})
expect(fallback).toBe(1)
})
test("reports native picker failures without rejecting", async () => {
const error = new Error("picker unavailable")
const errors: unknown[] = []
const handled = Promise.withResolvers<void>()
pickAttachmentFiles({
picker: async () => Promise.reject(error),
directory: () => "C:\\Projects\\LoremIpsum",
fallback: () => undefined,
onFile: async () => undefined,
onError: (cause) => {
errors.push(cause)
handled.resolve()
},
})
await handled.promise
expect(errors).toEqual([error])
})
})
describe("pasteMode", () => {
test("uses native paste for short single-line text", () => {
expect(pasteMode("hello world")).toBe("native")
})
test("uses manual paste for multiline text", () => {
expect(
pasteMode(`{
"ok": true
}`),
).toBe("manual")
expect(pasteMode("a\r\nb")).toBe("manual")
})
test("uses manual paste for large text", () => {
expect(pasteMode("x".repeat(8000))).toBe("manual")
})
})
@@ -1,213 +0,0 @@
import { onMount } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { showToast } from "@/utils/toast"
import { type ContentPart, type ImageAttachmentPart, type usePrompt } from "@/context/prompt"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { uuid } from "@/utils/uuid"
import { getCursorPosition } from "./editor-dom"
import { createBlobReference, type DraftStore } from "@/utils/draft-store"
import { attachmentMime } from "./files"
import { normalizePaste, pasteMode } from "./paste"
type PromptTarget = Pick<ReturnType<ReturnType<typeof usePrompt>["capture"]>, "current" | "cursor" | "set">
type AttachmentTarget = { prompt: PromptTarget; cursor: number | undefined }
type PromptAttachmentsCoreInput = {
capture: () => PromptTarget
editor: () => HTMLDivElement | undefined
focusEditor?: () => void
addPart?: (part: ContentPart) => boolean
warn?: () => void
readClipboardImage?: () => Promise<File | null>
getPathForFile?: (file: File) => string
draftStore?: DraftStore
}
export type PromptAttachmentsInput = {
prompt: ReturnType<typeof usePrompt>
editor: () => HTMLDivElement | undefined
isDialogActive: () => boolean
setDraggingType: (type: "image" | "@mention" | null) => void
focusEditor: () => void
addPart: (part: ContentPart) => boolean
readClipboardImage?: () => Promise<File | null>
getPathForFile?: (file: File) => string
}
export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
const capture = (): AttachmentTarget | undefined => {
const prompt = input.capture()
const editor = input.editor()
if (!editor) return
return { prompt, cursor: prompt.cursor() ?? getCursorPosition(editor) }
}
const add = async (file: File, toast = true, target = capture()) => {
if (!target) return false
const mime = await attachmentMime(file)
if (!mime) {
if (toast) input.warn?.()
return false
}
const attachment: ImageAttachmentPart = {
type: "image",
id: uuid(),
filename: file.name,
sourcePath: input.getPathForFile?.(file) || undefined,
mime,
blob: input.draftStore ? await input.draftStore.putBlob(file) : await createBlobReference(file),
}
target.prompt.set([...target.prompt.current(), attachment], target.cursor)
return true
}
const addAttachment = (file: File) => add(file)
const addAttachments = async (files: File[], toast = true, target = capture()) => {
let found = false
for (const file of files) {
const ok = await add(file, false, target)
if (ok) found = true
}
if (!found && files.length > 0 && toast) input.warn?.()
return found
}
const addClipboardAttachment = async (pending: Promise<File | null>, target = capture()) => {
const file = await pending
if (!file) return false
return add(file, true, target)
}
const removeAttachment = (id: string) => {
const target = input.capture()
const current = target.current()
const next = current.filter((part) => part.type !== "image" || part.id !== id)
target.set(next, target.cursor())
}
const handlePaste = async (event: ClipboardEvent) => {
const clipboardData = event.clipboardData
if (!clipboardData) return
const target = capture()
if (!target) return
event.preventDefault()
event.stopPropagation()
const files = Array.from(clipboardData.items).flatMap((item) => {
if (item.kind !== "file") return []
const file = item.getAsFile()
return file ? [file] : []
})
if (files.length > 0) {
await addAttachments(files, true, target)
return
}
const plainText = clipboardData.getData("text/plain") ?? ""
// Desktop: Browser clipboard has no images and no text, try platform's native clipboard for images
if (input.readClipboardImage && !plainText) {
if (await addClipboardAttachment(input.readClipboardImage(), target)) return
}
if (!plainText) return
const text = normalizePaste(plainText)
const put = () => {
if (input.addPart?.({ type: "text", content: text, start: 0, end: 0 })) return true
input.focusEditor?.()
return input.addPart?.({ type: "text", content: text, start: 0, end: 0 }) ?? false
}
if (pasteMode(text) === "manual") {
put()
return
}
const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, text)
if (inserted) return
put()
}
return {
addAttachment,
addAttachments,
addClipboardAttachment,
removeAttachment,
handlePaste,
}
}
export function createPromptAttachments(input: PromptAttachmentsInput) {
const language = useLanguage()
const platform = usePlatform()
const attachments = createPromptAttachmentsCore({
...input,
draftStore: platform.draftStore,
capture: input.prompt.capture,
warn: () => {
showToast({
title: language.t("prompt.toast.pasteUnsupported.title"),
description: language.t("prompt.toast.pasteUnsupported.description"),
})
},
})
const handleGlobalDragOver = (event: DragEvent) => {
if (input.isDialogActive()) return
event.preventDefault()
const hasFiles = event.dataTransfer?.types.includes("Files")
const hasText = event.dataTransfer?.types.includes("text/plain")
if (hasFiles) {
input.setDraggingType("image")
} else if (hasText) {
input.setDraggingType("@mention")
}
}
const handleGlobalDragLeave = (event: DragEvent) => {
if (input.isDialogActive()) return
if (!event.relatedTarget) {
input.setDraggingType(null)
}
}
const handleGlobalDrop = async (event: DragEvent) => {
if (input.isDialogActive()) return
event.preventDefault()
input.setDraggingType(null)
const plainText = event.dataTransfer?.getData("text/plain")
const filePrefix = "file:"
if (plainText?.startsWith(filePrefix)) {
const filePath = plainText.slice(filePrefix.length)
input.focusEditor()
input.addPart({ type: "file", path: filePath, content: "@" + filePath, start: 0, end: 0 })
return
}
const dropped = event.dataTransfer?.files
if (!dropped) return
await attachments.addAttachments(Array.from(dropped))
}
onMount(() => {
makeEventListener(document, "dragover", handleGlobalDragOver)
makeEventListener(document, "dragleave", handleGlobalDragLeave)
makeEventListener(document, "drop", handleGlobalDrop)
})
return attachments
}
@@ -1,57 +0,0 @@
import type { useLocal } from "@/context/local"
import type { Prompt, usePrompt } from "@/context/prompt"
import type { PromptInputHistory } from "./history-store"
import type { FollowupDraft } from "./submit"
export type PromptInputState = ReturnType<typeof usePrompt>
export type PromptInputSubmission = {
abort: () => Promise<void> | void
handleSubmit: (event: Event) => Promise<void> | void
}
export type PromptInputControls = {
agents: {
available: { name: string; hidden?: boolean; mode: string }[]
options: string[]
current: string
loading: boolean
visible: boolean
select: (name: string | undefined) => void
}
model: {
selection: ReturnType<typeof useLocal>["model"]
paid: boolean
loading: boolean
}
session: {
id?: string
tabs: {
active: () => string | undefined
all: () => string[]
open: (tab: string) => void | Promise<void>
setActive: (tab: string) => void
}
reviewPanel: {
opened: () => boolean
open: () => void
}
}
}
export interface PromptInputProps {
class?: string
state?: PromptInputState
history?: PromptInputHistory
submission?: PromptInputSubmission
controls: PromptInputControls
ref?: (el: HTMLDivElement) => void
newSessionWorktree?: string
onNewSessionWorktreeReset?: () => void
edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] }
onEditLoaded?: () => void
shouldQueue?: () => boolean
onQueue?: (draft: FollowupDraft) => void
onAbort?: () => void
onSubmit?: () => void
}
@@ -1,99 +0,0 @@
import { describe, expect, test } from "bun:test"
import { createTextFragment, getCursorPosition, getNodeLength, getTextLength, setCursorPosition } from "./editor-dom"
describe("prompt-input editor dom", () => {
test("createTextFragment preserves newlines with consecutive br nodes", () => {
const fragment = createTextFragment("foo\n\nbar")
const container = document.createElement("div")
container.appendChild(fragment)
expect(container.childNodes.length).toBe(4)
expect(container.childNodes[0]?.textContent).toBe("foo")
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
expect((container.childNodes[2] as HTMLElement).tagName).toBe("BR")
expect(container.childNodes[3]?.textContent).toBe("bar")
})
test("createTextFragment keeps trailing newline as terminal break", () => {
const fragment = createTextFragment("foo\n")
const container = document.createElement("div")
container.appendChild(fragment)
expect(container.childNodes.length).toBe(2)
expect(container.childNodes[0]?.textContent).toBe("foo")
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
})
test("createTextFragment avoids break-node explosion for large multiline content", () => {
const content = Array.from({ length: 220 }, () => "line").join("\n")
const fragment = createTextFragment(content)
const container = document.createElement("div")
container.appendChild(fragment)
expect(container.childNodes.length).toBe(1)
expect(container.childNodes[0]?.nodeType).toBe(Node.TEXT_NODE)
expect(container.textContent).toBe(content)
})
test("createTextFragment keeps terminal break in large multiline fallback", () => {
const content = `${Array.from({ length: 220 }, () => "line").join("\n")}\n`
const fragment = createTextFragment(content)
const container = document.createElement("div")
container.appendChild(fragment)
expect(container.childNodes.length).toBe(2)
expect(container.childNodes[0]?.textContent).toBe(content.slice(0, -1))
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
})
test("length helpers treat breaks as one char and ignore zero-width chars", () => {
const container = document.createElement("div")
container.appendChild(document.createTextNode("ab\u200B"))
container.appendChild(document.createElement("br"))
container.appendChild(document.createTextNode("cd"))
expect(getNodeLength(container.childNodes[0]!)).toBe(2)
expect(getNodeLength(container.childNodes[1]!)).toBe(1)
expect(getTextLength(container)).toBe(5)
})
test("setCursorPosition and getCursorPosition round-trip with pills and breaks", () => {
const container = document.createElement("div")
const pill = document.createElement("span")
pill.dataset.type = "file"
pill.textContent = "@file"
container.appendChild(document.createTextNode("ab"))
container.appendChild(pill)
container.appendChild(document.createElement("br"))
container.appendChild(document.createTextNode("cd"))
document.body.appendChild(container)
setCursorPosition(container, 2)
expect(getCursorPosition(container)).toBe(2)
setCursorPosition(container, 7)
expect(getCursorPosition(container)).toBe(7)
setCursorPosition(container, 8)
expect(getCursorPosition(container)).toBe(8)
container.remove()
})
test("setCursorPosition and getCursorPosition round-trip across blank lines", () => {
const container = document.createElement("div")
container.appendChild(document.createTextNode("a"))
container.appendChild(document.createElement("br"))
container.appendChild(document.createElement("br"))
container.appendChild(document.createTextNode("b"))
document.body.appendChild(container)
setCursorPosition(container, 2)
expect(getCursorPosition(container)).toBe(2)
setCursorPosition(container, 3)
expect(getCursorPosition(container)).toBe(3)
container.remove()
})
})
@@ -1,98 +0,0 @@
import { ACCEPTED_FILE_TYPES, ACCEPTED_IMAGE_TYPES } from "@/constants/file-picker"
export { ACCEPTED_FILE_TYPES }
type AttachmentPicker = (
options: {
defaultPath?: string
multiple?: boolean
accept?: string[]
},
onFile: (file: File) => Promise<unknown>,
) => Promise<void>
export function pickAttachmentFiles(input: {
picker?: AttachmentPicker
directory: () => string
fallback: () => void
onFile: (file: File) => Promise<unknown>
onError: (error: unknown) => void
}) {
if (!input.picker) {
input.fallback()
return
}
void input
.picker(
{
defaultPath: input.directory(),
multiple: true,
accept: ACCEPTED_FILE_TYPES,
},
input.onFile,
)
.catch(input.onError)
}
const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES)
const IMAGE_EXTS = new Map([
["gif", "image/gif"],
["jpeg", "image/jpeg"],
["jpg", "image/jpeg"],
["png", "image/png"],
["webp", "image/webp"],
])
const TEXT_MIMES = new Set([
"application/json",
"application/ld+json",
"application/toml",
"application/x-toml",
"application/x-yaml",
"application/xml",
"application/yaml",
])
const SAMPLE = 4096
function kind(type: string) {
return type.split(";", 1)[0]?.trim().toLowerCase() ?? ""
}
function ext(name: string) {
const idx = name.lastIndexOf(".")
if (idx === -1) return ""
return name.slice(idx + 1).toLowerCase()
}
function textMime(type: string) {
if (!type) return false
if (type.startsWith("text/")) return true
if (TEXT_MIMES.has(type)) return true
if (type.endsWith("+json")) return true
return type.endsWith("+xml")
}
function textBytes(bytes: Uint8Array) {
if (bytes.length === 0) return true
let count = 0
for (const byte of bytes) {
if (byte === 0) return false
if (byte < 9 || (byte > 13 && byte < 32)) count += 1
}
return count / bytes.length <= 0.3
}
export async function attachmentMime(file: File) {
const type = kind(file.type)
if (IMAGE_MIMES.has(type)) return type
if (type === "application/pdf") return type
const suffix = ext(file.name)
const fallback = IMAGE_EXTS.get(suffix) ?? (suffix === "pdf" ? "application/pdf" : undefined)
if ((!type || type === "application/octet-stream") && fallback) return fallback
if (textMime(type)) return "text/plain"
const bytes = new Uint8Array(await file.slice(0, SAMPLE).arrayBuffer())
if (!textBytes(bytes)) return
return "text/plain"
}
@@ -1,154 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { Prompt } from "@/context/prompt"
import {
canNavigateHistoryAtCursor,
clonePromptParts,
navigatePromptHistory,
prependHistoryEntry,
promptLength,
type PromptHistoryComment,
} from "./history"
import { upgradeHistoryState } from "./history-store"
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
const text = (value: string): Prompt => [{ type: "text", content: value, start: 0, end: value.length }]
const entry = (value: string) => ({ prompt: text(value), comments: [] })
const comment = (id: string, value = "note"): PromptHistoryComment => ({
id,
path: "src/a.ts",
selection: { start: 2, end: 4 },
comment: value,
time: 1,
origin: "review",
preview: "const a = 1",
})
describe("prompt-input history", () => {
test("prependHistoryEntry skips empty prompt and deduplicates consecutive entries", () => {
const first = prependHistoryEntry([], DEFAULT_PROMPT)
expect(first).toEqual([])
const commentsOnly = prependHistoryEntry([], DEFAULT_PROMPT, [comment("c1")])
expect(commentsOnly).toHaveLength(1)
const withOne = prependHistoryEntry([], text("hello"))
expect(withOne).toHaveLength(1)
const deduped = prependHistoryEntry(withOne, text("hello"))
expect(deduped).toBe(withOne)
const dedupedComments = prependHistoryEntry(commentsOnly, DEFAULT_PROMPT, [comment("c1")])
expect(dedupedComments).toBe(commentsOnly)
})
test("navigatePromptHistory restores saved prompt when moving down from newest", () => {
const entries = [entry("third"), entry("second"), entry("first")]
const up = navigatePromptHistory({
direction: "up",
entries,
historyIndex: -1,
currentPrompt: text("draft"),
currentComments: [comment("draft")],
savedPrompt: null,
})
expect(up.handled).toBe(true)
if (!up.handled) throw new Error("expected handled")
expect(up.historyIndex).toBe(0)
expect(up.cursor).toBe("start")
expect(up.entry.comments).toEqual([])
const down = navigatePromptHistory({
direction: "down",
entries,
historyIndex: up.historyIndex,
currentPrompt: text("ignored"),
currentComments: [],
savedPrompt: up.savedPrompt,
})
expect(down.handled).toBe(true)
if (!down.handled) throw new Error("expected handled")
expect(down.historyIndex).toBe(-1)
expect(down.entry.prompt[0]?.type === "text" ? down.entry.prompt[0].content : "").toBe("draft")
expect(down.entry.comments).toEqual([comment("draft")])
})
test("navigatePromptHistory keeps entry comments when moving through history", () => {
const entries = [
{
prompt: text("with comment"),
comments: [comment("c1")],
},
]
const up = navigatePromptHistory({
direction: "up",
entries,
historyIndex: -1,
currentPrompt: text("draft"),
currentComments: [],
savedPrompt: null,
})
expect(up.handled).toBe(true)
if (!up.handled) throw new Error("expected handled")
expect(up.entry.prompt[0]?.type === "text" ? up.entry.prompt[0].content : "").toBe("with comment")
expect(up.entry.comments).toEqual([comment("c1")])
})
test("upgrades stored prompt arrays once at the persistence boundary", () => {
expect(upgradeHistoryState({ entries: [text("stored")] })).toEqual({
entries: [{ prompt: text("stored"), comments: [] }],
})
})
test("helpers clone prompt and count text content length", () => {
const original: Prompt = [
{ type: "text", content: "one", start: 0, end: 3 },
{
type: "file",
path: "src/a.ts",
content: "@src/a.ts",
start: 3,
end: 12,
selection: { startLine: 1, startChar: 1, endLine: 2, endChar: 1 },
},
{ type: "image", id: "1", filename: "img.png", mime: "image/png", blob: { id: "blob", url: "blob:test" } },
]
const copy = clonePromptParts(original)
expect(copy).not.toBe(original)
expect(promptLength(copy)).toBe(12)
if (copy[1]?.type !== "file") throw new Error("expected file")
copy[1].selection!.startLine = 9
if (original[1]?.type !== "file") throw new Error("expected file")
expect(original[1].selection?.startLine).toBe(1)
})
test("canNavigateHistoryAtCursor only allows prompt boundaries", () => {
const value = "a\nb\nc"
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(false)
expect(canNavigateHistoryAtCursor("down", value, 0)).toBe(false)
expect(canNavigateHistoryAtCursor("up", value, 2)).toBe(false)
expect(canNavigateHistoryAtCursor("down", value, 2)).toBe(false)
expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false)
expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(false)
expect(canNavigateHistoryAtCursor("down", "abc", 3)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(false)
expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(false)
expect(canNavigateHistoryAtCursor("up", "", 0)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "", 0)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 0, true)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 3, true)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "abc", 0, true)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "abc", 3, true)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 1, true)).toBe(false)
expect(canNavigateHistoryAtCursor("down", "abc", 1, true)).toBe(false)
})
})
@@ -1,24 +0,0 @@
const LARGE_PASTE_CHARS = 8000
const LARGE_PASTE_BREAKS = 120
function largePaste(text: string) {
if (text.length >= LARGE_PASTE_CHARS) return true
let breaks = 0
for (const char of text) {
if (char !== "\n") continue
breaks += 1
if (breaks >= LARGE_PASTE_BREAKS) return true
}
return false
}
export function normalizePaste(text: string) {
if (!text.includes("\r")) return text
return text.replace(/\r\n?/g, "\n")
}
export function pasteMode(text: string) {
if (largePaste(text)) return "manual"
if (text.includes("\n") || text.includes("\r")) return "manual"
return "native"
}
@@ -1,63 +0,0 @@
import { describe, expect, test } from "bun:test"
import { promptDesignPlaceholder, promptPlaceholder } from "./placeholder"
describe("promptPlaceholder", () => {
const t = (key: string, params?: Record<string, string>) => `${key}${params?.example ? `:${params.example}` : ""}`
test("returns shell placeholder in shell mode", () => {
const value = promptPlaceholder({
mode: "shell",
commentCount: 0,
example: "example",
suggest: true,
t,
})
expect(value).toBe("prompt.placeholder.shell:example")
})
test("returns summarize placeholders for comment context", () => {
expect(promptPlaceholder({ mode: "normal", commentCount: 1, example: "example", suggest: true, t })).toBe(
"prompt.placeholder.summarizeComment",
)
expect(promptPlaceholder({ mode: "normal", commentCount: 2, example: "example", suggest: true, t })).toBe(
"prompt.placeholder.summarizeComments",
)
})
test("returns default placeholder with example when suggestions enabled", () => {
const value = promptPlaceholder({
mode: "normal",
commentCount: 0,
example: "translated-example",
suggest: true,
t,
})
expect(value).toBe("prompt.placeholder.normal:translated-example")
})
test("returns simple placeholder when suggestions disabled", () => {
const value = promptPlaceholder({
mode: "normal",
commentCount: 0,
example: "translated-example",
suggest: false,
t,
})
expect(value).toBe("prompt.placeholder.simple")
})
})
describe("promptDesignPlaceholder", () => {
const t = (key: string, params?: Record<string, string>) => {
if (key !== "ui.promptInput.placeholder.normal") return key
return `Ask anything, ${params?.slash} for commands, ${params?.at} for context...`
}
test("composes the design placeholder from localized fragments", () => {
expect(promptDesignPlaceholder("normal", "fallback", t)).toBe("Ask anything, / for commands, @ for context...")
})
test("preserves the shell placeholder", () => {
expect(promptDesignPlaceholder("shell", "Enter shell command...", t)).toBe("Enter shell command...")
})
})
@@ -1,24 +0,0 @@
type PromptPlaceholderInput = {
mode: "normal" | "shell"
commentCount: number
example: string
suggest: boolean
t: (key: string, params?: Record<string, string>) => string
}
export function promptPlaceholder(input: PromptPlaceholderInput) {
if (input.mode === "shell") return input.t("prompt.placeholder.shell", { example: input.example })
if (input.commentCount > 1) return input.t("prompt.placeholder.summarizeComments")
if (input.commentCount === 1) return input.t("prompt.placeholder.summarizeComment")
if (!input.suggest) return input.t("prompt.placeholder.simple")
return input.t("prompt.placeholder.normal", { example: input.example })
}
export function promptDesignPlaceholder(
mode: PromptPlaceholderInput["mode"],
placeholder: string,
t: PromptPlaceholderInput["t"],
) {
if (mode === "shell") return placeholder
return t("ui.promptInput.placeholder.normal", { slash: "/", at: "@" })
}
@@ -1,480 +0,0 @@
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
import { createStore } from "solid-js/store"
import type { Prompt, PromptStore } from "@/context/prompt"
import { ServerScope } from "@/utils/server-scope"
let createPromptSubmit: typeof import("./submit").createPromptSubmit
const createdSessions: string[] = []
type SessionCreateInput = {
agent?: string
model?: { id: string; providerID: string; variant?: string }
location?: { directory: string }
}
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
const sentShellDirectories: string[] = []
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
const sentPrompts: string[] = []
const promptInputs: unknown[] = []
const sentCommands: unknown[] = []
const switchedAgents: Array<{ sessionID: string; agent: string }> = []
const switchedModels: Array<{
sessionID: string
model: { id: string; providerID: string; variant?: string }
}> = []
const sessionRequestOrder: string[] = []
const updatedDrafts: Array<{ draftID: string; worktree?: string }> = []
const promptCaptures: Array<{ scope?: unknown; target?: unknown }> = []
const navigations: string[] = []
let serverSessionSyncs = 0
let restoredPrompts = 0
let params: { id?: string } = {}
let search: { draftId?: string } = {}
let selected = "/repo/worktree-a"
let variant: string | undefined
let createSessionGate: Promise<void> | undefined
let createWorktreeGate: Promise<void> | undefined
let worktreeFailure: Error | undefined
let locationFailure: Error | undefined
let promptFailure: Error | undefined
let worktreeCreates = 0
let activeSDK = "server-a"
let activeServer = "server-a"
let commands: Array<{ name: string }> = []
let worktreeDirectory = "/repo/new-0"
let worktreeID = 0
const sessionDirectories: Record<string, string> = {}
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const [promptStore, setPromptStore] = createStore<PromptStore>({
prompt: promptValue,
cursor: 0,
context: { items: [] },
})
const prompt = {
store: [() => promptStore, setPromptStore] as [() => PromptStore, typeof setPromptStore],
ready: Object.assign(() => true, { promise: Promise.resolve(true) }),
current: () => promptValue,
cursor: () => 0,
dirty: () => true,
model: {
current: () => undefined,
set: () => undefined,
},
reset: () => undefined,
set: () => restoredPrompts++,
context: {
add: () => undefined,
remove: () => undefined,
removeComment: () => undefined,
updateComment: () => undefined,
replaceComments: () => undefined,
items: () => [],
},
capture: (scope?: unknown, target?: unknown) => {
promptCaptures.push({ scope, target })
return prompt
},
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 0))
const clientFor = (directory: string) => {
return {
api: {
session: {
create: async (input: SessionCreateInput) => {
await createSessionGate
const location = input.location?.directory ?? directory
createdSessions.push(location)
const id = `session-${createdSessions.length}`
sessionDirectories[id] = location
return {
id,
projectID: "project",
agent: input.agent,
model: input.model,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
title: `New session ${createdSessions.length}`,
location: { directory: location },
}
},
prompt: async (input: unknown) => {
sessionRequestOrder.push("prompt")
sentPrompts.push(sessionDirectories[(input as { sessionID: string }).sessionID] ?? directory)
promptInputs.push(input)
if (promptFailure) throw promptFailure
const prompt = input as { sessionID: string; id: string; text: string }
return {
id: prompt.id,
sessionID: prompt.sessionID,
timeCreated: 1,
type: "user" as const,
delivery: "steer" as const,
payload: { text: prompt.text },
}
},
switchAgent: async (input: { sessionID: string; agent: string }) => {
sessionRequestOrder.push("agent")
switchedAgents.push(input)
},
switchModel: async (input: {
sessionID: string
model: { id: string; providerID: string; variant?: string }
}) => {
sessionRequestOrder.push("model")
switchedModels.push(input)
},
command: async (input: unknown) => {
sentCommands.push(input)
},
shell: async (input: { sessionID: string; id?: string; command: string }) => {
sentShell.push(input)
sentShellDirectories.push(sessionDirectories[input.sessionID] ?? directory)
},
},
worktree: {
create: async (_input: unknown) => {
worktreeCreates++
await createWorktreeGate
if (worktreeFailure) throw worktreeFailure
return { directory: worktreeDirectory }
},
},
location: {
get: async () => {
if (locationFailure) throw locationFailure
return { directory: worktreeDirectory }
},
},
},
session: {
command: async () => ({ data: undefined }),
abort: async () => ({ data: undefined }),
},
}
}
beforeAll(async () => {
const rootClient = clientFor("/repo/main")
mock.module("@solidjs/router", () => ({
useNavigate: () => (href: string) => navigations.push(href),
useParams: () => params,
useLocation: () => ({}),
useSearchParams: () => [search, () => undefined],
}))
mock.module("@opencode-ai/ui/toast", () => ({
Toast: { Region: () => null },
toaster: { create: () => undefined, show: () => undefined, dismiss: () => undefined },
showToast: () => 0,
}))
mock.module("@opencode-ai/util/encode", () => ({
base64Decode: (value: string) => value,
base64Encode: (value: string) => value,
checksum: (value: string) => value,
sampledChecksum: (value: string) => value,
}))
mock.module("@/context/local", () => ({
useLocal: () => ({
model: {
current: () => ({ id: "model", provider: { id: "provider" } }),
variant: { current: () => variant },
},
agent: {
current: () => ({ name: "agent" }),
},
session: {
promote: () => undefined,
},
}),
}))
mock.module("@/context/permission", () => {
return { usePermission: () => ({ currentServerState: () => ({ enableAutoAccept: () => undefined }) }) }
})
mock.module("@/context/tabs", () => ({
useTabs: () => ({
updateDraft: (draftID: string, draft: { worktree?: string }) => {
updatedDrafts.push({ draftID, ...draft })
},
promoteDraft: (draftID: string, session: { server: string; sessionId: string }) => {
promotedDrafts.push({ draftID, ...session })
},
}),
}))
mock.module("@/context/prompt", () => ({
usePrompt: () => prompt,
}))
mock.module("@/context/location", () => ({
useWorkspaceLocation: () => {
return () => ({
directory: activeSDK === "server-a" ? "/repo/main" : "/repo/other",
})
},
}))
mock.module("@/context/server-sdk", () => ({
useServerSDK: () => ({
scope: activeSDK === "server-a" ? ServerScope.local : "server-b",
api: rootClient.api,
}),
}))
mock.module("@/context/server", () => ({
useServer: () => ({ key: activeServer }),
useData: () => ({
session: {
remember: () => undefined,
setStatus: () => undefined,
// Delegates straight to the API client; optimistic admission and
// rollback are covered by the data-layer tests in packages/tui.
prompt: (input: unknown) => rootClient.api.session.prompt(input as never),
},
location: {
info: () => ({ project: { id: "project", directory: "/repo/main" } }),
command: {
list: () => commands,
},
},
}),
}))
mock.module("@/context/platform", () => ({
usePlatform: () => ({
fetch: fetch,
}),
}))
mock.module("@/context/language", () => ({
useLanguage: () => ({
t: (key: string) => key,
}),
}))
const mod = await import("./submit")
createPromptSubmit = mod.createPromptSubmit
})
beforeEach(() => {
createdSessions.length = 0
promotedDrafts.length = 0
updatedDrafts.length = 0
sentCommands.length = 0
sentPrompts.length = 0
promptInputs.length = 0
switchedAgents.length = 0
switchedModels.length = 0
sessionRequestOrder.length = 0
promptCaptures.length = 0
navigations.length = 0
restoredPrompts = 0
params = {}
search = {}
sentShell.length = 0
sentShellDirectories.length = 0
selected = "/repo/worktree-a"
variant = undefined
activeSDK = "server-a"
activeServer = "server-a"
commands = []
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
worktreeDirectory = `/repo/new-${++worktreeID}`
createSessionGate = undefined
serverSessionSyncs = 0
createWorktreeGate = undefined
worktreeFailure = undefined
locationFailure = undefined
promptFailure = undefined
worktreeCreates = 0
for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key]
})
const event = { preventDefault: () => undefined } as unknown as Event
const makeSubmit = (overrides: Partial<Parameters<typeof createPromptSubmit>[0]> = {}) =>
createPromptSubmit({
prompt,
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
...overrides,
})
describe("prompt submit worktree selection", () => {
test("admits only one concurrent new-workspace submission", async () => {
selected = "create"
let release = () => {}
createWorktreeGate = new Promise<void>((resolve) => {
release = resolve
})
const submit = makeSubmit()
const first = submit.handleSubmit(event)
const duplicate = submit.handleSubmit(event)
expect(worktreeCreates).toBe(1)
release()
await Promise.all([first, duplicate])
expect(createdSessions).toEqual([worktreeDirectory])
await settle()
expect(worktreeCreates).toBe(1)
expect(createdSessions).toHaveLength(1)
expect(sentPrompts).toEqual([worktreeDirectory])
expect(navigations).toEqual(["/server/server-a/session/session-1"])
})
test("stops when the created workspace cannot initialize", async () => {
selected = "create"
locationFailure = new Error("initialization failed")
await makeSubmit().handleSubmit(event)
expect(worktreeCreates).toBe(1)
expect(createdSessions).toEqual([])
expect(sentPrompts).toEqual([])
})
test("keeps async submission effects bound to the initiating context", async () => {
search = { draftId: "draft-1" }
let release = () => {}
createSessionGate = new Promise<void>((resolve) => {
release = resolve
})
let submitted = 0
const submit = makeSubmit({
onSubmit: () => submitted++,
})
const result = submit.handleSubmit(event)
activeSDK = "server-b"
activeServer = "server-b"
search.draftId = "draft-2"
release()
await result
await settle()
expect(updatedDrafts).toEqual([{ draftID: "draft-1", worktree: undefined }])
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "server-a", sessionId: "session-1" }])
expect(promptCaptures.at(-1)?.target).toEqual({ server: "server-a", scope: ServerScope.local })
expect(submitted).toBe(0)
})
test("switches the selected agent and model before prompting", async () => {
params = { id: "session-1" }
variant = "high"
const submit = makeSubmit({
info: () => ({
id: "session-1",
agent: "old-agent",
model: { id: "old-model", providerID: "old-provider" },
}),
})
await submit.handleSubmit(event)
await Bun.sleep(0)
expect(sentPrompts).toEqual(["/repo/main"])
expect(switchedAgents).toEqual([{ sessionID: "session-1", agent: "agent" }])
expect(switchedModels).toEqual([
{
sessionID: "session-1",
model: { id: "model", providerID: "provider", variant: "high" },
},
])
expect(sessionRequestOrder).toEqual(["agent", "model", "prompt"])
expect(promptInputs[0]).toMatchObject({
sessionID: "session-1",
text: "ls",
files: [],
agents: [],
metadata: {
displayText: "ls",
comments: [],
agent: "agent",
model: { providerID: "provider", modelID: "model", variant: "high" },
},
})
// ID minting is delegated to the data layer, which mints a client ID when
// none is supplied (covered by the data-layer tests in packages/tui).
expect((promptInputs[0] as { id?: string }).id).toBeUndefined()
})
test("restores the prompt when sending fails", async () => {
params = { id: "session-1" }
promptFailure = new Error("connection lost")
const submit = makeSubmit({
info: () => ({ id: "session-1", agent: "agent", model: { id: "model", providerID: "provider" } }),
})
await submit.handleSubmit(event)
await settle()
expect(restoredPrompts).toBe(1)
})
test("submits slash commands through the current session API", async () => {
params = { id: "session-1" }
variant = "high"
commands.push({ name: "review" })
promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }]
const submit = makeSubmit({
info: () => ({ id: "session-1" }),
})
await submit.handleSubmit(event)
await settle()
expect(sentCommands).toEqual([
{
sessionID: "session-1",
id: expect.stringMatching(/^msg_/),
command: "review",
arguments: "staged changes",
agent: "agent",
model: { id: "model", providerID: "provider", variant: "high" },
files: [],
},
])
expect(serverSessionSyncs).toBe(0)
})
test("sends an initial shell after synchronous workspace creation", async () => {
selected = "create"
const submit = makeSubmit({
mode: () => "shell",
})
await submit.handleSubmit(event)
await settle()
expect(sentShellDirectories).toEqual([worktreeDirectory])
expect(sentShell[0]).toMatchObject({
sessionID: "session-1",
command: "ls",
})
})
})
@@ -1,505 +0,0 @@
import type { Data } from "@opencode-ai/client/solid"
import { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/util/encode"
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { startTransition, type Accessor } from "solid-js"
import { useTabs } from "@/context/tabs"
import { useData } from "@/context/server"
import { useLanguage } from "@/context/language"
import { useLocal, type ModelSelection } from "@/context/local"
import { usePermission } from "@/context/permission"
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
import { useWorkspaceLocation } from "@/context/location"
import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { getDirectory } from "@opencode-ai/util/path"
import { buildPromptRequest } from "./build-prompt-request"
import { setCursorPosition } from "./editor-dom"
import { formatServerError } from "@/utils/server-errors"
import { ScopedKey } from "@/utils/server-scope"
import { createPromptSubmissionState } from "./submission-state"
import { Event } from "@opencode-ai/schema/event"
import { blobDataUrl } from "@/utils/draft-store"
import { useServer } from "@/context/server"
import { sessionHref } from "@/utils/session-route"
const submitting = new Set<string>()
export type FollowupDraft = {
sessionID: string
sessionDirectory: string
prompt: Prompt
context: (ContextItem & { key: string })[]
agent: string
model: { providerID: string; modelID: string }
variant?: string
}
type FollowupSendInput = {
api: ServerSDK["api"]["session"]
data: Data
session: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined>
draft: FollowupDraft
optimisticBusy?: boolean
}
const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? part.content : "")).join("")
const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
export async function sendFollowupDraft(input: FollowupSendInput) {
const text = draftText(input.draft.prompt)
const images = draftImages(input.draft.prompt)
const setBusy = () => {
if (!input.optimisticBusy) return
input.data.session.setStatus(input.draft.sessionID, "running")
}
const setIdle = () => {
if (!input.optimisticBusy) return
input.data.session.setStatus(input.draft.sessionID, "idle")
}
const [head, ...tail] = text.split(" ")
const cmd = head?.startsWith("/") ? head.slice(1) : undefined
if (
cmd &&
input.data.location.command.list({ directory: input.draft.sessionDirectory })?.some((item) => item.name === cmd)
) {
setBusy()
try {
await input.api.command({
sessionID: input.draft.sessionID,
id: SessionMessage.ID.create(),
command: cmd,
arguments: tail.join(" "),
agent: input.draft.agent,
model: {
id: input.draft.model.modelID,
providerID: input.draft.model.providerID,
variant: input.draft.variant,
},
files: await Promise.all(
images.map(async (attachment) => ({
uri: await blobDataUrl(attachment.blob, attachment.mime),
name: attachment.filename,
})),
),
})
return true
} catch (err) {
setIdle()
throw err
}
}
const encodedImages = await Promise.all(
images.map(async (attachment) => ({
...attachment,
dataUrl: await blobDataUrl(attachment.blob, attachment.mime),
})),
)
const request = buildPromptRequest({
prompt: input.draft.prompt,
context: input.draft.context,
images: encodedImages,
text,
sessionDirectory: input.draft.sessionDirectory,
})
setBusy()
try {
const session = input.session()
if (session?.agent !== input.draft.agent) {
await input.api.switchAgent({ sessionID: input.draft.sessionID, agent: input.draft.agent })
}
if (
session?.model?.providerID !== input.draft.model.providerID ||
session.model.id !== input.draft.model.modelID ||
(session.model.variant ?? "default") !== (input.draft.variant ?? "default")
) {
await input.api.switchModel({
sessionID: input.draft.sessionID,
model: {
id: input.draft.model.modelID,
providerID: input.draft.model.providerID,
variant: input.draft.variant,
},
})
}
// The data layer admits optimistically under a client-minted ID: the
// prompt renders immediately and rolls back if the server rejects it.
await input.data.session.prompt({
sessionID: input.draft.sessionID,
text: request.text,
files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
agents: request.agents,
metadata: {
displayText: request.displayText,
comments: request.comments,
agent: input.draft.agent,
model: {
...input.draft.model,
...(input.draft.variant ? { variant: input.draft.variant } : {}),
},
},
})
return true
} catch (err) {
setIdle()
throw err
}
}
type PromptSubmitInput = {
prompt: ReturnType<typeof usePrompt>
info: Accessor<
{ id: string; agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined
>
imageAttachments: Accessor<ImageAttachmentPart[]>
commentCount: Accessor<number>
autoAccept: Accessor<boolean>
mode: Accessor<"normal" | "shell">
working: Accessor<boolean>
editor: () => HTMLDivElement | undefined
queueScroll: () => void
promptLength: (prompt: Prompt) => number
addToHistory: (prompt: Prompt, mode: "normal" | "shell") => void
resetHistoryNavigation: () => void
setMode: (mode: "normal" | "shell") => void
setPopover: (popover: "at" | "slash" | null) => void
newSessionWorktree?: Accessor<string | undefined>
onNewSessionWorktreeReset?: () => void
shouldQueue?: Accessor<boolean>
onQueue?: (draft: FollowupDraft) => void
onAbort?: () => void
onSubmit?: () => void
model?: ModelSelection
}
export function createPromptSubmit(input: PromptSubmitInput) {
const navigate = useNavigate()
const sdk = useWorkspaceLocation()
const serverSDK = useServerSDK()
const data = useData()
const server = useServer()
const local = useLocal()
const permission = usePermission()
const prompt = input.prompt
const language = useLanguage()
const params = useParams()
const [search] = useSearchParams<{ draftId?: string }>()
const tabs = useTabs()
const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message
if (err && typeof err === "object" && "data" in err) {
const data = (err as { data?: { message?: string } }).data
if (data?.message) return data.message
}
if (err instanceof Error) return err.message
return language.t("common.requestFailed")
}
const abort = async () => {
const sessionID = params.id
if (!sessionID) return Promise.resolve()
input.onAbort?.()
return serverSDK.api.session.interrupt({ sessionID }).catch(() => {})
}
const restoreCommentItems = (
target: ReturnType<ReturnType<typeof usePrompt>["capture"]>,
items: (ContextItem & { key: string })[],
) => {
for (const item of items) {
target.context.add({
type: "file",
path: item.path,
selection: item.selection,
comment: item.comment,
commentID: item.commentID,
commentOrigin: item.commentOrigin,
preview: item.preview,
})
}
}
const clearContext = (target: ReturnType<ReturnType<typeof usePrompt>["capture"]>) => {
for (const item of target.context.items()) {
target.context.remove(item.key)
}
}
const handleSubmit = async (event: Event) => {
event.preventDefault()
const target = prompt.capture()
const submission = createPromptSubmissionState({
target,
prompt: target.current(),
context: target.context.items().slice(),
})
const currentPrompt = submission.prompt
const context = submission.context
const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
const images = input.imageAttachments().slice()
const mode = input.mode()
if (text.trim().length === 0 && images.length === 0 && input.commentCount() === 0) {
if (input.working()) void abort()
return
}
const modelSelection = input.model ?? local.model
const currentModel = modelSelection.current()
const currentAgent = local.agent.current()
const variant = modelSelection.variant.current()
if (!currentModel || !currentAgent) {
showToast({
title: language.t("prompt.toast.modelAgentRequired.title"),
description: language.t("prompt.toast.modelAgentRequired.description"),
})
return
}
const submissionSDK = sdk()
const submissionServerSDK = serverSDK
const submissionData = data
const submissionScope = submissionServerSDK.scope
const submissionServer = server.key
const projectDirectory = submissionSDK.directory
const sessionID = params.id
const isNewSession = !sessionID
const currentSession = input.info()
const draftID = search.draftId
const capturePrompt = prompt.capture
const localSession = local.session
const resetWorktree = input.onNewSessionWorktreeReset
const onSubmit = input.onSubmit
const permissionState = permission
const shouldAutoAccept = isNewSession && input.autoAccept()
const worktreeSelection = input.newSessionWorktree?.() || "main"
const submissionKey = ScopedKey.from(
submissionScope,
draftID ? `draft:${draftID}` : sessionID ? `session:${sessionID}` : `directory:${projectDirectory}`,
)
if (submitting.has(submissionKey)) return
submitting.add(submissionKey)
try {
input.addToHistory(currentPrompt, mode)
input.resetHistoryNavigation()
let sessionDirectory = projectDirectory
if (isNewSession) {
if (worktreeSelection === "create") {
const createdWorktree = await submissionServerSDK.api.worktree
.create({
projectID: submissionData.location.info({ directory: projectDirectory })?.project.id ?? "",
strategy: "git",
directory: getDirectory(
submissionData.location.info({ directory: projectDirectory })?.project.directory ?? projectDirectory,
),
})
.then(async (created) => {
await submissionServerSDK.api.location.get({ location: { directory: created.directory } })
return created
})
.catch((err) => {
showToast({
title: language.t("prompt.toast.worktreeCreateFailed.title"),
description: errorMessage(err),
})
})
if (!createdWorktree) return
sessionDirectory = createdWorktree.directory
}
if (worktreeSelection !== "main" && worktreeSelection !== "create") {
sessionDirectory = worktreeSelection
}
}
let session = currentSession
if (!session && isNewSession) {
const created = await submissionServerSDK.api.session
.create({
agent: currentAgent.name,
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
location: { directory: sessionDirectory },
})
.catch((err) => {
showToast({
title: language.t("prompt.toast.sessionCreateFailed.title"),
description: errorMessage(err),
})
return undefined
})
if (created) {
submissionData.session.remember(created)
session = created
await startTransition(() => {
if (!session) return
if (draftID) tabs.updateDraft(draftID, { worktree: undefined })
if (!draftID) resetWorktree?.()
if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
localSession.promote(sessionDirectory, session.id, {
agent: currentAgent.name,
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
variant: variant ?? null,
})
if (draftID) tabs.promoteDraft(draftID, { server: submissionServer, sessionId: session.id })
else navigate(sessionHref(submissionServer, session.id))
submission.retarget(
capturePrompt(
{ dir: base64Encode(sessionDirectory), id: session.id },
{ server: submissionServer, scope: submissionScope },
),
)
})
}
}
if (!session) {
showToast({
title: language.t("prompt.toast.promptSendFailed.title"),
description: language.t("prompt.toast.promptSendFailed.description"),
})
return
}
const model = {
modelID: currentModel.id,
providerID: currentModel.provider.id,
}
const agent = currentAgent.name
const draft: FollowupDraft = {
sessionID: session.id,
sessionDirectory,
prompt: currentPrompt,
context,
agent,
model,
variant,
}
const clearInput = () => {
submission.clear()
input.setMode("normal")
input.setPopover(null)
}
const restoreInput = () => {
const restored = submission.restore()
if (!restored) return false
restored.target.set(restored.prompt, input.promptLength(restored.prompt))
if (!submission.current(prompt.capture())) return true
input.setMode(mode)
input.setPopover(null)
requestAnimationFrame(() => {
const editor = input.editor()
if (!editor) return
editor.focus()
setCursorPosition(editor, input.promptLength(currentPrompt))
input.queueScroll()
})
return true
}
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
input.onQueue?.(draft)
clearContext(submission.target())
clearInput()
return
}
if (!draftID || search.draftId === draftID) onSubmit?.()
if (mode === "shell") {
clearInput()
const eventID = Event.ID.create()
void submissionServerSDK.api.session
.shell({
sessionID: session.id,
id: eventID,
command: text,
})
.catch((err) => {
showToast({
title: language.t("prompt.toast.shellSendFailed.title"),
description: errorMessage(err),
})
restoreInput()
})
return
}
if (text.startsWith("/")) {
const [cmdName, ...args] = text.split(" ")
const commandName = cmdName.slice(1)
const customCommand = submissionData.location.command
.list({ directory: sessionDirectory })
?.find((command) => command.name === commandName)
if (customCommand) {
clearInput()
submissionData.session.setStatus(session.id, "running")
void submissionServerSDK.api.session
.command({
sessionID: session.id,
id: SessionMessage.ID.create(),
command: commandName,
arguments: args.join(" "),
agent,
model: { id: model.modelID, providerID: model.providerID, variant },
files: await Promise.all(
images.map(async (attachment) => ({
uri: await blobDataUrl(attachment.blob, attachment.mime),
name: attachment.filename,
})),
),
})
.catch((err) => {
submissionData.session.setStatus(session.id, "idle")
showToast({
title: language.t("prompt.toast.commandSendFailed.title"),
description: formatServerError(err, language.t, language.t("common.requestFailed")),
})
restoreInput()
})
return
}
}
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
for (const item of commentItems) submission.target().context.remove(item.key)
clearInput()
void sendFollowupDraft({
api: submissionServerSDK.api.session,
data: submissionData,
session: () => session,
draft,
optimisticBusy: sessionDirectory === projectDirectory,
}).catch((err) => {
if (sessionDirectory === projectDirectory) {
submissionData.session.setStatus(session.id, "idle")
}
showToast({
title: language.t("prompt.toast.promptSendFailed.title"),
description: errorMessage(err),
})
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
})
} finally {
submitting.delete(submissionKey)
}
}
return {
abort,
handleSubmit,
}
}
@@ -1,5 +0,0 @@
import { type Component, type JSX } from "solid-js"
export const SettingsList: Component<{ children: JSX.Element }> = (props) => {
return <div class="bg-surface-base px-4 rounded-lg">{props.children}</div>
}
@@ -1 +0,0 @@
export { DialogSettings } from "./dialog-settings-v2"
@@ -1,6 +0,0 @@
import type { Component, JSX } from "solid-js"
import "../settings-v2.css"
export const SettingsListV2: Component<{ children: JSX.Element }> = (props) => {
return <div data-component="settings-v2-list">{props.children}</div>
}
@@ -1,20 +0,0 @@
import type { Component, JSX } from "solid-js"
import "../settings-v2.css"
export interface SettingsRowV2Props {
title: string | JSX.Element
description: string | JSX.Element
children: JSX.Element
}
export const SettingsRowV2: Component<SettingsRowV2Props> = (props) => {
return (
<div data-component="settings-v2-row">
<div data-slot="settings-v2-row-copy">
<div data-slot="settings-v2-row-title">{props.title}</div>
<div data-slot="settings-v2-row-description">{props.description}</div>
</div>
<div data-slot="settings-v2-row-control">{props.children}</div>
</div>
)
}
-119
View File
@@ -1,119 +0,0 @@
/**
* Taken from https://www.solid-ui.com/docs/components/drawer
* Only used in one place hence not a v2 component yet... can be promoted to ui/v2 later
*/
import type { Component, ComponentProps, JSX, ValidComponent } from "solid-js"
import { splitProps } from "solid-js"
import type { ContentProps, DescriptionProps, DynamicProps, LabelProps, OverlayProps } from "@corvu/drawer"
import DrawerPrimitive from "@corvu/drawer"
const Drawer = DrawerPrimitive
const DrawerTrigger = DrawerPrimitive.Trigger
const DrawerPortal = DrawerPrimitive.Portal
const DrawerClose = DrawerPrimitive.Close
type DrawerOverlayProps<T extends ValidComponent = "div"> = OverlayProps<T> & { class?: string }
const DrawerOverlay = <T extends ValidComponent = "div">(props: DynamicProps<T, DrawerOverlayProps<T>>) => {
const [, rest] = splitProps(props as DrawerOverlayProps, ["class"])
const drawerContext = DrawerPrimitive.useContext()
const overlayStyle = () => {
const state = drawerContext.transitionState()
if (state === "opening" || state === "closing") return undefined
const open = drawerContext.openPercentage()
return {
opacity: open,
"backdrop-filter": `blur(${4 * open}px)`,
}
}
return (
<DrawerPrimitive.Overlay
class={props.class}
classList={{
"fixed inset-0 z-[100] bg-v2-overlay-simple-overlay-scrim opacity-0 backdrop-blur-none transition-[opacity,backdrop-filter] duration-300 data-[opening]:opacity-100 data-[opening]:backdrop-blur-[4px] data-[closing]:opacity-0 data-[closing]:backdrop-blur-none": true,
}}
style={overlayStyle()}
{...rest}
/>
)
}
type DrawerContentProps<T extends ValidComponent = "div"> = ContentProps<T> & {
class?: string
children?: JSX.Element
}
const DrawerContent = <T extends ValidComponent = "div">(props: DynamicProps<T, DrawerContentProps<T>>) => {
const [, rest] = splitProps(props as DrawerContentProps, ["class", "children"])
return (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
class={props.class}
classList={{
"group/drawer-content fixed inset-y-[6px] end-[6px] start-auto z-[100] flex h-auto max-h-[calc(100vh-12px)] w-[560px] max-w-[calc(100vw-12px)] flex-col items-start rounded-[8px] bg-v2-background-bg-base p-0 shadow-[var(--v2-elevation-overlay)] data-[transitioning]:transition-transform data-[transitioning]:duration-300 md:select-none": true,
}}
{...rest}
>
{props.children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
const DrawerHeader: Component<ComponentProps<"div">> = (props) => {
const [, rest] = splitProps(props, ["class"])
return <div class={props.class} classList={{ "grid gap-1.5 p-4 text-center sm:text-left": true }} {...rest} />
}
const DrawerFooter: Component<ComponentProps<"div">> = (props) => {
const [, rest] = splitProps(props, ["class"])
return <div class={props.class} classList={{ "mt-auto flex flex-col gap-2 p-4": true }} {...rest} />
}
type DrawerTitleProps<T extends ValidComponent = "div"> = LabelProps<T> & { class?: string }
const DrawerTitle = <T extends ValidComponent = "div">(props: DynamicProps<T, DrawerTitleProps<T>>) => {
const [, rest] = splitProps(props as DrawerTitleProps, ["class"])
return (
<DrawerPrimitive.Label
class={props.class}
classList={{ "text-base font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base": true }}
{...rest}
/>
)
}
type DrawerDescriptionProps<T extends ValidComponent = "div"> = DescriptionProps<T> & {
class?: string
}
const DrawerDescription = <T extends ValidComponent = "div">(props: DynamicProps<T, DrawerDescriptionProps<T>>) => {
const [, rest] = splitProps(props as DrawerDescriptionProps, ["class"])
return (
<DrawerPrimitive.Description
class={props.class}
classList={{
"text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-v2-text-text-muted": true,
}}
{...rest}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
+83
View File
@@ -0,0 +1,83 @@
import type { Data } from "@opencode-ai/client/solid"
import type { Accessor } from "solid-js"
import type { ModelSelection } from "@/providers/models/selection"
import type { ServerSDK } from "@/runtime/server/client"
import type { ComposerStateTarget } from "./submission-state"
import type { createComposerSubmission } from "./submission-state"
export type ComposerControls = {
agents: {
available: { name: string; hidden?: boolean; mode: string }[]
options: string[]
current: string
visible: boolean
select: (name: string | undefined) => void
}
model: {
selection: ModelSelection
paid: boolean
loading: boolean
}
session: {
tabs: {
active: () => string | undefined
all: () => string[]
open: (tab: string) => void | Promise<void>
setActive: (tab: string) => void
}
reviewPanel: {
opened: () => boolean
open: () => void
}
}
}
export type ComposerSelection = {
agent: string
model: { providerID: string; modelID: string }
variant?: string
}
export type ComposerSession = {
id: string
directory: string
api: {
command: (input: Parameters<ServerSDK["api"]["session"]["command"]>[0]) => Promise<unknown>
shell: (input: Parameters<ServerSDK["api"]["session"]["shell"]>[0]) => Promise<unknown>
switchAgent: (input: Parameters<ServerSDK["api"]["session"]["switchAgent"]>[0]) => Promise<unknown>
switchModel: (input: Parameters<ServerSDK["api"]["session"]["switchModel"]>[0]) => Promise<unknown>
}
data: {
location: { command: Pick<Data["location"]["command"], "list"> }
session: {
prompt: (input: Parameters<Data["session"]["prompt"]>[0]) => Promise<unknown>
}
}
current: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined>
admitted: (messageID: string) => boolean
}
type ComposerAdapterBase = {
state: ComposerStateTarget
ready: Accessor<boolean>
controls: Accessor<ComposerControls>
working: Accessor<boolean>
submitted: () => void
}
export type ActiveComposerAdapter = ComposerAdapterBase & {
kind: "active-session"
session: () => ComposerSession
interrupt: () => Promise<void>
setEditor: (element: HTMLDivElement) => void
}
export type NewSessionComposerAdapter = ComposerAdapterBase & {
kind: "new-session"
start: (
selection: ComposerSelection,
submission: ReturnType<typeof createComposerSubmission>,
) => Promise<ComposerSession | undefined>
}
export type ComposerAdapter = ActiveComposerAdapter | NewSessionComposerAdapter
@@ -0,0 +1,43 @@
@keyframes composer-attachments-fade-left {
from {
visibility: hidden;
}
to {
visibility: visible;
}
}
@keyframes composer-attachments-fade-right {
from {
visibility: visible;
}
to {
visibility: hidden;
}
}
[data-component="composer-attachments"] {
timeline-scope: --composer-attachments-scroll;
[data-slot^="composer-attachments-fade-"] {
visibility: hidden;
}
}
@supports (animation-timeline: --composer-attachments-scroll) and (timeline-scope: --composer-attachments-scroll) {
[data-component="composer-attachments"] [data-slot="composer-attachments-scroll"] {
scroll-timeline: --composer-attachments-scroll x;
}
[data-component="composer-attachments"] [data-slot="composer-attachments-fade-left"] {
animation: composer-attachments-fade-left linear both;
animation-timeline: --composer-attachments-scroll;
animation-range: 0 0.1px;
}
[data-component="composer-attachments"] [data-slot="composer-attachments-fade-right"] {
animation: composer-attachments-fade-right linear both;
animation-timeline: --composer-attachments-scroll;
animation-range: calc(100% - 1.1px) calc(100% - 1px);
}
}
@@ -1,6 +1,6 @@
import { onMount } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import type { PromptInputV2Attachment, PromptInputV2Prompt } from "./types"
import type { ComposerAttachment, ComposerPrompt } from "../types"
const accepted = [
"image/png",
@@ -61,12 +61,12 @@ const accepted = [
]
type PromptTarget = {
current: () => PromptInputV2Prompt
current: () => ComposerPrompt
cursor: () => number | undefined
set: (prompt: PromptInputV2Prompt, cursor?: number) => void
set: (prompt: ComposerPrompt, cursor?: number) => void
}
export type PromptInputV2AttachmentConfig = {
export type ComposerAttachmentConfig = {
picker?: (
options: { defaultPath?: string; multiple?: boolean; accept?: string[] },
onFile: (file: File) => Promise<unknown>,
@@ -81,12 +81,12 @@ export type PromptInputV2AttachmentConfig = {
store?: (file: File) => Promise<{ id: string; url: string }>
}
export function createPromptInputV2Attachments(
input: PromptInputV2AttachmentConfig & {
export function createComposerAttachments(
input: ComposerAttachmentConfig & {
capture: () => PromptTarget
editor: () => HTMLElement | undefined
focusEditor: () => void
addPart: (part: PromptInputV2Prompt[number]) => boolean
addPart: (part: ComposerPrompt[number]) => boolean
setDraggingType: (type: "image" | "@mention" | null) => void
},
) {
@@ -121,9 +121,9 @@ export function createPromptInputV2Attachments(
input.duplicate()
return true
}
const attachment: PromptInputV2Attachment = {
const attachment: ComposerAttachment = {
type: "image",
id: globalThis.crypto?.randomUUID?.() ?? Math.random().toString(16).slice(2),
id: crypto.randomUUID(),
filename: file.name,
sourcePath,
mime,
@@ -1,8 +1,8 @@
import { useCommand, type CommandOption } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useLocal, type ModelSelection } from "@/context/local"
import { useCommand, type CommandOption } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { useLocal, type ModelSelection } from "@/providers/models/selection"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { getCursorPosition, setCursorPosition } from "@/components/prompt-input/editor-dom"
import { getCursorPosition, setCursorPosition } from "./editor/dom"
import { useSessionLayout } from "@/session/session-layout"
import { createSessionOwnership } from "@/session/session-ownership"
@@ -26,7 +26,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
const chooseModel = async () => {
const owner = sessionOwnership.capture()
const editor = document.querySelector<HTMLElement>('[data-component="prompt-input"]')
const editor = document.querySelector<HTMLElement>('[data-component="composer-editor"]')
const selection = window.getSelection()
const cursor =
editor && selection?.rangeCount && editor.contains(selection.anchorNode) ? getCursorPosition(editor) : null
@@ -34,13 +34,13 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
// Kobalte restores focus during its teardown effect; defer past it so the
// composer keeps focus and the caret returns to where the user left it.
requestAnimationFrame(() => {
const editor = document.querySelector<HTMLElement>('[data-component="prompt-input"]')
const editor = document.querySelector<HTMLElement>('[data-component="composer-editor"]')
if (!editor) return
editor.focus()
if (cursor !== null) setCursorPosition(editor, cursor)
})
}
const { DialogSelectModel } = await import("@/components/dialog-select-model")
const { DialogSelectModel } = await import("@/providers/models/select-dialog")
owner.run(() => {
void dialog.show(() => <DialogSelectModel model={model} />, restoreComposer)
})
@@ -1,4 +1,4 @@
import type { FileSelection } from "@/context/file"
import type { FileSelection } from "@/workspaces/files/model"
export type PromptComment = {
path: string
@@ -3,13 +3,13 @@ import { createStore, reconcile, type SetStoreFunction, type Store } from "solid
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useParams } from "@solidjs/router"
import { base64Encode } from "@opencode-ai/util/encode"
import { Persist, persisted } from "@/utils/persist"
import { useServerSDK } from "./server-sdk"
import type { ServerScope } from "@/utils/server-scope"
import { createScopedCache } from "@/utils/scoped-cache"
import { uuid } from "@/utils/uuid"
import type { SelectedLineRange } from "@/context/file"
import { useWorkspaceLocation } from "./location"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { useServerSDK } from "@/runtime/server/client"
import type { ServerScope } from "@/runtime/server/scope"
import { createScopedCache } from "@/runtime/server/scoped-cache"
import { uuid } from "@/runtime/persistence/uuid"
import type { SelectedLineRange } from "@/workspaces/files/model"
import { useWorkspaceLocation } from "@/workspaces/location"
export type LineComment = {
id: string
@@ -0,0 +1,474 @@
import { Show, createMemo, onMount, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import type { ModelSelection } from "@/providers/models/selection"
import { STORY_MODEL, emptySessionDocument, pendingAndQueuedDocument } from "@opencode-ai/session-ui/storybook"
import { Composer } from "./composer"
import type { ComposerModel } from "./model"
import { createComposerEditor } from "./editor/interaction"
import type { ComposerPersistedState, ComposerSuggestion } from "./types"
import { buildPromptRequest } from "./request"
import { SessionPreview } from "@/session/story-model"
import { Skill } from "@opencode-ai/schema/skill"
import { resolveSessionComposerSelection } from "@/session/composer/selection"
const selectedModel = {
id: STORY_MODEL.id,
providerID: STORY_MODEL.providerID,
api: { id: STORY_MODEL.id, url: "https://api.anthropic.com", npm: "@ai-sdk/anthropic" },
name: "Claude Sonnet 4",
family: "claude-sonnet",
capabilities: {
temperature: true,
reasoning: true,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: true },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: true,
},
cost: { input: 3, output: 15, cache: { read: 0.3, write: 3.75 } },
limit: { context: 200_000, output: 64_000 },
status: "active",
options: {},
headers: {},
release_date: "2025-05-22",
variants: { balanced: {}, high: {} },
provider: {
id: STORY_MODEL.providerID,
name: "Anthropic",
source: "custom",
env: [],
options: {},
models: {},
},
latest: true,
} satisfies NonNullable<ReturnType<ModelSelection["current"]>>
function ComposerStory(props: {
prompt?: ComposerPersistedState["prompt"]
comments?: ComposerPersistedState["context"]["items"]
working?: boolean
stopping?: boolean
suggestions?: "command" | "context"
failure?: boolean
label?: string
inspectRequest?: boolean
continueOnStop?: boolean
}) {
const [draft, setDraft] = createStore<ComposerPersistedState>({
prompt: props.prompt ?? [{ type: "text", content: "", start: 0, end: 0 }],
cursor: props.prompt?.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0) ?? 0,
model: { providerID: STORY_MODEL.providerID, modelID: STORY_MODEL.id, variant: STORY_MODEL.variant },
context: { items: props.comments ?? [] },
})
const [story, setStory] = createStore({
activity: props.label ?? "Ready",
variant: STORY_MODEL.variant,
})
const modelSelection = {
ready: Object.assign(() => true, { promise: undefined }),
current: () => selectedModel,
recent: () => [selectedModel],
list: () => [selectedModel],
cycle() {},
set() {},
visible: () => true,
setVisibility() {},
variant: {
configured: () => STORY_MODEL.variant,
selected: () => story.variant,
current: () => story.variant,
list: () => ["balanced", "high"],
set: (variant: string | undefined) => setStory("variant", variant ?? "balanced"),
cycle() {},
},
} satisfies ModelSelection
const commands: ComposerSuggestion[] = [
{ id: "command.test", kind: "command", label: "/test", trigger: "test", title: "Run tests" },
{ id: "command.review", kind: "command", label: "/review", trigger: "review", title: "Review changes" },
]
const context: ComposerSuggestion[] = [
{
id: "file:src/app.tsx",
kind: "file",
label: "src/app.tsx",
path: "src/app.tsx",
mention: { type: "file", path: "src/app.tsx", content: "@src/app.tsx", start: 0, end: 0 },
},
{
id: "agent:review",
kind: "agent",
label: "@review",
mention: { type: "agent", name: "review", content: "@review", start: 0, end: 0 },
},
{
id: "skill:effect",
kind: "skill",
label: "@effect",
description: "Build Effect applications",
mention: {
type: "skill",
id: Skill.ID.make("effect"),
name: Skill.Name.make("Effect"),
content: "@effect",
start: 0,
end: 0,
},
},
]
const editor = createComposerEditor({
store: [draft, setDraft],
commands: () => commands,
context: () => context,
searchContextFiles: () => [],
view: {
placeholder: () => "Ask anything, / for commands, @ for context...",
agent: {
options: () => [
{ id: "build", label: "build" },
{ id: "review", label: "review" },
],
current: () => "build",
onSelect: (agent) => setStory("activity", `Selected ${agent}`),
},
variant: {
options: () => [
{ id: "balanced", label: "balanced" },
{ id: "high", label: "high" },
],
current: () => story.variant,
onSelect: (variant) => setStory("variant", variant),
},
submit: {
stopping: () => !!props.stopping,
working: () => !!props.working,
onSubmit: () => {
const value = draft.prompt.map((part) => ("content" in part ? part.content : `[${part.filename}]`)).join("")
const request = props.inspectRequest
? buildPromptRequest({
prompt: draft.prompt,
context: draft.context.items,
images: [],
text: value,
sessionDirectory: "C:/repo",
})
: undefined
setDraft("prompt", [{ type: "text", content: "", start: 0, end: 0 }])
setDraft("cursor", 0)
if (props.failure) {
setDraft("prompt", props.prompt ?? [{ type: "text", content: "", start: 0, end: 0 }])
setStory("activity", "Submission failed; draft restored")
return
}
setStory(
"activity",
request
? JSON.stringify({ files: request.files, agents: request.agents, skills: request.skills })
: `Submitted: ${value}`,
)
},
onStop: () =>
setStory("activity", props.continueOnStop ? "POST /interrupt · continue: true" : "Stop requested"),
},
},
})
const model = {
...editor,
model: { selection: modelSelection, paid: true, loading: false },
} satisfies ComposerModel
onMount(() => {
if (props.suggestions === "command") model.openCommands()
if (props.suggestions === "context") model.openContext()
})
return (
<div class="mx-auto flex min-h-80 w-full max-w-200 flex-col justify-end gap-3 rounded-xl bg-v2-background-bg-deep p-6">
<output class="text-12-regular text-text-weak" aria-live="polite">
{story.activity}
</output>
<Composer model={model} borderUnderlay />
</div>
)
}
const text = (content: string): ComposerPersistedState["prompt"] => [
{ type: "text", content, start: 0, end: content.length },
]
export default {
title: "OpenCode/Composer/Flow",
component: Composer,
parameters: { layout: "centered" },
}
export const EmptyDraft = { render: () => <ComposerStory /> }
export const TextDraft = { render: () => <ComposerStory prompt={text("Explain this change")} /> }
export const MultilineDraft = {
render: () => <ComposerStory prompt={text("Review the implementation\nThen run the focused tests")} />,
}
export const MixedAttachments = {
render: () => (
<ComposerStory
prompt={[
{ type: "text", content: "Review ", start: 0, end: 7 },
{ type: "file", path: "src/app.tsx", content: "@src/app.tsx", start: 7, end: 19 },
{ type: "text", content: " with ", start: 19, end: 25 },
{ type: "agent", name: "review", content: "@review", start: 25, end: 32 },
{ type: "text", content: " and ", start: 32, end: 37 },
{
type: "skill",
id: Skill.ID.make("effect"),
name: Skill.Name.make("Effect"),
content: "@effect",
start: 37,
end: 44,
},
{
type: "image",
id: "image-story",
filename: "layout.png",
mime: "image/png",
blob: { id: "image-story", url: "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==" },
},
]}
comments={[
{
type: "file",
key: "comment:src/app.tsx",
path: "src/app.tsx",
selection: { startLine: 12, startChar: 0, endLine: 14, endChar: 0 },
comment: "Keep the normal flow flat",
},
]}
/>
),
}
export const ModelAndVariant = { render: () => <ComposerStory prompt={text("Compare both variants")} /> }
export const SlashSuggestions = { render: () => <ComposerStory suggestions="command" /> }
export const ContextSuggestions = { render: () => <ComposerStory suggestions="context" /> }
export const RunningAndStopping = { render: () => <ComposerStory working stopping label="Session is running" /> }
export const SteeringFollowUp = {
render: () => <ComposerStory prompt={text("Use this correction at the next boundary")} working />,
}
export const FailedSubmissionRestoration = {
render: () => <ComposerStory prompt={text("Preserve this draft on failure")} failure />,
}
export const NewSessionFirstPrompt = {
render: () => (
<ComposerStory prompt={text("Create the Session and implement the change")} label="New Session draft" />
),
}
export const ActiveSessionFollowUp = {
render: () => <ComposerStory prompt={text("Now add focused coverage")} label="Active Session follow-up" />,
}
export const RightToLeft = {
globals: { direction: "rtl" },
render: () => <ComposerStory prompt={text("راجع src/app.tsx ثم شغّل bun test")} />,
}
export const NarrowLayout = {
parameters: { viewport: { defaultViewport: "mobile1" } },
render: () => (
<div class="w-[340px]">
<ComposerStory prompt={text("Verify the narrow Composer")} />
</div>
),
}
export const DemoFirstClassSkillIDs = {
name: "Demo: First-class skill IDs",
render: () => (
<DemoFrame
title="First-class skill IDs"
description="Choose @effect, then Send. The output shows the durable skill ID sent to the prompt API."
>
<ComposerStory suggestions="context" inspectRequest label="Select a skill from the context menu" />
</DemoFrame>
),
}
export const DemoStructuredCustomCommand = {
name: "Demo: Structured custom command",
render: () => (
<DemoFrame
title="Structured custom-command input"
description="Send the draft. Files, agents, and skills remain structured instead of becoming plain command text."
>
<ComposerStory
inspectRequest
prompt={[
{ type: "text", content: "/review ", start: 0, end: 8 },
{ type: "file", path: "src/app.tsx", content: "@src/app.tsx", start: 8, end: 20 },
{ type: "text", content: " ", start: 20, end: 21 },
{ type: "agent", name: "review", content: "@review", start: 21, end: 28 },
{ type: "text", content: " ", start: 28, end: 29 },
{
type: "skill",
id: Skill.ID.make("effect"),
name: Skill.Name.make("Effect"),
content: "@effect",
start: 29,
end: 36,
},
]}
/>
</DemoFrame>
),
}
export const DemoPendingInboxHydration = {
name: "Demo: Pending inbox hydration",
render: () => <PendingInboxDemo />,
}
export const DemoServerOwnedExecutionStatus = {
name: "Demo: Server-owned execution status",
render: () => <ServerStatusDemo />,
}
export const DemoDurableSelectionPrecedence = {
name: "Demo: Durable selection precedence",
render: () => <SelectionPrecedenceDemo />,
}
export const DemoContinueOnStop = {
name: "Demo: Continue on Stop",
render: () => (
<DemoFrame
title="Continue admitted work after Stop"
description="Press Stop. The output shows the interrupt request used by the active Session adapter."
>
<ComposerStory working stopping continueOnStop label="Session execution is running" />
</DemoFrame>
),
}
function DemoFrame(props: { title: string; description: string; children: JSX.Element }) {
return (
<section class="flex w-[min(920px,calc(100vw-32px))] flex-col gap-3 rounded-xl bg-v2-background-bg-deep p-4">
<div class="flex flex-col gap-1">
<h2 class="text-16-medium text-text-strong">{props.title}</h2>
<p class="text-13-regular text-text-weak">{props.description}</p>
</div>
{props.children}
</section>
)
}
function PendingInboxDemo() {
const [store, setStore] = createStore({ hydrated: false })
return (
<DemoFrame
title="Active pending-inbox hydration"
description="Toggle hydration to simulate the active Session loading durable pending inbox rows with its messages."
>
<div class="flex flex-col gap-3">
<button
type="button"
class="self-start rounded-md bg-background-base px-3 py-2 text-13-medium text-text-strong"
onClick={() => setStore("hydrated", (value) => !value)}
>
{store.hydrated ? "Clear pending data" : "Hydrate pending data"}
</button>
<Show
when={store.hydrated}
fallback={<SessionPreview title="Pending inbox" description="Not hydrated" document={emptySessionDocument} />}
>
<SessionPreview
title="Pending inbox"
description="Hydrated from Client Data"
document={pendingAndQueuedDocument}
/>
</Show>
</div>
</DemoFrame>
)
}
function ServerStatusDemo() {
const [store, setStore] = createStore({ running: false, activity: "Idle from server projection" })
const document = createMemo(() => ({
...emptySessionDocument,
status: store.running ? ({ type: "busy" } as const) : ({ type: "idle" } as const),
}))
return (
<DemoFrame
title="Server-owned execution status"
description="Submitting does not force running or idle. Only the simulated execution event changes status."
>
<div class="flex flex-col gap-3">
<div class="flex gap-2">
<button
type="button"
class="rounded-md bg-background-base px-3 py-2 text-13-medium text-text-strong"
onClick={() => setStore("activity", "Prompt admitted; status unchanged")}
>
Admit prompt
</button>
<button
type="button"
class="rounded-md bg-background-base px-3 py-2 text-13-medium text-text-strong"
onClick={() => {
setStore("running", (value) => !value)
setStore("activity", store.running ? "execution.started" : "execution.succeeded")
}}
>
Toggle execution event
</button>
</div>
<output class="text-12-regular text-text-weak">{store.activity}</output>
<SessionPreview title="Execution status" description={store.activity} document={document()} />
</div>
</DemoFrame>
)
}
function SelectionPrecedenceDemo() {
const [store, setStore] = createStore({ durable: true })
const selection = createMemo(() =>
resolveSessionComposerSelection(
store.durable ? { agent: "build", model: { id: "claude-sonnet-4", providerID: "anthropic" } } : undefined,
{ agent: "review", model: { modelID: "gpt-5", providerID: "openai" } },
),
)
return (
<DemoFrame
title="Durable Session selection precedence"
description="The current Session model wins over historical message metadata. Clear it to see the history fallback."
>
<div class="flex flex-col gap-3">
<div class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-13-regular">
<span class="text-text-weak">SessionInfo.model</span>
<strong class="text-text-strong">{store.durable ? "anthropic/claude-sonnet-4" : "Unavailable"}</strong>
<span class="text-text-weak">Last message metadata</span>
<strong class="text-text-strong">openai/gpt-5</strong>
<span class="text-text-weak">Resolved selection</span>
<strong class="text-text-strong">
{selection().model ? `${selection().model?.providerID}/${selection().model?.modelID}` : "Unavailable"}
</strong>
</div>
<button
type="button"
class="self-start rounded-md bg-background-base px-3 py-2 text-13-medium text-text-strong"
onClick={() => setStore("durable", (value) => !value)}
>
{store.durable ? "Remove durable Session state" : "Restore durable Session state"}
</button>
<ComposerStory prompt={text("Continue with the resolved Session selection")} label="Composer is ready" />
</div>
</DemoFrame>
)
}
+133
View File
@@ -0,0 +1,133 @@
import { Show, createMemo } from "solid-js"
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { Keybind } from "@opencode-ai/ui/keybind"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { ComposerEditor } from "./editor/editor"
import { ModelSelectorPopover } from "@/providers/models/select-dialog"
import { DialogSelectModelUnpaid } from "@/providers/models/unpaid"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import type { ComposerModel } from "./model"
export function Composer(props: {
class?: string
model: ComposerModel
borderUnderlay?: boolean
accentSubmit?: boolean
}) {
const dialog = useDialog()
const command = useCommand()
const language = useLanguage()
return (
<div class="flex flex-col gap-3">
<ComposerEditor
controller={props.model}
accentSubmit={props.accentSubmit}
borderUnderlay={props.borderUnderlay}
class={props.class}
variantControlVisible={!props.model.model.loading}
attachKeybind={command.keybindParts("file.attach")}
attachShortcut={command.keybind("file.attach")}
modelControl={
<ComposerModelControl
loading={props.model.model.loading}
paid={props.model.model.paid}
title={language.t("command.model.choose")}
keybind={command.keybindParts("model.choose")}
model={props.model.model.selection}
providerID={props.model.model.selection.current()?.provider?.id}
modelName={props.model.model.selection.current()?.name ?? language.t("dialog.model.select.title")}
onClose={props.model.restoreFocus}
onUnpaidClick={() => dialog.show(() => <DialogSelectModelUnpaid model={props.model.model.selection} />)}
/>
}
/>
</div>
)
}
function ComposerModelControl(props: {
loading: boolean
paid: boolean
title: string
keybind: string[]
model: ComposerModel["model"]["selection"]
providerID?: string
modelName: string
onClose: () => void
onUnpaidClick: () => void
}) {
const shouldAnimate = createMemo<boolean>((previous) => previous ?? props.loading)
const content = () => (
<>
<Show when={props.providerID}>
{(providerID) => (
<ProviderIcon
id={providerID()}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
)}
</Show>
<span class="truncate leading-4">{props.modelName}</span>
<span class="-ml-0.5 -mr-1 flex shrink-0">
<Icon name="chevron-down" />
</span>
</>
)
return (
<Show when={!props.loading}>
<Tooltip
placement="top"
gutter={4}
value={
<>
{props.title}
<Keybind keys={props.keybind} variant="neutral" />
</>
}
>
<Show
when={props.paid}
fallback={
<Button
data-action="composer-model"
data-control-type="dialog"
variant="ghost-muted"
size="normal"
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
classList={{ "animate-in fade-in": shouldAnimate() }}
style={{ height: "28px" }}
onClick={props.onUnpaidClick}
>
{content()}
</Button>
}
>
<ModelSelectorPopover
model={props.model}
trigger={(triggerProps) => (
<Button
{...triggerProps}
variant="ghost-muted"
size="normal"
style={{ height: "28px" }}
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
classList={{ "animate-in fade-in": shouldAnimate() }}
data-action="composer-model"
data-control-type="popover"
>
{content()}
</Button>
)}
onClose={props.onClose}
/>
</Show>
</Tooltip>
</Show>
)
}
@@ -1,11 +1,13 @@
import { describe, expect, test } from "bun:test"
import { createStore } from "solid-js/store"
import type { PromptInputV2PersistedState } from "./types"
import { createPromptInputV2Store } from "./store"
import type { ComposerPersistedState } from "../types"
import { createComposerEditorActions } from "./actions"
const context = { key: "file:src/index.ts", type: "file" as const, path: "src/index.ts" }
function createPromptStore() {
return createPromptInputV2Store(
createStore<PromptInputV2PersistedState>({
return createComposerEditorActions(
createStore<ComposerPersistedState>({
prompt: [
{ type: "text", content: "old", start: 0, end: 3 },
{
@@ -18,19 +20,19 @@ function createPromptStore() {
],
cursor: 3,
model: { providerID: "anthropic", modelID: "claude-sonnet", variant: null },
context: { items: [] },
context: { items: [context] },
}),
)
}
describe("prompt input v2 store", () => {
describe("Composer store", () => {
test("accepts an accessor for the backing store", () => {
const [state, setState] = createStore<PromptInputV2PersistedState>({
const [state, setState] = createStore<ComposerPersistedState>({
prompt: [{ type: "text", content: "", start: 0, end: 0 }],
cursor: 0,
context: { items: [] },
})
const prompt = createPromptInputV2Store([() => state, setState])
const prompt = createComposerEditorActions([() => state, setState])
prompt.setText("accessed")
@@ -57,7 +59,7 @@ describe("prompt input v2 store", () => {
})
test("inserts text without flattening structured mentions", () => {
const [state, setState] = createStore<PromptInputV2PersistedState>({
const [state, setState] = createStore<ComposerPersistedState>({
prompt: [
{ type: "text", content: "A ", start: 0, end: 2 },
{ type: "file", path: "one", content: "@one", start: 2, end: 6 },
@@ -66,7 +68,7 @@ describe("prompt input v2 store", () => {
cursor: 2,
context: { items: [] },
})
const prompt = createPromptInputV2Store([state, setState])
const prompt = createComposerEditorActions([state, setState])
prompt.addText("X\nY")
@@ -78,39 +80,21 @@ describe("prompt input v2 store", () => {
expect(prompt.state.cursor).toBe(5)
})
test("mutates context, attachments, and model through shared actions", () => {
test("mutates mentions, attachments, and context through editor actions", () => {
const prompt = createPromptStore()
const context = { key: "file:src/index.ts", type: "file" as const, path: "src/index.ts" }
prompt.addContext(context)
prompt.addContext(context)
prompt.addMention({ type: "file", path: "src/app.ts", content: "@src/app.ts", start: 0, end: 0 })
prompt.removeAttachment("attachment-1")
prompt.setVariant("thinking")
prompt.removeContext(context.key)
expect(prompt.state.context.items).toEqual([context])
expect(prompt.state.context.items).toEqual([])
expect(prompt.state.prompt).toEqual([
{ type: "text", content: "old", start: 0, end: 3 },
{ type: "file", path: "src/app.ts", content: "@src/app.ts", start: 3, end: 14 },
{ type: "text", content: " ", start: 14, end: 15 },
])
expect(prompt.state.model?.variant).toBe("thinking")
prompt.removeContext(context.key)
prompt.setPrompt([{ type: "text", content: "old", start: 0, end: 3 }], 3)
prompt.setModel(undefined)
expect(prompt.state.context.items).toEqual([])
expect(prompt.state.prompt).toEqual([{ type: "text", content: "old", start: 0, end: 3 }])
expect(prompt.state.model).toBeUndefined()
})
test("resets the prompt and cursor", () => {
const prompt = createPromptStore()
prompt.reset()
expect(prompt.state.prompt).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
expect(prompt.state.cursor).toBe(0)
})
})
@@ -1,50 +1,55 @@
import { batch, type Accessor } from "solid-js"
import type { SetStoreFunction, Store } from "solid-js/store"
import type {
PromptInputV2AgentPart,
PromptInputV2Attachment,
PromptInputV2Comment,
PromptInputV2FilePart,
PromptInputV2Model,
PromptInputV2PersistedState,
PromptInputV2Prompt,
} from "./types"
ComposerAgentPart,
ComposerFilePart,
ComposerSkillPart,
ComposerPersistedState,
ComposerPrompt,
} from "../types"
export type PromptInputV2StoreTuple = [
Store<PromptInputV2PersistedState> | Accessor<Store<PromptInputV2PersistedState>>,
SetStoreFunction<PromptInputV2PersistedState>,
export type ComposerStateStore = [
Store<ComposerPersistedState> | Accessor<Store<ComposerPersistedState>>,
SetStoreFunction<ComposerPersistedState>,
]
export type PromptInputV2StoreInput = PromptInputV2StoreTuple | Accessor<PromptInputV2StoreTuple>
export type ComposerStateStoreInput = ComposerStateStore | Accessor<ComposerStateStore>
export function createPromptInputV2Store(input: PromptInputV2StoreInput) {
export function createComposerEditorActions(input: ComposerStateStoreInput) {
const tuple = () => (typeof input === "function" ? input() : input)
const store = () => {
const value = tuple()[0]
return typeof value === "function" ? value() : value
}
const setStore = () => tuple()[1]
const clearRetry = () => setStore()("retry", undefined)
return {
get state() {
return store()
},
setPrompt(prompt: PromptInputV2Prompt, cursor?: number) {
setPrompt(prompt: ComposerPrompt, cursor?: number) {
batch(() => {
setStore()("prompt", prompt)
if (cursor !== undefined) setStore()("cursor", cursor)
clearRetry()
})
},
setCursor(cursor: number) {
setStore()("cursor", cursor)
},
setMode(mode: "normal" | "shell") {
setStore()("mode", mode)
clearRetry()
},
setText(content: string) {
batch(() => {
setStore()("prompt", (prompt) => [
{ type: "text", content, start: 0, end: content.length },
...prompt.filter((part) => part.type !== "text"),
...prompt.filter((part) => part.type === "image"),
])
setStore()("cursor", content.length)
clearRetry()
})
},
addText(content: string) {
@@ -52,28 +57,14 @@ export function createPromptInputV2Store(input: PromptInputV2StoreInput) {
batch(() => {
setStore()("prompt", (prompt) => insertText(prompt, cursor, content))
setStore()("cursor", cursor + content.length)
clearRetry()
})
},
reset() {
batch(() => {
setStore()("prompt", [{ type: "text", content: "", start: 0, end: 0 }])
setStore()("cursor", 0)
})
},
setModel(model: PromptInputV2Model | undefined) {
setStore()("model", model)
},
setVariant(variant: string | null) {
if (store().model) setStore()("model", "variant", variant)
},
addContext(item: PromptInputV2Comment) {
if (store().context.items.some((entry) => entry.key === item.key)) return
setStore()("context", "items", (items) => [...items, item])
},
removeContext(key: string) {
setStore()("context", "items", (items) => items.filter((item) => item.key !== key))
clearRetry()
},
addMention(mention: PromptInputV2FilePart | PromptInputV2AgentPart) {
addMention(mention: ComposerFilePart | ComposerAgentPart | ComposerSkillPart) {
const text = store()
.prompt.map((part) => ("content" in part ? part.content : ""))
.join("")
@@ -81,22 +72,19 @@ export function createPromptInputV2Store(input: PromptInputV2StoreInput) {
const start = text.slice(0, end).lastIndexOf("@")
setStore()("prompt", insertMention(store().prompt, start < 0 ? end : start, end, mention))
setStore()("cursor", (start < 0 ? end : start) + mention.content.length + 1)
},
addAttachment(attachment: PromptInputV2Attachment) {
setStore()("prompt", (prompt) => [...prompt, attachment])
clearRetry()
},
removeAttachment(id: string) {
setStore()("prompt", (parts) => parts.filter((part) => part.type !== "image" || part.id !== id))
clearRetry()
},
}
}
export type PromptInputV2Store = ReturnType<typeof createPromptInputV2Store>
function insertText(prompt: PromptInputV2Prompt, cursor: number, content: string): PromptInputV2Prompt {
function insertText(prompt: ComposerPrompt, cursor: number, content: string): ComposerPrompt {
let position = 0
let inserted = false
const parts = prompt.flatMap<PromptInputV2Prompt[number]>((part) => {
const parts = prompt.flatMap<ComposerPrompt[number]>((part) => {
if (part.type === "image") return [part]
const start = position
position += part.content.length
@@ -115,13 +103,13 @@ function insertText(prompt: PromptInputV2Prompt, cursor: number, content: string
}
function insertMention(
prompt: PromptInputV2Prompt,
prompt: ComposerPrompt,
start: number,
end: number,
mention: PromptInputV2FilePart | PromptInputV2AgentPart,
): PromptInputV2Prompt {
mention: ComposerFilePart | ComposerAgentPart | ComposerSkillPart,
): ComposerPrompt {
let position = 0
const parts = prompt.flatMap<PromptInputV2Prompt[number]>((part) => {
const parts = prompt.flatMap<ComposerPrompt[number]>((part) => {
if (part.type === "image") return [part]
const partStart = position
position += part.content.length
@@ -137,7 +125,7 @@ function insertMention(
return withOffsets(parts)
}
function withOffsets(prompt: PromptInputV2Prompt): PromptInputV2Prompt {
function withOffsets(prompt: ComposerPrompt): ComposerPrompt {
let offset = 0
return prompt.map((part) => {
if (part.type === "image") return part
@@ -147,6 +135,6 @@ function withOffsets(prompt: PromptInputV2Prompt): PromptInputV2Prompt {
})
}
function promptLength(prompt: PromptInputV2Prompt) {
function promptLength(prompt: ComposerPrompt) {
return prompt.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0)
}
@@ -0,0 +1,55 @@
import { describe, expect, test } from "bun:test"
import { getCursorPosition, getNodeLength, getTextLength, setCursorPosition } from "./dom"
describe("Composer editor DOM", () => {
test("length helpers treat breaks as one char and ignore zero-width chars", () => {
const container = document.createElement("div")
container.appendChild(document.createTextNode("ab\u200B"))
container.appendChild(document.createElement("br"))
container.appendChild(document.createTextNode("cd"))
expect(getNodeLength(container.childNodes[0]!)).toBe(2)
expect(getNodeLength(container.childNodes[1]!)).toBe(1)
expect(getTextLength(container)).toBe(5)
})
test("setCursorPosition and getCursorPosition round-trip with pills and breaks", () => {
const container = document.createElement("div")
const pill = document.createElement("span")
pill.dataset.mention = "file"
pill.textContent = "@file"
container.appendChild(document.createTextNode("ab"))
container.appendChild(pill)
container.appendChild(document.createElement("br"))
container.appendChild(document.createTextNode("cd"))
document.body.appendChild(container)
setCursorPosition(container, 2)
expect(getCursorPosition(container)).toBe(2)
setCursorPosition(container, 7)
expect(getCursorPosition(container)).toBe(7)
setCursorPosition(container, 8)
expect(getCursorPosition(container)).toBe(8)
container.remove()
})
test("setCursorPosition and getCursorPosition round-trip across blank lines", () => {
const container = document.createElement("div")
container.appendChild(document.createTextNode("a"))
container.appendChild(document.createElement("br"))
container.appendChild(document.createElement("br"))
container.appendChild(document.createTextNode("b"))
document.body.appendChild(container)
setCursorPosition(container, 2)
expect(getCursorPosition(container)).toBe(2)
setCursorPosition(container, 3)
expect(getCursorPosition(container)).toBe(3)
container.remove()
})
})
@@ -1,32 +1,3 @@
const MAX_BREAKS = 200
export function createTextFragment(content: string): DocumentFragment {
const fragment = document.createDocumentFragment()
let breaks = 0
for (const char of content) {
if (char !== "\n") continue
breaks += 1
if (breaks > MAX_BREAKS) {
const tail = content.endsWith("\n")
const text = tail ? content.slice(0, -1) : content
if (text) fragment.appendChild(document.createTextNode(text))
if (tail) fragment.appendChild(document.createElement("br"))
return fragment
}
}
const segments = content.split("\n")
segments.forEach((segment, index) => {
if (segment) {
fragment.appendChild(document.createTextNode(segment))
}
if (index < segments.length - 1) {
fragment.appendChild(document.createElement("br"))
}
})
return fragment
}
export function getNodeLength(node: Node): number {
if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
return (node.textContent ?? "").replace(/\u200B/g, "").length
@@ -59,9 +30,7 @@ export function setCursorPosition(parent: HTMLElement, position: number) {
while (node) {
const length = getNodeLength(node)
const isText = node.nodeType === Node.TEXT_NODE
const isPill =
node.nodeType === Node.ELEMENT_NODE &&
((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
const isPill = node.nodeType === Node.ELEMENT_NODE && !!(node as HTMLElement).dataset.mention
const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
if (isText && remaining <= length) {
@@ -116,33 +85,3 @@ export function setCursorPosition(parent: HTMLElement, position: number) {
fallbackSelection?.removeAllRanges()
fallbackSelection?.addRange(fallbackRange)
}
export function setRangeEdge(parent: HTMLElement, range: Range, edge: "start" | "end", offset: number) {
let remaining = offset
const nodes = Array.from(parent.childNodes)
for (const node of nodes) {
const length = getNodeLength(node)
const isText = node.nodeType === Node.TEXT_NODE
const isPill =
node.nodeType === Node.ELEMENT_NODE &&
((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
if (isText && remaining <= length) {
if (edge === "start") range.setStart(node, remaining)
if (edge === "end") range.setEnd(node, remaining)
return
}
if ((isPill || isBreak) && remaining <= length) {
if (edge === "start" && remaining === 0) range.setStartBefore(node)
if (edge === "start" && remaining > 0) range.setStartAfter(node)
if (edge === "end" && remaining === 0) range.setEndBefore(node)
if (edge === "end" && remaining > 0) range.setEndAfter(node)
return
}
remaining -= length
}
}
@@ -0,0 +1,3 @@
[data-component="composer-editor"]:empty::before {
content: "\200B";
}
@@ -1,4 +1,4 @@
import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js"
import { createEffect, createMemo, For, Show, type JSX } from "solid-js"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -8,33 +8,34 @@ import { Button } from "@opencode-ai/ui/button"
import { Keybind } from "@opencode-ai/ui/keybind"
import { Menu } from "@opencode-ai/ui/menu"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { AttachmentCardV2 } from "../attachment-card-v2"
import { CommentCardV2 } from "../comment-card-v2"
import { typeLabel } from "../../../components/message-file"
import { AttachmentCard } from "@opencode-ai/session-ui/attachment-card"
import { CommentCard } from "@opencode-ai/session-ui/comment-card"
import { typeLabel } from "@opencode-ai/session-ui/message-file"
import { Skill } from "@opencode-ai/schema/skill"
import type {
PromptInputV2Attachment,
PromptInputV2Comment,
PromptInputV2Option,
PromptInputV2PersistedState,
PromptInputV2Prompt,
PromptInputV2Suggestion,
} from "./types"
import type { PromptInputV2Interaction, PromptInputV2SelectControl } from "./interaction"
import "./attachments.css"
ComposerAttachment,
ComposerComment,
ComposerOption,
ComposerPersistedState,
ComposerPrompt,
ComposerSuggestion,
} from "../types"
import type { ComposerEditorModel, ComposerSelectControl } from "./interaction"
import "../attachments/attachments.css"
import "./editor.css"
export type {
PromptInputV2Attachment,
PromptInputV2Comment,
PromptInputV2Option,
PromptInputV2PersistedState,
PromptInputV2Suggestion,
} from "./types"
ComposerAttachment,
ComposerComment,
ComposerOption,
ComposerPersistedState,
ComposerSuggestion,
} from "../types"
export type PromptInputV2Mode = "normal" | "shell"
export type ComposerMode = "normal" | "shell"
export type PromptInputV2Props = {
controller: PromptInputV2Interaction
export type ComposerEditorProps = {
controller: ComposerEditorModel
accentSubmit?: boolean
disabled?: boolean
readOnly?: boolean
@@ -46,7 +47,7 @@ export type PromptInputV2Props = {
attachShortcut?: string
}
export function PromptInputV2(props: PromptInputV2Props) {
export function ComposerEditor(props: ComposerEditorProps) {
const i18n = useI18n()
const state = props.controller.state
const view = props.controller.view
@@ -54,7 +55,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
let localInput = false
const updateCursor = () => {
if (!editor || !window.getSelection()?.isCollapsed) return
props.controller.onCursor(promptInputV2Cursor(editor))
props.controller.onCursor(composerCursor(editor))
}
const mode = createMemo(() => state.mode)
const buttons = createMemo(() => ({
@@ -70,7 +71,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
localInput = false
return
}
renderPromptInputV2Editor(editor, parts)
renderComposerEditor(editor, parts)
})
return (
@@ -88,7 +89,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
}}
/>
<Show when={state.popover.type !== "closed"}>
<PromptInputV2Popover
<ComposerEditorPopover
emptyLabel={i18n.t("ui.promptInput.noMatchingItems")}
items={props.controller.suggestions()}
activeID={state.popover.type === "closed" ? undefined : state.popover.activeID}
@@ -108,9 +109,9 @@ export function PromptInputV2(props: PromptInputV2Props) {
/>
</Show>
<form
data-component="prompt-input-v2"
data-dock-border-underlay={props.borderUnderlay ? "v2" : undefined}
class="group/prompt-input relative min-h-[96px] w-full overflow-clip rounded-xl bg-v2-background-bg-base"
data-component="composer"
data-dock-border-underlay={props.borderUnderlay ? "true" : undefined}
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl bg-v2-background-bg-base"
classList={{
"shadow-[var(--v2-elevation-raised)]": !props.borderUnderlay,
"border border-v2-icon-icon-info border-dashed": state.drag === "active",
@@ -131,7 +132,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
</Show>
<Show when={state.mode === "normal"}>
<PromptInputV2Attachments
<ComposerAttachments
attachments={props.controller.attachments()}
comments={props.controller.comments()}
activeCommentID={state.activeContextID}
@@ -148,9 +149,9 @@ export function PromptInputV2(props: PromptInputV2Props) {
ref={(element) => {
editor = element
props.controller.setEditor(element)
renderPromptInputV2Editor(element, props.controller.parts())
renderComposerEditor(element, props.controller.parts())
}}
data-component="prompt-input"
data-component="composer-editor"
role="textbox"
aria-multiline="true"
aria-label={i18n.t("ui.promptInput.label")}
@@ -168,8 +169,8 @@ export function PromptInputV2(props: PromptInputV2Props) {
"text-align": "start",
}}
onInput={(event) => {
const cursor = promptInputV2Cursor(event.currentTarget)
const prompt = parsePromptInputV2Editor(event.currentTarget)
const cursor = composerCursor(event.currentTarget)
const prompt = parseComposerEditor(event.currentTarget)
const images = props.controller.parts().filter((part) => part.type === "image")
localInput = true
props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...images], cursor)
@@ -189,8 +190,10 @@ export function PromptInputV2(props: PromptInputV2Props) {
/>
<Show when={!props.controller.value()}>
<div
dir={state.mode === "normal" ? "auto" : "ltr"}
class="pointer-events-none absolute inset-x-0 top-0 px-4 pt-4 text-[13px] font-[440] leading-5 text-v2-text-text-faint"
classList={{ "font-mono!": state.mode === "shell" }}
style={{ "unicode-bidi": state.mode === "normal" ? "plaintext" : undefined, "text-align": "start" }}
>
{view.placeholder?.() ??
(state.mode === "shell"
@@ -207,7 +210,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
inert={state.mode === "shell" ? true : undefined}
style={buttons()}
>
<PromptInputV2AddMenu
<ComposerEditorAddMenu
disabled={state.mode === "shell"}
title={i18n.t("ui.promptInput.add")}
keybind={props.attachKeybind ?? ["Mod", "U"]}
@@ -223,34 +226,18 @@ export function PromptInputV2(props: PromptInputV2Props) {
/>
<Show when={view.agent} keyed>
{(control) => (
<PromptInputV2ConfiguredSelect
<ComposerEditorConfiguredSelect
title={i18n.t("ui.promptInput.chooseAgent")}
keybind={["Mod", "."]}
control={control}
/>
)}
</Show>
<Show
when={props.modelControl}
fallback={
<Show when={view.model} keyed>
{(control) => (
<PromptInputV2ConfiguredSelect
title={i18n.t("ui.promptInput.chooseModel")}
keybind={["Mod", "M"]}
control={control}
model
/>
)}
</Show>
}
>
{props.modelControl}
</Show>
{props.modelControl}
<Show when={(props.variantControlVisible ?? true) && view.variant} keyed>
{(control) => (
<Show when={control.options().length > 1}>
<PromptInputV2ConfiguredSelect
<ComposerEditorConfiguredSelect
title={i18n.t("ui.promptInput.chooseVariant")}
keybind={["Shift", "Mod", "D"]}
control={control}
@@ -259,7 +246,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
)}
</Show>
</div>
<PromptInputV2SubmitButton
<ComposerEditorSubmitButton
mode={state.mode}
stopping={view.submit.stopping()}
disabled={!props.controller.canSubmit()}
@@ -275,18 +262,27 @@ export function PromptInputV2(props: PromptInputV2Props) {
)
}
function renderPromptInputV2Editor(editor: HTMLDivElement, prompt: PromptInputV2Prompt) {
const mentionParts = new WeakMap<HTMLElement, Exclude<ComposerPrompt[number], ComposerAttachment | { type: "text" }>>()
function renderComposerEditor(editor: HTMLDivElement, prompt: ComposerPrompt) {
const active = document.activeElement === editor
editor.replaceChildren(
...prompt.flatMap<Node>((part) => {
if (part.type === "image") return []
if (part.type === "text") return [document.createTextNode(part.content)]
const mention = document.createElement("span")
mentionParts.set(mention, part)
mention.textContent = part.content
mention.contentEditable = "false"
mention.dir = "auto"
mention.style.unicodeBidi = "isolate"
mention.dataset.mention =
part.type === "file" && part.mime === "application/x-directory" ? "reference" : part.type
if (part.type === "agent") mention.dataset.name = part.name
if (part.type === "skill") {
mention.dataset.id = part.id
mention.dataset.name = part.name
}
if (part.type === "file") {
mention.dataset.path = part.path
if (part.mime) mention.dataset.mime = part.mime
@@ -304,8 +300,8 @@ function renderPromptInputV2Editor(editor: HTMLDivElement, prompt: PromptInputV2
selection?.addRange(range)
}
function parsePromptInputV2Editor(editor: HTMLDivElement) {
const parts: Exclude<PromptInputV2Prompt[number], PromptInputV2Attachment>[] = []
function parseComposerEditor(editor: HTMLDivElement) {
const parts: Exclude<ComposerPrompt[number], ComposerAttachment>[] = []
let buffer = ""
let position = 0
@@ -318,8 +314,10 @@ function parsePromptInputV2Editor(editor: HTMLDivElement) {
const mention = (element: HTMLElement) => {
flush()
const content = element.textContent ?? ""
const original = mentionParts.get(element)
if (element.dataset.mention === "agent") {
parts.push({
...(original?.type === "agent" ? original : {}),
type: "agent",
name: element.dataset.name ?? content.slice(1),
content,
@@ -329,7 +327,21 @@ function parsePromptInputV2Editor(editor: HTMLDivElement) {
position += content.length
return
}
if (element.dataset.mention === "skill") {
parts.push({
...(original?.type === "skill" ? original : {}),
type: "skill",
id: Skill.ID.make(element.dataset.id ?? content.slice(1)),
name: Skill.Name.make(element.dataset.name ?? content.slice(1)),
content,
start: position,
end: position + content.length,
})
position += content.length
return
}
parts.push({
...(original?.type === "file" ? original : {}),
type: "file",
path: element.dataset.path ?? content.slice(1),
content,
@@ -372,7 +384,7 @@ function parsePromptInputV2Editor(editor: HTMLDivElement) {
return [{ type: "text" as const, content: "", start: 0, end: 0 }]
}
function promptInputV2Cursor(editor: HTMLDivElement) {
function composerCursor(editor: HTMLDivElement) {
const selection = window.getSelection()
if (!selection?.rangeCount || !editor.contains(selection.anchorNode)) return editor.textContent?.length ?? 0
const range = selection.getRangeAt(0).cloneRange()
@@ -381,22 +393,22 @@ function promptInputV2Cursor(editor: HTMLDivElement) {
return range.toString().length
}
export function PromptInputV2Attachments(props: {
attachments: PromptInputV2Attachment[]
comments?: PromptInputV2Comment[]
export function ComposerAttachments(props: {
attachments: ComposerAttachment[]
comments?: ComposerComment[]
activeCommentID?: string
removeLabel: string
onAttachmentClick?: (attachment: PromptInputV2Attachment) => void
onAttachmentRemove: (attachment: PromptInputV2Attachment) => void
onCommentClick?: (comment: PromptInputV2Comment) => void
onCommentRemove?: (comment: PromptInputV2Comment) => void
onAttachmentClick?: (attachment: ComposerAttachment) => void
onAttachmentRemove: (attachment: ComposerAttachment) => void
onCommentClick?: (comment: ComposerComment) => void
onCommentRemove?: (comment: ComposerComment) => void
}) {
const i18n = useI18n()
return (
<Show when={props.attachments.length > 0 || (props.comments?.length ?? 0) > 0}>
<div data-component="prompt-input-v2-attachments" data-slot="prompt-attachments" class="relative">
<div data-component="composer-attachments" data-slot="composer-attachments" class="relative">
<div
data-slot="prompt-attachments-scroll"
data-slot="composer-attachments-scroll"
class="flex flex-nowrap gap-2 overflow-x-auto no-scrollbar px-2 pt-2 pb-1"
>
<For each={props.comments ?? []}>
@@ -408,7 +420,7 @@ export function PromptInputV2Attachments(props: {
openDelay={800}
contentClass="max-w-[300px] break-words"
>
<CommentCardV2
<CommentCard
comment={comment.comment ?? ""}
path={comment.path}
selection={comment.selection}
@@ -434,9 +446,9 @@ export function PromptInputV2Attachments(props: {
<Show
when={attachment.mime.startsWith("image/")}
fallback={
<AttachmentCardV2 title={attachment.filename}>
<AttachmentCard title={attachment.filename}>
{typeLabel(attachment.filename, attachment.mime, i18n.t("ui.common.file"))}
</AttachmentCardV2>
</AttachmentCard>
}
>
<img
@@ -461,11 +473,11 @@ export function PromptInputV2Attachments(props: {
</For>
</div>
<div
data-slot="prompt-attachments-fade-left"
data-slot="composer-attachments-fade-left"
class="pointer-events-none absolute inset-y-0 start-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-base),transparent)] rtl:bg-[linear-gradient(to_left,var(--v2-background-bg-base),transparent)]"
/>
<div
data-slot="prompt-attachments-fade-right"
data-slot="composer-attachments-fade-right"
class="pointer-events-none absolute inset-y-0 end-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-base),transparent)] rtl:bg-[linear-gradient(to_right,var(--v2-background-bg-base),transparent)]"
/>
</div>
@@ -473,7 +485,7 @@ export function PromptInputV2Attachments(props: {
)
}
export function PromptInputV2AddMenu(props: {
export function ComposerEditorAddMenu(props: {
disabled?: boolean
title: string
keybind?: string[]
@@ -500,7 +512,7 @@ export function PromptInputV2AddMenu(props: {
<Menu gutter={6} modal={false} placement="top-start">
<Menu.Trigger
as={IconButton}
data-action="prompt-attach"
data-action="composer-attach"
type="button"
icon={<Icon name="plus" />}
variant="ghost-muted"
@@ -530,16 +542,16 @@ export function PromptInputV2AddMenu(props: {
)
}
function PromptInputV2ConfiguredSelect(props: {
function ComposerEditorConfiguredSelect(props: {
title: string
keybind?: string[]
control: PromptInputV2SelectControl
control: ComposerSelectControl
model?: boolean
}) {
const current = () => props.control.current()
const providerID = () => props.control.options().find((option) => option.id === current())?.providerID
return (
<PromptInputV2Select
<ComposerEditorSelect
title={props.title}
keybind={props.control.keybind?.() ?? props.keybind}
options={props.control.options()}
@@ -554,10 +566,10 @@ function PromptInputV2ConfiguredSelect(props: {
)
}
export function PromptInputV2Select(props: {
export function ComposerEditorSelect(props: {
title: string
keybind?: string[]
options: PromptInputV2Option[]
options: ComposerOption[]
current: string
currentIcon?: JSX.Element
class?: string
@@ -608,9 +620,9 @@ export function PromptInputV2Select(props: {
)
}
export function PromptInputV2Popover(props: {
export function ComposerEditorPopover(props: {
emptyLabel: string
items: PromptInputV2Suggestion[]
items: ComposerSuggestion[]
activeID?: string
search?: {
value: string
@@ -619,8 +631,8 @@ export function PromptInputV2Popover(props: {
onValueChange: (value: string) => void
onKeyDown: (event: KeyboardEvent) => void
}
onActiveChange: (item: PromptInputV2Suggestion) => void
onSelect: (item: PromptInputV2Suggestion) => void
onActiveChange: (item: ComposerSuggestion) => void
onSelect: (item: ComposerSuggestion) => void
}) {
return (
<div
@@ -658,8 +670,10 @@ export function PromptInputV2Popover(props: {
onClick={() => props.onSelect(item)}
>
<div class="flex min-w-0 flex-1 items-center gap-2">
<PromptInputV2SuggestionIcon item={item} />
<span class="shrink-0 text-v2-text-text-base">{item.label}</span>
<ComposerSuggestionIcon item={item} />
<bdi dir="auto" class="shrink-0 text-v2-text-text-base">
{item.label}
</bdi>
<Show when={item.description}>
<span class="min-w-0 truncate text-v2-text-text-muted">{item.description}</span>
</Show>
@@ -675,8 +689,8 @@ export function PromptInputV2Popover(props: {
)
}
export function PromptInputV2SubmitButton(props: {
mode: PromptInputV2Mode
export function ComposerEditorSubmitButton(props: {
mode: ComposerMode
stopping: boolean
disabled: boolean
accent?: boolean
@@ -692,7 +706,7 @@ export function PromptInputV2SubmitButton(props: {
value={props.stopping ? props.stopLabel : props.sendLabel}
>
<IconButton
data-action="prompt-submit"
data-action="composer-submit"
type="button"
disabled={!props.stopping && props.disabled}
tabIndex={props.mode === "normal" ? undefined : -1}
@@ -724,8 +738,9 @@ export function PromptInputV2SubmitButton(props: {
)
}
function PromptInputV2SuggestionIcon(props: { item: PromptInputV2Suggestion }) {
function ComposerSuggestionIcon(props: { item: ComposerSuggestion }) {
if (props.item.kind === "agent") return <Icon name="brain" size="small" class="shrink-0 text-icon-info-active" />
if (props.item.kind === "skill") return <Icon name="post-skill" size="small" class="shrink-0" />
if (props.item.kind === "command") return null
return (
<FileIcon
@@ -1,39 +1,38 @@
import { createEffect, on, type Accessor } from "solid-js"
import { createEffect, type Accessor } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { useFilteredList } from "@opencode-ai/ui/hooks"
import { createPromptInputV2Attachments, type PromptInputV2AttachmentConfig } from "./attachments"
import { createPromptInputV2Store, type PromptInputV2StoreInput } from "./store"
import { createComposerAttachments, type ComposerAttachmentConfig } from "../attachments/attachments"
import { createComposerEditorActions, type ComposerStateStoreInput } from "./actions"
import type {
PromptInputV2Attachment,
PromptInputV2Comment,
PromptInputV2History,
PromptInputV2HistoryEntry,
PromptInputV2Option,
PromptInputV2PersistedState,
PromptInputV2Suggestion,
} from "./types"
ComposerAttachment,
ComposerComment,
ComposerHistory,
ComposerHistoryEntry,
ComposerOption,
ComposerPersistedState,
ComposerSuggestion,
} from "../types"
import {
createPromptInputV2InteractionState,
transitionPromptInputV2,
type PromptInputV2InteractionCommand,
type PromptInputV2InteractionEvent,
} from "./machine"
createComposerInteractionState,
transitionComposer,
type ComposerInteractionCommand,
type ComposerInteractionEvent,
} from "../suggestions/machine"
export type PromptInputV2SelectControl = {
options: Accessor<PromptInputV2Option[]>
export type ComposerSelectControl = {
options: Accessor<ComposerOption[]>
current: Accessor<string>
onSelect: (id: string) => void
keybind?: Accessor<string[]>
}
export type PromptInputV2ViewConfig = {
export type ComposerEditorView = {
placeholder?: Accessor<string>
add?: {
onAttach: () => void
}
agent?: PromptInputV2SelectControl
model?: PromptInputV2SelectControl
variant?: PromptInputV2SelectControl
agent?: ComposerSelectControl
variant?: ComposerSelectControl
submit: {
stopping: Accessor<boolean>
working?: Accessor<boolean>
@@ -44,39 +43,32 @@ export type PromptInputV2ViewConfig = {
onOpen: () => void
onClose: () => void
}
onKeyDown?: (event: KeyboardEvent) => void
onPaste?: (event: ClipboardEvent) => void
onDrop?: (event: DragEvent) => void
}
export function createPromptInputV2State() {
return createStore(createPromptInputV2InteractionState())
export function createComposerEditorState(mode: "normal" | "shell" = "normal") {
return createStore({ ...createComposerInteractionState(), mode })
}
export function createPromptInputV2Controller(input: {
store: PromptInputV2StoreInput
state?: ReturnType<typeof createPromptInputV2State>
identity?: Accessor<unknown>
history?: PromptInputV2History
commands: Accessor<PromptInputV2Suggestion[]>
context: Accessor<PromptInputV2Suggestion[]>
searchContextFiles: (query: string) => PromptInputV2Suggestion[] | Promise<PromptInputV2Suggestion[]>
openAttachment?: (attachment: PromptInputV2Attachment) => void
export function createComposerEditor(input: {
store: ComposerStateStoreInput
state?: ReturnType<typeof createComposerEditorState>
history?: ComposerHistory
commands: Accessor<ComposerSuggestion[]>
context: Accessor<ComposerSuggestion[]>
searchContextFiles: (query: string) => ComposerSuggestion[] | Promise<ComposerSuggestion[]>
openAttachment?: (attachment: ComposerAttachment) => void
openContext?: (key: string) => void
onContextRemove?: (item: PromptInputV2Comment) => void
onContextRemove?: (item: ComposerComment) => void
onEditor?: (element: HTMLElement) => void
onSuggestionSelect?: (item: PromptInputV2Suggestion) => (() => void) | void
view: PromptInputV2ViewConfig
attachments?: PromptInputV2AttachmentConfig
onSuggestionSelect?: (item: ComposerSuggestion) => (() => void) | void
view: ComposerEditorView
attachments?: ComposerAttachmentConfig
}) {
let editor: HTMLElement | undefined
let fileInput: HTMLInputElement | undefined
const draft = createPromptInputV2Store(input.store)
const [state, setState] = input.state ?? createPromptInputV2State()
if (input.identity) {
createEffect(on(input.identity, () => setState(reconcile(createPromptInputV2InteractionState())), { defer: true }))
}
function addPart(part: PromptInputV2PersistedState["prompt"][number]) {
const draft = createComposerEditorActions(input.store)
const [state, setState] = input.state ?? createComposerEditorState(draft.state.mode)
function addPart(part: ComposerPersistedState["prompt"][number]) {
if (part.type === "image") return false
if (part.type === "file" || part.type === "agent") {
draft.addMention(part)
@@ -86,12 +78,12 @@ export function createPromptInputV2Controller(input: {
return true
}
const attachments = input.attachments
? createPromptInputV2Attachments({
? createComposerAttachments({
...input.attachments,
capture: () => ({
current: () => draft.state.prompt,
cursor: () => draft.state.cursor,
set: draft.setPrompt,
set: (prompt, cursor) => draft.setPrompt(prompt, cursor),
}),
editor: () => editor,
focusEditor: () => editor?.focus(),
@@ -106,7 +98,7 @@ export function createPromptInputV2Controller(input: {
}
attachments.pick(() => fileInput?.click())
}
const contextList = useFilteredList<PromptInputV2Suggestion>({
const contextList = useFilteredList<ComposerSuggestion>({
items: async (query) => {
const fixed = input.context().filter((item) => item.kind !== "file")
const recent = input.context().filter((item) => item.kind === "file" && item.recent)
@@ -120,17 +112,18 @@ export function createPromptInputV2Controller(input: {
skipFilter: (item) => item.kind === "file" && !item.recent,
groupBy: (item) => {
if (item.kind === "reference") return "reference"
if (item.kind === "skill") return "skill"
if (item.kind === "agent") return "agent"
if (item.kind === "resource") return "resource"
if (item.recent) return "recent"
return "file"
},
sortGroupsBy: (a, b) => {
const order = ["reference", "agent", "resource", "recent", "file"]
const order = ["reference", "skill", "agent", "resource", "recent", "file"]
return order.indexOf(a.category) - order.indexOf(b.category)
},
})
const commandList = useFilteredList<PromptInputV2Suggestion>({
const commandList = useFilteredList<ComposerSuggestion>({
items: () => input.commands(),
key: (item) => item.id,
filterKeys: ["trigger", "title"],
@@ -138,11 +131,15 @@ export function createPromptInputV2Controller(input: {
const list = () => (state.popover.type === "context" ? contextList : commandList)
const suggestions = () => list().flat()
const execute = (command: PromptInputV2InteractionCommand) => {
const execute = (command: ComposerInteractionCommand) => {
if (command.type === "draft.setText") {
draft.setText(command.value)
return
}
if (command.type === "draft.addText") {
draft.addText(command.value)
return
}
if (command.type === "mention.add") {
if (command.item.mention) draft.addMention(command.item.mention)
return
@@ -159,20 +156,21 @@ export function createPromptInputV2Controller(input: {
if (command.type === "focus.editor") requestAnimationFrame(() => editor?.focus())
}
function dispatch(event: PromptInputV2InteractionEvent) {
function dispatch(event: ComposerInteractionEvent) {
const mode = state.mode
const result = transitionPromptInputV2(state, event, draft.state)
const result = transitionComposer(state, event, draft.state)
const action = event.type === "popover.select" ? input.onSuggestionSelect?.(event.item) : undefined
if (event.type === "popover.select") {
if (!action || state.popover.type !== "command-menu") result.commands.forEach(execute)
if (action && event.item.kind === "command" && state.popover.type !== "command-menu") {
draft.setPrompt(
draft.state.prompt.filter((part): part is PromptInputV2Attachment => part.type === "image"),
draft.state.prompt.filter((part): part is ComposerAttachment => part.type === "image"),
0,
)
}
}
setState(reconcile(result.state))
if (mode !== result.state.mode) draft.setMode(result.state.mode)
if (event.type !== "popover.select") result.commands.forEach(execute)
if (mode !== result.state.mode) {
if (result.state.mode === "shell") input.view.shell?.onOpen()
@@ -232,7 +230,6 @@ export function createPromptInputV2Controller(input: {
event.preventDefault()
return true
}
input.view.onKeyDown?.(event)
return event.defaultPrevented
}
@@ -250,7 +247,7 @@ export function createPromptInputV2Controller(input: {
})
}
const applyHistory = (entry: PromptInputV2HistoryEntry, position: "start" | "end") => {
const applyHistory = (entry: ComposerHistoryEntry, position: "start" | "end") => {
input.history?.restore?.(entry.metadata)
const cursor = position === "start" ? 0 : promptLength(entry.prompt)
draft.setPrompt(clonePrompt(entry.prompt), cursor)
@@ -301,15 +298,14 @@ export function createPromptInputV2Controller(input: {
parts() {
return draft.state.prompt
},
addPart,
contextItem(id: string) {
return draft.state.context.items.find((item) => item.key === id)
},
comments() {
return draft.state.context.items.filter((item) => !!item.comment?.trim())
},
attachments(): PromptInputV2Attachment[] {
return draft.state.prompt.filter((part): part is PromptInputV2Attachment => part.type === "image")
attachments(): ComposerAttachment[] {
return draft.state.prompt.filter((part): part is ComposerAttachment => part.type === "image")
},
toggleContext(id: string) {
dispatch({ type: "context.active", id })
@@ -321,7 +317,7 @@ export function createPromptInputV2Controller(input: {
draft.removeContext(id)
if (state.activeContextID === id) dispatch({ type: "context.active", id })
},
openAttachment(attachment: PromptInputV2Attachment) {
openAttachment(attachment: ComposerAttachment) {
input.openAttachment?.(attachment)
},
removeAttachment(id: string) {
@@ -329,6 +325,9 @@ export function createPromptInputV2Controller(input: {
},
canSubmit() {
const persisted = draft.state
if (state.mode === "shell") {
return persisted.prompt.some((part) => "content" in part && !!part.content.trim())
}
if (persisted.prompt.some((part) => part.type === "image")) return true
if (persisted.context.items.some((item) => !!item.comment?.trim())) return true
return persisted.prompt.some((part) => "content" in part && !!part.content.trim())
@@ -338,7 +337,7 @@ export function createPromptInputV2Controller(input: {
input.onEditor?.(element)
},
restoreFocus,
onInput(value: string, prompt?: PromptInputV2PersistedState["prompt"], cursor?: number) {
onInput(value: string, prompt?: ComposerPersistedState["prompt"], cursor?: number) {
if (prompt) draft.setPrompt(prompt, cursor)
dispatch({ type: "input.changed", value, persist: !prompt })
},
@@ -354,9 +353,6 @@ export function createPromptInputV2Controller(input: {
openShell() {
dispatch({ type: "mode.shell" })
},
closeShell() {
dispatch({ type: "mode.normal" })
},
submit() {
input.view.submit.onSubmit()
dispatch({ type: "popover.close" })
@@ -364,7 +360,7 @@ export function createPromptInputV2Controller(input: {
stop() {
input.view.submit.onStop()
},
addHistory(prompt: PromptInputV2PersistedState["prompt"], mode: "normal" | "shell") {
addHistory(prompt: ComposerPersistedState["prompt"], mode: "normal" | "shell") {
input.history?.add(prompt, mode)
setState({ historyIndex: -1, savedHistory: undefined })
},
@@ -380,8 +376,6 @@ export function createPromptInputV2Controller(input: {
void attachments.handlePaste(event)
return
}
input.view.onPaste?.(event)
if (event.defaultPrevented) return
const text = clipboard?.getData("text/plain")
if (!text) return
event.preventDefault()
@@ -417,7 +411,6 @@ export function createPromptInputV2Controller(input: {
void attachments.handleDrop(event)
return
}
input.view.onDrop?.(event)
},
attach,
setFileInput(element: HTMLInputElement) {
@@ -432,7 +425,7 @@ export function createPromptInputV2Controller(input: {
}
}
export type PromptInputV2Interaction = ReturnType<typeof createPromptInputV2Controller>
export type ComposerEditorModel = ReturnType<typeof createComposerEditor>
function canNavigateHistory(direction: "up" | "down", text: string, cursor: number, inHistory: boolean) {
const position = Math.max(0, Math.min(cursor, text.length))
@@ -441,13 +434,13 @@ function canNavigateHistory(direction: "up" | "down", text: string, cursor: numb
return position === text.length
}
function clonePrompt(prompt: PromptInputV2PersistedState["prompt"]): PromptInputV2PersistedState["prompt"] {
function clonePrompt(prompt: ComposerPersistedState["prompt"]): ComposerPersistedState["prompt"] {
return prompt.map((part) =>
part.type === "file" ? { ...part, selection: part.selection ? { ...part.selection } : undefined } : { ...part },
)
}
function promptLength(prompt: PromptInputV2PersistedState["prompt"]) {
function promptLength(prompt: ComposerPersistedState["prompt"]) {
return prompt.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0)
}
@@ -0,0 +1,64 @@
import { describe, expect, test } from "bun:test"
import type { Prompt } from "@/composer/state"
import { clonePromptParts, prependHistoryEntry, promptLength, type PromptHistoryComment } from "./entry"
import { upgradeHistoryState } from "./store"
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
const text = (value: string): Prompt => [{ type: "text", content: value, start: 0, end: value.length }]
const comment = (id: string, value = "note"): PromptHistoryComment => ({
id,
path: "src/a.ts",
selection: { start: 2, end: 4 },
comment: value,
time: 1,
origin: "review",
preview: "const a = 1",
})
describe("Composer history", () => {
test("prependHistoryEntry skips empty prompt and deduplicates consecutive entries", () => {
const first = prependHistoryEntry([], DEFAULT_PROMPT)
expect(first).toEqual([])
const commentsOnly = prependHistoryEntry([], DEFAULT_PROMPT, [comment("c1")])
expect(commentsOnly).toHaveLength(1)
const withOne = prependHistoryEntry([], text("hello"))
expect(withOne).toHaveLength(1)
const deduped = prependHistoryEntry(withOne, text("hello"))
expect(deduped).toBe(withOne)
const dedupedComments = prependHistoryEntry(commentsOnly, DEFAULT_PROMPT, [comment("c1")])
expect(dedupedComments).toBe(commentsOnly)
})
test("upgrades stored prompt arrays once at the persistence boundary", () => {
expect(upgradeHistoryState({ entries: [text("stored")] })).toEqual({
entries: [{ prompt: text("stored"), comments: [] }],
})
})
test("helpers clone prompt and count text content length", () => {
const original: Prompt = [
{ type: "text", content: "one", start: 0, end: 3 },
{
type: "file",
path: "src/a.ts",
content: "@src/a.ts",
start: 3,
end: 12,
selection: { startLine: 1, startChar: 1, endLine: 2, endChar: 1 },
},
{ type: "image", id: "1", filename: "img.png", mime: "image/png", blob: { id: "blob", url: "blob:test" } },
]
const copy = clonePromptParts(original)
expect(copy).not.toBe(original)
expect(promptLength(copy)).toBe(12)
if (copy[1]?.type !== "file") throw new Error("expected file")
copy[1].selection!.startLine = 9
if (original[1]?.type !== "file") throw new Error("expected file")
expect(original[1].selection?.startLine).toBe(1)
})
})
@@ -1,7 +1,5 @@
import type { Prompt } from "@/context/prompt"
import type { SelectedLineRange } from "@/context/file"
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
import type { Prompt } from "@/composer/state"
import type { SelectedLineRange } from "@/workspaces/files/model"
export const MAX_HISTORY = 100
@@ -22,20 +20,12 @@ export type PromptHistoryEntry = {
export type PromptHistoryStoredEntry = PromptHistoryEntry
export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number, inHistory = false) {
const position = Math.max(0, Math.min(cursor, text.length))
const atStart = position === 0
const atEnd = position === text.length
if (inHistory) return atStart || atEnd
if (direction === "up") return position === 0 && text.length === 0
return position === text.length
}
export function clonePromptParts(prompt: Prompt): Prompt {
return prompt.map((part) => {
if (part.type === "text") return { ...part }
if (part.type === "image") return { ...part }
if (part.type === "agent") return { ...part }
if (part.type === "skill") return { ...part }
return {
...part,
selection: part.selection ? { ...part.selection } : undefined,
@@ -130,6 +120,9 @@ function isPromptEqual(promptA: PromptHistoryStoredEntry, promptB: PromptHistory
if (!sameSelection) return false
}
if (partA.type === "agent" && partA.name !== (partB.type === "agent" ? partB.name : "")) return false
if (partA.type === "skill") {
if (partB.type !== "skill" || partA.id !== partB.id || partA.name !== partB.name) return false
}
if (partA.type === "image" && partA.id !== (partB.type === "image" ? partB.id : "")) return false
}
if (entryA.comments.length !== entryB.comments.length) return false
@@ -140,111 +133,3 @@ function isPromptEqual(promptA: PromptHistoryStoredEntry, promptB: PromptHistory
}
return true
}
type HistoryNavInput = {
direction: "up" | "down"
entries: PromptHistoryStoredEntry[]
historyIndex: number
currentPrompt: Prompt
currentComments: PromptHistoryComment[]
savedPrompt: PromptHistoryEntry | null
}
type HistoryNavResult =
| {
handled: false
historyIndex: number
savedPrompt: PromptHistoryEntry | null
}
| {
handled: true
historyIndex: number
savedPrompt: PromptHistoryEntry | null
entry: PromptHistoryEntry
cursor: "start" | "end"
}
export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult {
if (input.direction === "up") {
if (input.entries.length === 0) {
return {
handled: false,
historyIndex: input.historyIndex,
savedPrompt: input.savedPrompt,
}
}
if (input.historyIndex === -1) {
const entry = normalizePromptHistoryEntry(input.entries[0])
return {
handled: true,
historyIndex: 0,
savedPrompt: {
prompt: clonePromptParts(input.currentPrompt),
comments: clonePromptHistoryComments(input.currentComments),
},
entry,
cursor: "start",
}
}
if (input.historyIndex < input.entries.length - 1) {
const next = input.historyIndex + 1
const entry = normalizePromptHistoryEntry(input.entries[next])
return {
handled: true,
historyIndex: next,
savedPrompt: input.savedPrompt,
entry,
cursor: "start",
}
}
return {
handled: false,
historyIndex: input.historyIndex,
savedPrompt: input.savedPrompt,
}
}
if (input.historyIndex > 0) {
const next = input.historyIndex - 1
const entry = normalizePromptHistoryEntry(input.entries[next])
return {
handled: true,
historyIndex: next,
savedPrompt: input.savedPrompt,
entry,
cursor: "end",
}
}
if (input.historyIndex === 0) {
if (input.savedPrompt) {
return {
handled: true,
historyIndex: -1,
savedPrompt: null,
entry: input.savedPrompt,
cursor: "end",
}
}
return {
handled: true,
historyIndex: -1,
savedPrompt: null,
entry: {
prompt: DEFAULT_PROMPT,
comments: [],
},
cursor: "end",
}
}
return {
handled: false,
historyIndex: input.historyIndex,
savedPrompt: input.savedPrompt,
}
}
@@ -1,15 +1,15 @@
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import type { Prompt } from "@/context/prompt"
import { Persist, persisted } from "@/utils/persist"
import type { Prompt } from "@/composer/state"
import { Persist, persisted } from "@/runtime/persistence/storage"
import {
clonePromptHistoryComments,
clonePromptParts,
prependHistoryEntry,
type PromptHistoryComment,
type PromptHistoryStoredEntry,
} from "./history"
} from "./entry"
export type PromptInputHistory = {
export type ComposerHistoryStore = {
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
}
@@ -31,12 +31,12 @@ export function upgradeHistoryState(value: unknown) {
}
}
function createPromptInputHistoryStore(
function createComposerHistoryStore(
normal: Store<PromptHistoryState>,
setNormal: SetStoreFunction<PromptHistoryState>,
shell: Store<PromptHistoryState>,
setShell: SetStoreFunction<PromptHistoryState>,
): PromptInputHistory {
): ComposerHistoryStore {
return {
entries: (mode) => (mode === "shell" ? shell.entries : normal.entries),
add(prompt, mode, comments) {
@@ -49,13 +49,7 @@ function createPromptInputHistoryStore(
}
}
export function createPromptInputHistory(): PromptInputHistory {
const [normal, setNormal] = createStore<PromptHistoryState>({ entries: [] })
const [shell, setShell] = createStore<PromptHistoryState>({ entries: [] })
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
}
export function createPersistedPromptInputHistory() {
export function createComposerHistory() {
const [normal, setNormal, normalInit] = persisted(
{ ...Persist.prompt(Persist.global("prompt-history")), migrate: upgradeHistoryState },
createStore<PromptHistoryState>({ entries: [] }),
@@ -64,7 +58,7 @@ export function createPersistedPromptInputHistory() {
{ ...Persist.prompt(Persist.global("prompt-history-shell")), migrate: upgradeHistoryState },
createStore<PromptHistoryState>({ entries: [] }),
)
const history = createPromptInputHistoryStore(normal, setNormal, shell, setShell)
const history = createComposerHistoryStore(normal, setNormal, shell, setShell)
return {
...history,
add(prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) {
@@ -1,86 +1,33 @@
import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { Keybind } from "@opencode-ai/ui/keybind"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import type { ReferenceInfo } from "@opencode-ai/client/promise"
import { createEffect, createMemo, on, Show } from "solid-js"
import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
import type { PromptInputProps } from "@/components/prompt-input/contracts"
import { normalizePromptHistoryEntry, promptLength, type PromptHistoryComment } from "@/components/prompt-input/history"
import { createPersistedPromptInputHistory } from "@/components/prompt-input/history-store"
import { promptDesignPlaceholder, promptPlaceholder } from "@/components/prompt-input/placeholder"
import { createPromptSubmit } from "@/components/prompt-input/submit"
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
import { useComments } from "@/context/comments"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { usePermission } from "@/context/permission"
import { type ImageAttachmentPart, usePrompt } from "@/context/prompt"
import { usePlatform } from "@/context/platform"
import { useWorkspaceLocation } from "@/context/location"
import { useData } from "@/context/server"
import { createComponent, createEffect, createMemo, on } from "solid-js"
import type { ComposerSuggestion } from "./types"
import { createComposerEditor, createComposerEditorState, type ComposerEditorModel } from "./editor/interaction"
import { selectionFromLines, type SelectedLineRange, useFile } from "@/workspaces/files/model"
import { useComments } from "@/composer/comments"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { useLayout } from "@/shell/state/layout"
import { usePlatform } from "@/runtime/platform/platform"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useData } from "@/runtime/server/current"
import { createSessionTabs } from "@/session/helpers"
import { showToast } from "@/utils/toast"
import { PromptInputV2, type PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input"
import {
createPromptInputV2Controller,
createPromptInputV2State,
type PromptInputV2Interaction,
} from "@opencode-ai/session-ui/v2/prompt-input/interaction"
import { showToast } from "@/shell/notifications/toast"
import { formatServerError } from "@/runtime/server/errors"
import { Skill } from "@opencode-ai/schema/skill"
import type { ComposerAdapter, ComposerControls } from "./adapter"
import type { ImageAttachmentPart } from "./state"
import { normalizePromptHistoryEntry, type PromptHistoryComment } from "./history/entry"
import { createComposerHistory } from "./history/store"
import { composerPlaceholder } from "./placeholder"
import { createComposerSubmit } from "./submit"
export type PromptInputV2ComposerProps = {
class?: string
controller: PromptInputV2ComposerController
borderUnderlay?: boolean
accentSubmit?: boolean
export type ComposerModel = ComposerEditorModel & {
readonly model: ComposerControls["model"]
}
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class">
export type PromptInputV2ComposerController = PromptInputV2Interaction & {
readonly model: PromptInputProps["controls"]["model"]
}
export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
const dialog = useDialog()
const command = useCommand()
const language = useLanguage()
return (
<div class="flex flex-col gap-3">
<PromptInputV2
controller={props.controller}
accentSubmit={props.accentSubmit}
borderUnderlay={props.borderUnderlay}
class={props.class}
variantControlVisible={!props.controller.model.loading}
attachKeybind={command.keybindParts("file.attach")}
attachShortcut={command.keybind("file.attach")}
modelControl={
<PromptInputV2ModelControl
loading={props.controller.model.loading}
paid={props.controller.model.paid}
title={language.t("command.model.choose")}
keybind={command.keybindParts("model.choose")}
model={props.controller.model.selection}
providerID={props.controller.model.selection.current()?.provider?.id}
modelName={props.controller.model.selection.current()?.name ?? language.t("dialog.model.select.title")}
onClose={props.controller.restoreFocus}
onUnpaidClick={() =>
dialog.show(() => <DialogSelectModelUnpaidV2 model={props.controller.model.selection} />)
}
/>
}
/>
</div>
)
}
export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController {
export function createComposerModel(adapter: ComposerAdapter): ComposerModel {
const sdk = useWorkspaceLocation()
const data = useData()
const files = useFile()
@@ -88,16 +35,20 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
const comments = useComments()
const dialog = useDialog()
const command = useCommand()
const permission = usePermission()
const language = useLanguage()
const platform = usePlatform()
const prompt = props.state ?? usePrompt()
const prompt = adapter.state
let editor: HTMLDivElement | undefined
const interaction = createPromptInputV2State()
const interaction = createComposerEditorState(prompt.mode.current())
createEffect(
on(adapter.ready, (ready) => {
if (ready) interaction[1]("mode", prompt.mode.current())
}),
)
const mode = () => interaction[0].mode
const history = props.history ?? createPersistedPromptInputHistory()
const tabs = () => props.controls.session.tabs
const history = createComposerHistory()
const tabs = () => adapter.controls().session.tabs
const activeFileTab = createSessionTabs({
tabs,
pathFromTab: files.pathFromTab,
@@ -113,8 +64,6 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
return [...result, path]
}, [])
})
const info = createMemo(() => (props.controls.session.id ? data.session.get(props.controls.session.id) : undefined))
const working = createMemo(() => data.session.status(props.controls.session.id ?? "") === "running")
const attachments = createMemo(() =>
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
)
@@ -129,20 +78,9 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
.join("")
return text.trim().length === 0 && attachments().length === 0 && commentCount() === 0
})
const stopping = createMemo(() => working() && blank())
const placeholder = createMemo(() =>
promptPlaceholder({
mode: mode(),
commentCount: commentCount(),
example: mode() === "shell" ? "git status" : "",
suggest: false,
t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
}),
)
const designPlaceholder = () =>
promptDesignPlaceholder(mode(), placeholder(), (key, params) =>
language.t(key as Parameters<typeof language.t>[0], params as never),
)
const stopping = createMemo(() => adapter.working() && blank())
const placeholder = () =>
composerPlaceholder(mode(), (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never))
const historyComments = () => {
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
@@ -192,39 +130,6 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
)
}
const accepting = createMemo(() => {
const id = props.controls.session.id
if (!id) return permission.isAutoAcceptingDirectory(sdk().directory)
return permission.isAutoAccepting(id, sdk().directory)
})
const submission =
props.submission ??
createPromptSubmit({
prompt,
info,
imageAttachments: attachments,
commentCount,
autoAccept: accepting,
mode,
working,
editor: () => editor,
queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })),
promptLength,
addToHistory: (value, mode) => controller.addHistory(value, mode),
resetHistoryNavigation: () => controller.resetHistory(),
setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }),
setPopover: (popover) => {
if (!popover) controller.dispatch({ type: "popover.close" })
},
newSessionWorktree: () => props.newSessionWorktree,
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
shouldQueue: props.shouldQueue,
onQueue: props.onQueue,
onAbort: props.onAbort,
onSubmit: props.onSubmit,
model: props.controls.model.selection,
})
const referenceDescription = (reference: ReferenceInfo) =>
reference.source.type === "git" ? reference.source.repository : reference.source.path
const references = createMemo(() =>
@@ -273,10 +178,26 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
resource,
})),
)
const context = createMemo<PromptInputV2Suggestion[]>(() => [
const skills = createMemo(() => data.location.skill.list({ directory: sdk().directory }) ?? [])
const context = createMemo<ComposerSuggestion[]>(() => [
...references(),
...props.controls.agents.available
.filter((agent) => !agent.hidden && agent.mode !== "primary")
...skills().map((skill) => ({
id: `skill:${skill.id}`,
kind: "skill" as const,
label: `@${skill.id}`,
description: skill.description,
mention: {
type: "skill" as const,
id: Skill.ID.make(skill.id),
name: Skill.Name.make(skill.name),
content: `@${skill.id}`,
start: 0,
end: 0,
},
})),
...adapter
.controls()
.agents.available.filter((agent) => !agent.hidden && agent.mode !== "primary")
.map((agent) => ({
id: `agent:${agent.name}`,
kind: "agent" as const,
@@ -311,7 +232,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
type: "builtin" as const,
})),
])
const commands = createMemo<PromptInputV2Suggestion[]>(() =>
const commands = createMemo<ComposerSuggestion[]>(() =>
slashCommands().map((item) => ({
id: item.id,
kind: "command",
@@ -322,11 +243,46 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
keybind: command.keybindParts(item.id),
})),
)
const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()])
const controller = createPromptInputV2Controller({
store: () => prompt.capture().store,
const variants = createMemo(() => ["default", ...adapter.controls().model.selection.variant.list()])
const submission = createComposerSubmit({
adapter,
mode,
editor: () => editor,
queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })),
addToHistory: (value, mode) => controller.addHistory(value, mode),
resetHistory: () => controller.resetHistory(),
setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }),
closePopover: () => controller.dispatch({ type: "popover.close" }),
notify: {
missingSelection: () =>
showToast({
title: language.t("prompt.toast.modelAgentRequired.title"),
description: language.t("prompt.toast.modelAgentRequired.description"),
}),
failed: (kind, error) =>
showToast({
title: language.t(
kind === "shell"
? "prompt.toast.shellSendFailed.title"
: kind === "command"
? "prompt.toast.commandSendFailed.title"
: "prompt.toast.promptSendFailed.title",
),
description:
kind === "command"
? formatServerError(error, language.t, language.t("common.requestFailed"))
: composerErrorMessage(language, error),
}),
},
comments: {
capture: historyComments,
clear: comments.clear,
restore: restoreHistoryComments,
},
})
const controller = createComposerEditor({
store: prompt.store,
state: interaction,
identity: () => prompt.capture(),
history: {
entries: (mode) =>
history.entries(mode).map((value) => {
@@ -351,14 +307,14 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
if (item?.commentID) comments.remove(item.path, item.commentID)
},
openAttachment: (attachment) =>
dialog.show(() => <ImagePreview src={attachment.blob.url} alt={attachment.filename} />),
dialog.show(() => createComponent(ImagePreview, { src: attachment.blob.url, alt: attachment.filename })),
openContext(key) {
const item = controller.contextItem(key)
if (item) openComment(item, props, layout, files, comments)
if (item) openComment(item, adapter.controls(), layout, files, comments)
},
onEditor(element) {
editor = element as HTMLDivElement
props.ref?.(editor)
if (adapter.kind === "active-session") adapter.setEditor(editor)
},
onSuggestionSelect(item) {
if (item.kind !== "command") return
@@ -387,34 +343,35 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
store: platform.draftStore?.putBlob,
},
view: {
placeholder: designPlaceholder,
placeholder,
get agent() {
return props.controls.agents.visible && props.controls.agents.options.length > 0
const agents = adapter.controls().agents
return agents.visible && agents.options.length > 0
? {
options: () => props.controls.agents.options.map((name) => ({ id: name, label: name })),
current: () => props.controls.agents.current,
onSelect: (value: string) => props.controls.agents.select(value),
options: () => adapter.controls().agents.options.map((name) => ({ id: name, label: name })),
current: () => adapter.controls().agents.current,
onSelect: (value: string) => adapter.controls().agents.select(value),
keybind: () => command.keybindParts("agent.cycle"),
}
: undefined
},
variant: {
options: () => variants().map((value) => ({ id: value, label: value })),
current: () => props.controls.model.selection.variant.current() ?? "default",
onSelect: (value) => props.controls.model.selection.variant.set(value === "default" ? undefined : value),
current: () => adapter.controls().model.selection.variant.current() ?? "default",
onSelect: (value) => adapter.controls().model.selection.variant.set(value === "default" ? undefined : value),
keybind: () => command.keybindParts("model.variant.cycle"),
},
submit: {
stopping,
working,
onSubmit: () => void submission.handleSubmit(new Event("submit")),
onStop: () => void submission.abort(),
working: adapter.working,
onSubmit: () => void submission.submit(new Event("submit")),
onStop: () => void submission.stop(),
},
},
})
Object.defineProperty(controller, "model", { get: () => props.controls.model })
Object.defineProperty(controller, "model", { get: () => adapter.controls().model })
command.register("prompt-input", () => [
command.register("composer-editor", () => [
{
id: "file.attach",
title: language.t("prompt.action.attachFile"),
@@ -441,122 +398,23 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
},
])
createEffect(
on(
() => props.edit?.id,
(id) => {
const edit = props.edit
if (!id || !edit) return
prompt.context.items().forEach((item) => prompt.context.remove(item.key))
edit.context.forEach((item) =>
prompt.context.add({
type: item.type,
path: item.path,
selection: item.selection,
comment: item.comment,
commentID: item.commentID,
commentOrigin: item.commentOrigin,
preview: item.preview,
}),
)
controller.dispatch({ type: "mode.normal" })
controller.resetHistory()
prompt.set(edit.prompt, promptLength(edit.prompt))
controller.restoreFocus()
props.onEditLoaded?.()
},
{ defer: true },
),
)
return controller as PromptInputV2ComposerController
return controller as ComposerModel
}
function PromptInputV2ModelControl(props: {
loading: boolean
paid: boolean
title: string
keybind: string[]
model: PromptInputV2ComposerController["model"]["selection"]
providerID?: string
modelName: string
onClose: () => void
onUnpaidClick: () => void
}) {
const shouldAnimate = createMemo<boolean>((previous) => previous ?? props.loading)
const content = () => (
<>
<Show when={props.providerID}>
{(providerID) => (
<ProviderIcon
id={providerID()}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
)}
</Show>
<span class="truncate leading-4">{props.modelName}</span>
<span class="-ml-0.5 -mr-1 flex shrink-0">
<Icon name="chevron-down" />
</span>
</>
)
return (
<Show when={!props.loading}>
<Tooltip
placement="top"
gutter={4}
value={
<>
{props.title}
<Keybind keys={props.keybind} variant="neutral" />
</>
}
>
<Show
when={props.paid}
fallback={
<Button
data-action="prompt-model"
data-control-type="dialog"
variant="ghost-muted"
size="normal"
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
classList={{ "animate-in fade-in": shouldAnimate() }}
style={{ height: "28px" }}
onClick={props.onUnpaidClick}
>
{content()}
</Button>
}
>
<ModelSelectorPopoverV2
model={props.model}
trigger={(triggerProps) => (
<Button
{...triggerProps}
variant="ghost-muted"
size="normal"
style={{ height: "28px" }}
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
classList={{ "animate-in fade-in": shouldAnimate() }}
data-action="prompt-model"
data-control-type="popover"
>
{content()}
</Button>
)}
onClose={props.onClose}
/>
</Show>
</Tooltip>
</Show>
)
function composerErrorMessage(language: ReturnType<typeof useLanguage>, error: unknown) {
if (error && typeof error === "object" && "message" in error && typeof error.message === "string") {
return error.message
}
if (error && typeof error === "object" && "data" in error) {
const data = (error as { data?: { message?: string } }).data
if (data?.message) return data.message
}
return language.t("common.requestFailed")
}
function openComment(
item: { path: string; commentID?: string; commentOrigin?: "review" | "file" },
props: PromptInputV2ControllerProps,
controls: ComposerControls,
layout: ReturnType<typeof useLayout>,
files: ReturnType<typeof useFile>,
comments: ReturnType<typeof useComments>,
@@ -575,16 +433,16 @@ function openComment(
})
}
const review = item.commentOrigin === "review"
if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
if (!controls.session.reviewPanel.opened()) controls.session.reviewPanel.open()
if (review) {
layout.fileTree.setTab("changes")
props.controls.session.tabs.setActive("review")
controls.session.tabs.setActive("review")
queueFocus()
return
}
layout.fileTree.setTab("all")
const tab = files.tab(item.path)
void props.controls.session.tabs.open(tab)
props.controls.session.tabs.setActive(tab)
void controls.session.tabs.open(tab)
controls.session.tabs.setActive(tab)
void Promise.resolve(files.load(item.path)).finally(() => queueFocus())
}
@@ -2,31 +2,30 @@ import { base64Encode } from "@opencode-ai/util/encode"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useParams, useSearchParams } from "@solidjs/router"
import { createMemo, createResource, createRoot, getOwner, onCleanup } from "solid-js"
import { requireServerKey } from "@/utils/session-route"
import { ServerConnection } from "./servers"
import { useServerSDK } from "./server-sdk"
import { useWorkspaceLocation } from "./location"
import { useTabs, type Tab } from "./tabs"
import type { ServerScope } from "@/utils/server-scope"
import { requireServerKey } from "@/shell/routes/session"
import { ServerConnection } from "@/runtime/server/registry"
import { useServerSDK } from "@/runtime/server/client"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useTabs, type Tab } from "@/shell/tabs/tabs"
import type { ServerScope } from "@/runtime/server/scope"
import {
createPromptReady,
createPromptSession,
createComposerReady,
createComposerState,
type ContextItem,
type FileContextItem,
type Prompt,
type PromptModel,
type PromptScope,
type PromptSession,
} from "./prompt-state"
type ComposerState,
} from "./state"
export {
createPromptReady,
createPromptSession,
createPromptState,
createComposerReady,
createComposerState,
createMemoryComposerState,
DEFAULT_PROMPT,
isCommentItem,
isPromptEqual,
} from "./prompt-state"
} from "./state"
export type {
AgentPart,
ContentPart,
@@ -36,11 +35,11 @@ export type {
ImageAttachmentPart,
Prompt,
PromptModel,
PromptStore,
ComposerStore,
PromptScope,
PromptSession,
ComposerState,
TextPart,
} from "./prompt-state"
} from "./state"
const WORKSPACE_KEY = "__workspace__"
const MAX_PROMPT_SESSIONS = 20
@@ -59,19 +58,19 @@ function scopeKey(scope: PromptScope) {
return `${scope.dir}:${scope.id ?? WORKSPACE_KEY}`
}
type PromptCacheEntry = {
value: PromptSession
type ComposerCacheEntry = {
value: ComposerState
dispose: VoidFunction
}
export const createTabPromptState = (
export const createTabComposerState = (
tabs: ReturnType<typeof useTabs>,
tab: Tab,
...args: Parameters<typeof createPromptSession>
) => tabs.state(tab, "prompt", () => createPromptSession(...args))
...args: Parameters<typeof createComposerState>
) => tabs.state(tab, "prompt", () => createComposerState(...args))
export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({
name: "Prompt",
export const { use: useComposerState, provider: ComposerPersistenceProvider } = createSimpleContext({
name: "ComposerState",
gate: false,
init: () => {
const params = useParams<{ serverKey?: string; id?: string }>()
@@ -79,7 +78,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const [search] = useSearchParams<{ draftId?: string }>()
const serverSDK = useServerSDK()
const tabs = useTabs()
const cache = new Map<string, PromptCacheEntry>()
const cache = new Map<string, ComposerCacheEntry>()
const disposeAll = () => {
for (const entry of cache.values()) entry.dispose()
@@ -105,7 +104,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
const load = (scope: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) => {
const current = selectPromptTab(tabs.store, scope, target?.server ?? serverKey())
if (current) return createTabPromptState(tabs, current, target?.scope ?? serverSDK.scope, scope)
if (current) return createTabComposerState(tabs, current, target?.scope ?? serverSDK.scope, scope)
const key = target ? `${target.scope}:${scopeKey(scope)}` : scopeKey(scope)
const existing = cache.get(key)
@@ -117,7 +116,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const entry = createRoot(
(dispose) => ({
value: createPromptSession(target?.scope ?? serverSDK.scope, scope),
value: createComposerState(target?.scope ?? serverSDK.scope, scope),
dispose,
}),
owner,
@@ -131,7 +130,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const session = createMemo(() => load(scope()))
const pick = (scope?: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) =>
scope ? load(scope, target) : session()
const ready = createPromptReady(session)
const ready = createComposerReady(session)
const withSuspense = <T,>(cb: () => T): (() => T) =>
createResource(
@@ -150,7 +149,6 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
pick(scope, target).capture(),
current: withSuspense(() => session().current()),
cursor: withSuspense(() => session().cursor()),
dirty: withSuspense(() => session().dirty()),
model: {
current: withSuspense(() => session().model.current()),
set: (model: PromptModel | undefined) => session().model.set(model),
@@ -0,0 +1,15 @@
import { describe, expect, test } from "bun:test"
import { composerPlaceholder } from "./placeholder"
describe("Composer placeholder", () => {
const t = (key: string, params?: Record<string, string>) =>
`${key}${params?.example ? `:${params.example}` : ""}${params?.slash ?? ""}${params?.at ?? ""}`
test("uses the shell command placeholder in shell mode", () => {
expect(composerPlaceholder("shell", t)).toBe("prompt.placeholder.shell:git status")
})
test("uses the command and context hint in normal mode", () => {
expect(composerPlaceholder("normal", t)).toBe("ui.promptInput.placeholder.normal/@")
})
})
+7
View File
@@ -0,0 +1,7 @@
export function composerPlaceholder(
mode: "normal" | "shell",
t: (key: string, params?: Record<string, string>) => string,
) {
if (mode === "shell") return t("prompt.placeholder.shell", { example: "git status" })
return t("ui.promptInput.placeholder.normal", { slash: "/", at: "@" })
}
@@ -105,4 +105,24 @@ describe("extractPromptFromMessage", () => {
expect(extractPromptFromMessage(message)[0]).toMatchObject({ type: "text", content: "model text" })
})
test("restores skill mentions as structured Composer parts", () => {
const message = {
id: "msg_1",
type: "user",
text: "Use @review",
skills: [{ id: "review", name: "Review", mention: { text: "@review", start: 4, end: 11 } }],
time: { created: 1 },
} satisfies SessionMessageUser
expect(extractPromptFromMessage(message)).toMatchObject([
{ type: "text", content: "Use " },
{
type: "skill",
id: "review",
name: "Review",
content: "@review",
},
])
})
})
@@ -1,7 +1,8 @@
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
import { createLegacyBlobReference } from "@/utils/draft-store"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt, SkillPart } from "@/composer/state"
import { createLegacyBlobReference } from "@/runtime/persistence/drafts"
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import { readPromptPresentation } from "./comment-note"
import { Skill } from "@opencode-ai/schema/skill"
type Inline =
| {
@@ -16,6 +17,8 @@ type Inline =
startChar: number
endChar: number
}
mime?: string
filename?: string
}
| {
type: "agent"
@@ -24,6 +27,14 @@ type Inline =
value: string
name: string
}
| {
type: "skill"
start: number
end: number
value: string
id: Skill.ID
name: Skill.Name
}
function selectionFromFileUrl(url: string): Extract<Inline, { type: "file" }>["selection"] {
const queryIndex = url.indexOf("?")
@@ -95,6 +106,18 @@ export function extractPromptFromMessage(
name: agent.name,
})
}
for (const attached of message.skills ?? []) {
const mention = attached.mention
if (!mention) continue
inline.push({
type: "skill",
start: mention.start,
end: mention.end,
value: mention.text,
id: Skill.ID.make(attached.id),
name: Skill.Name.make(attached.name),
})
}
return buildPrompt(text, inline, images)
}
@@ -132,6 +155,8 @@ function buildPrompt(text: string, inline: Inline[], images: ImageAttachmentPart
start: position,
end: position + content.length,
selection: item.selection,
mime: item.mime,
filename: item.filename,
}
result.push(attachment)
position += content.length
@@ -150,6 +175,20 @@ function buildPrompt(text: string, inline: Inline[], images: ImageAttachmentPart
position += content.length
}
const pushSkill = (item: Extract<Inline, { type: "skill" }>) => {
const content = item.value
const skill: SkillPart = {
type: "skill",
id: item.id,
name: item.name,
content,
start: position,
end: position + content.length,
}
result.push(skill)
position += content.length
}
for (const item of inline) {
if (item.start < 0 || item.end < item.start) continue
@@ -165,6 +204,7 @@ function buildPrompt(text: string, inline: Inline[], images: ImageAttachmentPart
if (item.type === "file") pushFile(item)
if (item.type === "agent") pushAgent(item)
if (item.type === "skill") pushSkill(item)
cursor = end
}
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { Prompt } from "@/context/prompt"
import { buildPromptRequest } from "./build-prompt-request"
import { Skill } from "@opencode-ai/schema/skill"
import type { Prompt } from "@/composer/state"
import { buildPromptRequest } from "./request"
describe("buildPromptRequest", () => {
test("builds text, files, and agents from the prompt", () => {
@@ -318,4 +319,30 @@ describe("buildPromptRequest", () => {
// Should preserve .. segments (backend normalizes)
expect(file!.uri).toContain("/..")
})
test("keeps skill mentions out of file attachments", () => {
const skill = {
id: "skill-review",
name: "review",
}
const result = buildPromptRequest({
prompt: [
{
type: "skill",
id: Skill.ID.make(skill.id),
name: Skill.Name.make(skill.name),
content: "@review",
start: 0,
end: 7,
},
],
context: [],
images: [],
text: "@review",
sessionDirectory: "/repo",
})
expect(result.files).toEqual([])
expect(result.skills).toEqual([{ id: skill.id, name: skill.name, mention: { start: 0, end: 7, text: "@review" } }])
})
})
@@ -1,8 +1,8 @@
import { getFilename } from "@opencode-ai/util/path"
import type { FileSelection } from "@/context/file"
import { encodeFilePath } from "@/context/file/path"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
import { formatCommentNote, type PromptComment } from "@/utils/comment-note"
import type { FileSelection } from "@/workspaces/files/model"
import { encodeFilePath } from "@/workspaces/files/path"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt, SkillPart } from "@/composer/state"
import { formatCommentNote, type PromptComment } from "@/composer/comment-note"
// Network fields feed both boundaries; display fields keep desktop-only rendering details in the local echo.
type PromptRequest = {
@@ -10,6 +10,7 @@ type PromptRequest = {
displayText: string
files: { uri: string; mime: string; name?: string; mention?: { start: number; end: number; text: string } }[]
agents: { name: string; mention?: { start: number; end: number; text: string } }[]
skills: { id: string; name: string; mention?: { start: number; end: number; text: string } }[]
comments: PromptComment[]
}
@@ -54,8 +55,14 @@ const parseCommentMentions = (comment: string) => {
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
const isSkillAttachment = (part: Prompt[number]): part is SkillPart => part.type === "skill"
export function buildPromptRequest(input: BuildPromptRequestInput): PromptRequest {
const skills = input.prompt.filter(isSkillAttachment).map((attachment) => ({
id: attachment.id,
name: attachment.name,
mention: { start: attachment.start, end: attachment.end, text: attachment.content },
}))
const files = input.prompt.filter(isFileAttachment).map((attachment) => {
const path = absolute(input.sessionDirectory, attachment.path)
return {
@@ -110,6 +117,7 @@ export function buildPromptRequest(input: BuildPromptRequestInput): PromptReques
displayText: input.text,
files: [...files, ...context, ...images],
agents,
skills,
comments,
}
}
@@ -1,15 +1,53 @@
import { batch, createMemo, startTransition } from "solid-js"
import { useModels } from "@/context/models"
import type { ModelKey, ModelSelection } from "@/context/local"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "@/context/model-variant"
import { usePrompt } from "@/context/prompt"
import { useWorkspaceLocation } from "@/context/location"
import { useProviders } from "@/hooks/use-providers"
import { batch, type Accessor, createMemo, startTransition } from "solid-js"
import type { ComposerControls } from "./adapter"
import { useLayout } from "@/shell/state/layout"
import { useLocal, type ModelKey, type ModelSelection } from "@/providers/models/selection"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useProviders } from "@/providers/catalog/providers"
import { useData } from "@/runtime/server/current"
import { normalizeAgentList } from "@/runtime/server/global-sync/utils"
import { useModels } from "@/providers/models/models"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "@/providers/models/variant"
import { useComposerState } from "./persistence"
export function createPromptModelSelection(input: { agent: () => { model?: ModelKey; variant?: string } | undefined }) {
export function createComposerControls(input: { sessionKey: Accessor<string>; model?: ModelSelection }) {
const layout = useLayout()
const local = useLocal()
const sdk = useWorkspaceLocation()
const data = useData()
const providers = useProviders(() => sdk().directory)
const view = layout.view(input.sessionKey)
return createMemo<ComposerControls>(() => {
return {
agents: {
available: normalizeAgentList(data.location.agent.list({ directory: sdk().directory }) ?? []),
options: local.agent.list().map((agent) => agent.name),
current: local.agent.current()?.name ?? "",
visible: local.agent.visible(),
select: local.agent.set,
},
model: {
selection: input.model ?? local.model,
paid: providers.paid().length > 0,
loading:
(local.agent.visible() && data.location.agent.list({ directory: sdk().directory }) === undefined) ||
!providers.ready(),
},
session: {
tabs: layout.tabs(input.sessionKey),
reviewPanel: view.reviewPanel,
},
}
})
}
export function createComposerModelSelection(input: {
agent: () => { model?: ModelKey; variant?: string } | undefined
}) {
const sdk = useWorkspaceLocation()
const models = useModels()
const prompt = usePrompt()
const prompt = useComposerState()
const providers = useProviders(() => sdk().directory)
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
@@ -17,26 +55,17 @@ export function createPromptModelSelection(input: { agent: () => { model?: Model
const provider = providers.all().get(model.providerID)
return !!provider?.models[model.modelID] && connected().has(model.providerID)
}
const configured = () => {
// TODO: Restore the configured model fallback when current location data exposes config.
return undefined
}
const recent = () => models.recent.list().find(valid)
const fallback = () => {
return providers.connected().flatMap((provider) => {
const fallback = () =>
providers.connected().flatMap((provider) => {
const modelID = Object.values(provider.models)[0]?.id
return modelID ? [{ providerID: provider.id, modelID }] : []
})[0]
}
const current = () => {
const key = [prompt.model.current(), input.agent()?.model, configured(), recent(), fallback()].find(
const key = [prompt.model.current(), input.agent()?.model, recent(), fallback()].find(
(item): item is ModelKey => !!item && valid(item),
)
if (!key) return
return models.find(key)
return key ? models.find(key) : undefined
}
const recentModels = createMemo(() =>
models.recent
@@ -44,7 +73,6 @@ export function createPromptModelSelection(input: { agent: () => { model?: Model
.map(models.find)
.filter((item): item is NonNullable<typeof item> => !!item),
)
const selection = {
ready: models.ready,
current,
@@ -60,7 +88,7 @@ export function createPromptModelSelection(input: { agent: () => { model?: Model
if (next) selection.set({ providerID: next.provider.id, modelID: next.id })
},
set(item: ModelKey | undefined, options?: { recent?: boolean }) {
startTransition(() =>
void startTransition(() =>
batch(() => {
prompt.model.set(item ? { ...item, variant: prompt.model.current()?.variant } : undefined)
if (!item) return
@@ -100,7 +128,7 @@ export function createPromptModelSelection(input: { agent: () => { model?: Model
return Object.keys(current()?.variants ?? {})
},
set(value: string | undefined) {
startTransition(() =>
void startTransition(() =>
batch(() => {
const model = current()
if (!model) return
+110
View File
@@ -0,0 +1,110 @@
import { describe, expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { Skill } from "@opencode-ai/schema/skill"
import { createMemoryComposerState, DEFAULT_PROMPT, parseComposerStore } from "./state"
describe("prompt state initialization", () => {
test("initializes prompt text, cursor, and model together", () => {
createRoot((dispose) => {
const model = { providerID: "anthropic", modelID: "claude", variant: "high" }
const prompt = createMemoryComposerState({ prompt: "hello", model })
expect(prompt.current()).toEqual([{ type: "text", content: "hello", start: 0, end: 5 }])
expect(prompt.cursor()).toBe(5)
expect(prompt.model.current()).toEqual(model)
expect(prompt.model.current()).not.toBe(model)
dispose()
})
})
test("uses the default prompt without initial values", () => {
createRoot((dispose) => {
const prompt = createMemoryComposerState()
expect(prompt.current()).toEqual(DEFAULT_PROMPT)
expect(prompt.cursor()).toBeUndefined()
expect(prompt.model.current()).toBeUndefined()
dispose()
})
})
test("parses persisted state into one trusted current shape", () => {
const parsed = parseComposerStore({
prompt: [
{ type: "text", content: "hello", start: 0, end: 5 },
{ type: "skill", id: "effect", name: "Effect", content: "@effect", start: 5, end: 12 },
{ type: "image", id: "broken", filename: "broken.png", mime: "image/png", blob: { id: 42 } },
{
type: "image",
id: "missing-blob",
filename: "missing.png",
mime: "image/png",
blob: { id: "content-hash-without-a-url" },
},
{
type: "image",
id: "invalid-url",
filename: "invalid.png",
mime: "image/png",
blob: { id: "hash", url: "relative-url" },
},
{
type: "image",
id: "legacy",
filename: "legacy.png",
mime: "image/png",
dataUrl: "data:image/png;base64,AAA",
},
],
cursor: -2,
model: { providerID: "anthropic", modelID: "claude", variant: "high" },
retry: { id: "invalid", agent: "build", providerID: "anthropic", modelID: "claude" },
context: {
items: [
{
type: "file",
path: "src/app.ts",
selection: { startLine: 1, startChar: 0, endLine: 2, endChar: 3 },
comment: "Check this",
key: "untrusted",
},
{ type: "file", path: 42 },
],
},
})
expect(parsed).toEqual({
prompt: [
{ type: "text", content: "hello", start: 0, end: 5 },
{
type: "skill",
id: Skill.ID.make("effect"),
name: Skill.Name.make("Effect"),
content: "@effect",
start: 5,
end: 12,
},
{
type: "image",
id: "legacy",
filename: "legacy.png",
mime: "image/png",
blob: { id: "data:image/png;base64,AAA", url: "data:image/png;base64,AAA" },
},
],
cursor: 0,
model: { providerID: "anthropic", modelID: "claude", variant: "high" },
context: {
items: [
{
type: "file",
path: "src/app.ts",
selection: { startLine: 1, startChar: 0, endLine: 2, endChar: 3 },
comment: "Check this",
key: expect.stringMatching(/^file:src\/app\.ts:1:2:c=/),
},
],
},
})
expect(parseComposerStore("not an object")).toBeUndefined()
})
})
+506
View File
@@ -0,0 +1,506 @@
import { checksum } from "@opencode-ai/util/encode"
import { batch, type Accessor } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store"
import type { FileSelection } from "@/workspaces/files/model"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { ServerScope } from "@/runtime/server/scope"
import type { BlobReference } from "@/runtime/persistence/drafts"
import type { Platform } from "@/runtime/platform/platform"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Skill } from "@opencode-ai/schema/skill"
interface PartBase {
content: string
start: number
end: number
}
type FilePartSourceText = { value: string; start: number; end: number }
type FilePartSource =
| { text: FilePartSourceText; type: "file"; path: string }
| {
text: FilePartSourceText
type: "symbol"
path: string
range: { start: { line: number; character: number }; end: { line: number; character: number } }
name: string
kind: number
}
| { text: FilePartSourceText; type: "resource"; clientName: string; uri: string }
export interface TextPart extends PartBase {
type: "text"
}
export interface FileAttachmentPart extends PartBase {
type: "file"
path: string
selection?: FileSelection
mime?: string
filename?: string
url?: string
source?: FilePartSource
}
export interface AgentPart extends PartBase {
type: "agent"
name: string
}
export interface SkillPart extends PartBase {
type: "skill"
id: Skill.ID
name: Skill.Name
}
export interface ImageAttachmentPart {
type: "image"
id: string
filename: string
sourcePath?: string
mime: string
blob: BlobReference
}
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | SkillPart | ImageAttachmentPart
export type Prompt = ContentPart[]
export type PromptModel = {
providerID: string
modelID: string
variant?: string | null
}
export type FileContextItem = {
type: "file"
path: string
selection?: FileSelection
comment?: string
commentID?: string
commentOrigin?: "review" | "file"
preview?: string
}
export type ContextItem = FileContextItem
export type PromptScope = { draftID: string } | { dir: string; id?: string }
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
export type ComposerStore = {
prompt: Prompt
cursor?: number
model?: PromptModel
mode?: "normal" | "shell"
retry?: {
id: SessionMessage.ID
agent: string
providerID: string
modelID: string
variant?: string
}
context: {
items: (ContextItem & { key: string })[]
}
}
type InitialPrompt = {
prompt?: string
model?: PromptModel
}
function cloneSelection(selection?: FileSelection) {
if (!selection) return undefined
return { ...selection }
}
function clonePart(part: ContentPart): ContentPart {
if (part.type === "text") return { ...part }
if (part.type === "image") return { ...part }
if (part.type === "agent") return { ...part }
if (part.type === "skill") return { ...part }
return {
...part,
selection: cloneSelection(part.selection),
}
}
function clonePrompt(prompt: Prompt): Prompt {
return prompt.map(clonePart)
}
function contextItemKey(item: ContextItem) {
if (item.type !== "file") return item.type
const start = item.selection?.startLine
const end = item.selection?.endLine
const key = `${item.type}:${item.path}:${start}:${end}`
if (item.commentID) return `${key}:c=${item.commentID}`
const comment = item.comment?.trim()
if (!comment) return key
const digest = checksum(comment) ?? comment
return `${key}:c=${digest.slice(0, 8)}`
}
export function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
return item.type === "file" && !!item.comment?.trim()
}
function createComposerActions(setStore: SetStoreFunction<ComposerStore>) {
return {
set(prompt: Prompt, cursorPosition?: number) {
const next = clonePrompt(prompt)
batch(() => {
setStore("prompt", next)
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
setStore("retry", undefined)
})
},
reset() {
batch(() => {
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
setStore("cursor", 0)
setStore("retry", undefined)
})
},
}
}
function composerTarget(serverScope: ServerScope, scope: PromptScope) {
const target =
"draftID" in scope
? Persist.prompt(Persist.draft(scope.draftID, "prompt"))
: Persist.prompt({
...Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt"),
...(serverScope === ServerScope.local
? { previousKey: `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2` }
: {}),
})
return { ...target, migrate: parseComposerStore }
}
function initialComposerStore(initial?: InitialPrompt): ComposerStore {
const text = initial?.prompt
return {
prompt:
text === undefined ? clonePrompt(DEFAULT_PROMPT) : [{ type: "text", content: text, start: 0, end: text.length }],
cursor: text === undefined ? undefined : text.length,
model: initial?.model ? { ...initial.model } : undefined,
context: {
items: [],
},
}
}
export function parseComposerStore(value: unknown): ComposerStore | undefined {
if (!record(value)) return
const prompt = Array.isArray(value.prompt) ? value.prompt.flatMap(parsePart) : []
const context = record(value.context) && Array.isArray(value.context.items) ? value.context.items : []
const model = parseModel(value.model)
const retry = parseRetry(value.retry)
return {
prompt: prompt.length ? prompt : clonePrompt(DEFAULT_PROMPT),
...(typeof value.cursor === "number" && Number.isFinite(value.cursor) ? { cursor: Math.max(0, value.cursor) } : {}),
...(model ? { model } : {}),
...(value.mode === "normal" || value.mode === "shell" ? { mode: value.mode } : {}),
...(retry ? { retry } : {}),
context: {
items: context.flatMap((item) => {
const parsed = parseContextItem(item)
return parsed ? [{ ...parsed, key: contextItemKey(parsed) }] : []
}),
},
}
}
function parseRetry(value: unknown): ComposerStore["retry"] {
if (
!record(value) ||
typeof value.id !== "string" ||
!value.id.startsWith("msg_") ||
typeof value.agent !== "string" ||
typeof value.providerID !== "string" ||
typeof value.modelID !== "string"
) {
return
}
return {
id: SessionMessage.ID.make(value.id),
agent: value.agent,
providerID: value.providerID,
modelID: value.modelID,
...(typeof value.variant === "string" ? { variant: value.variant } : {}),
}
}
function parsePart(value: unknown): ContentPart[] {
if (!record(value) || typeof value.type !== "string") return []
if (value.type === "image") {
const legacy = typeof value.dataUrl === "string" ? value.dataUrl : undefined
const blobID = record(value.blob) && typeof value.blob.id === "string" ? value.blob.id : legacy
const hydrated = record(value.blob) && typeof value.blob.url === "string" ? value.blob.url : undefined
const blobURL =
hydrated?.startsWith("blob:") || hydrated?.startsWith("data:")
? hydrated
: blobID?.startsWith("data:")
? blobID
: undefined
if (
typeof value.id !== "string" ||
typeof value.filename !== "string" ||
typeof value.mime !== "string" ||
!blobID ||
!blobURL
) {
return []
}
return [
{
type: "image",
id: value.id,
filename: value.filename,
mime: value.mime,
blob: { id: blobID, url: blobURL },
...(typeof value.sourcePath === "string" ? { sourcePath: value.sourcePath } : {}),
},
]
}
if (typeof value.content !== "string" || typeof value.start !== "number" || typeof value.end !== "number") return []
if (value.type === "text") return [{ type: "text", content: value.content, start: value.start, end: value.end }]
if (value.type === "agent" && typeof value.name === "string") {
return [{ type: "agent", name: value.name, content: value.content, start: value.start, end: value.end }]
}
if (value.type === "skill" && typeof value.id === "string" && typeof value.name === "string") {
return [
{
type: "skill",
id: Skill.ID.make(value.id),
name: Skill.Name.make(value.name),
content: value.content,
start: value.start,
end: value.end,
},
]
}
if (value.type !== "file" || typeof value.path !== "string") return []
const selection = parseSelection(value.selection)
const source = parseSource(value.source)
return [
{
type: "file",
path: value.path,
content: value.content,
start: value.start,
end: value.end,
...(typeof value.mime === "string" ? { mime: value.mime } : {}),
...(typeof value.filename === "string" ? { filename: value.filename } : {}),
...(typeof value.url === "string" ? { url: value.url } : {}),
...(selection ? { selection } : {}),
...(source ? { source } : {}),
},
]
}
function parseContextItem(value: unknown): ContextItem | undefined {
if (!record(value) || value.type !== "file" || typeof value.path !== "string") return
const selection = parseSelection(value.selection)
const origin = value.commentOrigin === "review" || value.commentOrigin === "file" ? value.commentOrigin : undefined
return {
type: "file",
path: value.path,
...(selection ? { selection } : {}),
...(typeof value.comment === "string" ? { comment: value.comment } : {}),
...(typeof value.commentID === "string" ? { commentID: value.commentID } : {}),
...(origin ? { commentOrigin: origin } : {}),
...(typeof value.preview === "string" ? { preview: value.preview } : {}),
}
}
function parseModel(value: unknown): PromptModel | undefined {
if (!record(value) || typeof value.providerID !== "string" || typeof value.modelID !== "string") return
return {
providerID: value.providerID,
modelID: value.modelID,
...(typeof value.variant === "string" || value.variant === null ? { variant: value.variant } : {}),
}
}
function parseSelection(value: unknown): FileSelection | undefined {
if (!record(value)) return
if (
typeof value.startLine !== "number" ||
typeof value.startChar !== "number" ||
typeof value.endLine !== "number" ||
typeof value.endChar !== "number"
) {
return
}
return {
startLine: value.startLine,
startChar: value.startChar,
endLine: value.endLine,
endChar: value.endChar,
}
}
function parseSource(value: unknown): FilePartSource | undefined {
if (!record(value) || !record(value.text)) return
if (
typeof value.text.value !== "string" ||
typeof value.text.start !== "number" ||
typeof value.text.end !== "number"
) {
return
}
const text = { value: value.text.value, start: value.text.start, end: value.text.end }
if (value.type === "file" && typeof value.path === "string") return { type: "file", path: value.path, text }
if (value.type === "resource" && typeof value.clientName === "string" && typeof value.uri === "string") {
return { type: "resource", clientName: value.clientName, uri: value.uri, text }
}
if (
value.type !== "symbol" ||
typeof value.path !== "string" ||
typeof value.name !== "string" ||
typeof value.kind !== "number" ||
!record(value.range) ||
!record(value.range.start) ||
!record(value.range.end) ||
typeof value.range.start.line !== "number" ||
typeof value.range.start.character !== "number" ||
typeof value.range.end.line !== "number" ||
typeof value.range.end.character !== "number"
) {
return
}
return {
type: "symbol",
path: value.path,
name: value.name,
kind: value.kind,
text,
range: {
start: { line: value.range.start.line, character: value.range.start.character },
end: { line: value.range.end.line, character: value.range.end.character },
},
}
}
function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function createComposerStateValue(store: ComposerStore, setStore: SetStoreFunction<ComposerStore>) {
const actions = createComposerActions(setStore)
const clearRetry = () => setStore("retry", undefined)
const value = {
store: [() => store, setStore] as [Accessor<ComposerStore>, SetStoreFunction<ComposerStore>],
current: () => store.prompt,
cursor: () => store.cursor,
model: {
current: () => store.model,
set: (model: PromptModel | undefined) => {
setStore("model", model)
clearRetry()
},
},
mode: {
current: () => store.mode ?? "normal",
set: (mode: "normal" | "shell") => {
setStore("mode", mode)
clearRetry()
},
},
retry: {
current: () => store.retry,
set: (retry: NonNullable<ComposerStore["retry"]>) => setStore("retry", retry),
},
context: {
items: () => store.context.items,
add(item: ContextItem) {
const key = contextItemKey(item)
if (store.context.items.find((x) => x.key === key)) return
setStore("context", "items", (items) => [...items, { key, ...item }])
clearRetry()
},
remove(key: string) {
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
clearRetry()
},
removeComment(path: string, commentID: string) {
setStore("context", "items", (items) =>
items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)),
)
clearRetry()
},
updateComment(path: string, commentID: string, next: Partial<FileContextItem> & { comment?: string }) {
setStore("context", "items", (items) =>
items.map((item) => {
if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item
const value = { ...item, ...next }
return { ...value, key: contextItemKey(value) }
}),
)
clearRetry()
},
replaceComments(items: FileContextItem[]) {
setStore("context", "items", (current) => [
...current.filter((item) => !isCommentItem(item)),
...items.map((item) => ({ ...item, key: contextItemKey(item) })),
])
clearRetry()
},
},
set: (prompt: Prompt, cursorPosition?: number) => actions.set(prompt, cursorPosition),
reset: () => actions.reset(),
capture: () => value,
}
return value
}
function createPersistedComposer(
target: ReturnType<typeof composerTarget>,
initial?: InitialPrompt,
platform?: Platform,
) {
const [store, setStore, _, ready] = persisted(
target,
createStore<ComposerStore>(initialComposerStore(initial)),
platform,
)
return { ready, ...createComposerStateValue(store, setStore) }
}
export function createComposerState(
serverScope: ServerScope,
scope: PromptScope,
initial?: InitialPrompt,
platform?: Platform,
) {
return createPersistedComposer(composerTarget(serverScope, scope), initial, platform)
}
export function createDraftComposerState(draftID: string, initial?: InitialPrompt) {
return createPersistedComposer(
{
...Persist.prompt(Persist.draft(draftID, "prompt")),
migrate: parseComposerStore,
},
initial,
)
}
export type ComposerState = ReturnType<typeof createComposerState>
export function createComposerReady(session: Accessor<ComposerState>) {
return Object.defineProperty(() => session().ready(), "promise", {
get: () => session().ready.promise,
}) as (() => boolean) & { readonly promise: Promise<unknown> | undefined }
}
export function createMemoryComposerState(initial?: InitialPrompt) {
const [store, setStore] = createStore<ComposerStore>(initialComposerStore(initial))
const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
return {
ready,
...createComposerStateValue(store, setStore),
}
}
@@ -1,9 +1,9 @@
import { type ContextItem, type Prompt, type usePrompt } from "@/context/prompt"
import type { ComposerState, ContextItem, Prompt } from "./state"
type PromptTarget = ReturnType<ReturnType<typeof usePrompt>["capture"]>
export type ComposerStateTarget = ReturnType<ComposerState["capture"]>
export function createPromptSubmissionState(input: {
target: PromptTarget
export function createComposerSubmission(input: {
target: ComposerStateTarget
prompt: Prompt
context: (ContextItem & { key: string })[]
}) {
@@ -20,11 +20,11 @@ export function createPromptSubmissionState(input: {
target.reset()
cleared = target.current()
},
retarget(next: PromptTarget) {
input.context.forEach(next.context.add)
retarget(next: ComposerStateTarget) {
input.context.forEach((item) => next.context.add(item))
target = next
},
current: (value: PromptTarget) => target === value,
current: (value: ComposerStateTarget) => target === value,
restore() {
if (cleared !== undefined && target.current() !== cleared) return
return { target, prompt: input.prompt, context: input.context }
+364
View File
@@ -0,0 +1,364 @@
import { describe, expect, test } from "bun:test"
import type { ModelSelection } from "@/providers/models/selection"
import { Skill } from "@opencode-ai/schema/skill"
import type { ActiveComposerAdapter, ComposerControls, ComposerSession, NewSessionComposerAdapter } from "./adapter"
import { createMemoryComposerState } from "./state"
import { createComposerSubmit } from "./submit"
const selectedModel = {
id: "model-1",
name: "Model 1",
provider: { id: "provider-1" },
} as NonNullable<ReturnType<ModelSelection["current"]>>
const selection = {
ready: Object.assign(() => true, { promise: undefined }),
current: () => selectedModel,
recent: () => [selectedModel],
list: () => [selectedModel],
cycle() {},
set() {},
visible: () => true,
setVisibility() {},
variant: {
configured: () => undefined,
selected: () => "balanced",
current: () => "balanced",
list: () => ["balanced"],
set() {},
cycle() {},
},
} satisfies ModelSelection
function controls(): ComposerControls {
return {
agents: {
available: [{ name: "build", mode: "primary" }],
options: ["build"],
current: "build",
visible: true,
select() {},
},
model: { selection, paid: true, loading: false },
session: {
tabs: { active: () => undefined, all: () => [], open() {}, setActive() {} },
reviewPanel: { opened: () => false, open() {} },
},
}
}
function submitInput(
adapter: ActiveComposerAdapter | NewSessionComposerAdapter,
notify = { missingSelection() {}, failed(_kind: "shell" | "command" | "prompt", _error: unknown) {} },
mode: "normal" | "shell" = "normal",
) {
return createComposerSubmit({
adapter,
mode: () => mode,
editor: () => undefined,
queueScroll() {},
addToHistory() {},
resetHistory() {},
setMode() {},
closePopover() {},
notify,
comments: { capture: () => [], clear() {}, restore() {} },
})
}
function session(input: {
calls: string[]
prompt: (value: Parameters<ComposerSession["data"]["session"]["prompt"]>[0]) => Promise<void>
current?: ComposerSession["current"]
admitted?: (messageID: string) => boolean
shell?: () => Promise<unknown>
command?: ComposerSession["api"]["command"]
}): ComposerSession {
return {
id: "session-1",
directory: "C:/repo",
current: input.current ?? (() => undefined),
admitted: input.admitted ?? (() => false),
api: {
switchAgent: async () => {
input.calls.push("switch-agent")
},
switchModel: async () => {
input.calls.push("switch-model")
},
shell: input.shell ?? (async () => undefined),
command: input.command ?? (async () => undefined),
},
data: {
location: { command: { list: () => [] } },
session: {
prompt: async (value) => {
input.calls.push("prompt")
await input.prompt(value)
},
},
},
}
}
describe("Composer submission", () => {
test("sends one captured value with explicit delivery after selection switches", async () => {
const state = createMemoryComposerState({ prompt: "ship it" }).capture()
const calls: string[] = []
const admitted = Promise.withResolvers<Parameters<ComposerSession["data"]["session"]["prompt"]>[0]>()
const target = session({
calls,
current: () => ({ agent: "plan", model: { id: "old", providerID: "old" } }),
prompt: async (value) => admitted.resolve(value),
})
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
ready: () => true,
controls,
working: () => false,
session: () => target,
interrupt: async () => undefined,
submitted() {},
setEditor() {},
}
await submitInput(adapter).submit(new Event("submit"))
const request = await admitted.promise
expect(calls).toEqual(["switch-agent", "switch-model", "prompt"])
expect(request.delivery).toBe("steer")
expect(request.text).toBe("ship it")
expect(request.id).toMatch(/^msg_/)
expect(request.metadata).toMatchObject({
displayText: "ship it",
agent: "build",
model: { providerID: "provider-1", modelID: "model-1", variant: "balanced" },
})
expect(state.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
})
test("starts and promotes a New Session once before admitting its first prompt", async () => {
const draft = createMemoryComposerState({ prompt: "first prompt" }).capture()
const promoted = createMemoryComposerState().capture()
const calls: string[] = []
const admitted = Promise.withResolvers<Parameters<ComposerSession["data"]["session"]["prompt"]>[0]>()
const target = session({ calls, prompt: async (value) => admitted.resolve(value) })
const adapter: NewSessionComposerAdapter = {
kind: "new-session",
state: draft,
ready: () => true,
controls,
working: () => false,
submitted() {
calls.push("submitted")
},
async start(_selection, submission) {
calls.push("start")
submission.retarget(promoted)
return target
},
}
await submitInput(adapter).submit(new Event("submit"))
const request = await admitted.promise
expect(calls).toEqual(["start", "submitted", "switch-agent", "switch-model", "prompt"])
expect(request.delivery).toBe("steer")
expect(request.text).toBe("first prompt")
expect(draft.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
expect(promoted.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
})
test("does not restore a prompt already acknowledged by the durable inbox", async () => {
const state = createMemoryComposerState({ prompt: "admitted prompt" }).capture()
const checked = Promise.withResolvers<void>()
const attempts: string[] = []
const target = session({
calls: [],
admitted: () => {
checked.resolve()
return true
},
prompt: async (value) => {
attempts.push(value.id ?? "")
throw new Error("response lost")
},
})
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
ready: () => true,
controls,
working: () => false,
session: () => target,
interrupt: async () => undefined,
submitted() {},
setEditor() {},
}
await submitInput(adapter).submit(new Event("submit"))
await checked.promise
expect(state.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
expect(attempts).toHaveLength(2)
expect(new Set(attempts).size).toBe(1)
})
test("restores first-prompt comments into the promoted Session", async () => {
const draft = createMemoryComposerState({ prompt: "first prompt" }).capture()
draft.store[1]("context", "items", [
{
key: "file:src/app.ts:1:1:comment",
type: "file",
path: "src/app.ts",
comment: "Keep this comment",
selection: { startLine: 1, startChar: 0, endLine: 1, endChar: 4 },
},
])
expect(draft.context.items()).toHaveLength(1)
const promoted = createMemoryComposerState().capture()
const failed = Promise.withResolvers<void>()
const target = session({
calls: [],
prompt: async () => undefined,
shell: async () => Promise.reject(new Error("send failed")),
})
const adapter: NewSessionComposerAdapter = {
kind: "new-session",
state: draft,
ready: () => true,
controls,
working: () => false,
submitted() {},
async start(_selection, submission) {
submission.retarget(promoted)
return target
},
}
await submitInput(adapter, { missingSelection() {}, failed: () => failed.resolve() }, "shell").submit(
new Event("submit"),
)
await failed.promise
expect(promoted.current()).toMatchObject([{ type: "text", content: "first prompt" }])
expect(promoted.context.items()).toMatchObject([{ type: "file", path: "src/app.ts", comment: "Keep this comment" }])
expect(promoted.mode.current()).toBe("shell")
})
test("reuses the message ID when an unacknowledged admission is retried", async () => {
const state = createMemoryComposerState({ prompt: "retry me" }).capture()
const attempts: string[] = []
const first = Promise.withResolvers<void>()
const second = Promise.withResolvers<void>()
const target = session({
calls: [],
prompt: async (value) => {
attempts.push(value.id ?? "")
throw new Error("network unavailable")
},
})
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
ready: () => true,
controls,
working: () => false,
session: () => target,
interrupt: async () => undefined,
submitted() {},
setEditor() {},
}
const notify = {
missingSelection() {},
failed: () => (attempts.length === 2 ? first.resolve() : second.resolve()),
}
const submission = submitInput(adapter, notify)
await submission.submit(new Event("submit"))
await first.promise
await submission.submit(new Event("submit"))
await second.promise
expect(attempts).toHaveLength(4)
expect(new Set(attempts).size).toBe(1)
expect(state.current()).toMatchObject([{ type: "text", content: "retry me" }])
})
test("forwards structured mentions to custom commands", async () => {
const state = createMemoryComposerState().capture()
state.set([
{ type: "text", content: "/review ", start: 0, end: 8 },
{ type: "file", path: "src/app.ts", content: "@src/app.ts", start: 8, end: 19 },
{ type: "text", content: " ", start: 19, end: 20 },
{ type: "agent", name: "review", content: "@review", start: 20, end: 27 },
{ type: "text", content: " ", start: 27, end: 28 },
{
type: "skill",
id: Skill.ID.make("effect"),
name: Skill.Name.make("Effect"),
content: "@effect",
start: 28,
end: 35,
},
])
const sent = Promise.withResolvers<Parameters<ComposerSession["api"]["command"]>[0]>()
const target = session({
calls: [],
prompt: async () => undefined,
command: async (value) => sent.resolve(value),
})
target.data.location.command.list = () => [{ name: "review", description: "Review changes", template: "" }]
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
ready: () => true,
controls,
working: () => false,
session: () => target,
interrupt: async () => undefined,
submitted() {},
setEditor() {},
}
await submitInput(adapter).submit(new Event("submit"))
const request = await sent.promise
expect(request.files).toMatchObject([{ name: "app.ts", mention: { text: "@src/app.ts" } }])
expect(request.agents).toMatchObject([{ name: "review", mention: { text: "@review" } }])
expect(request.skills).toMatchObject([{ id: "effect", name: "Effect", mention: { text: "@effect" } }])
expect(request.delivery).toBe("steer")
})
test("does not run an empty shell command from hidden attachments", async () => {
const state = createMemoryComposerState().capture()
state.set([
{ type: "text", content: "", start: 0, end: 0 },
{
type: "image",
id: "attachment",
filename: "notes.txt",
mime: "text/plain",
blob: { id: "attachment", url: "data:text/plain;base64,bm90ZXM=" },
},
])
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
ready: () => true,
controls,
working: () => false,
session: () => {
throw new Error("shell should not run")
},
interrupt: async () => undefined,
submitted() {},
setEditor() {},
}
await submitInput(adapter, undefined, "shell").submit(new Event("submit"))
expect(state.current().some((part) => part.type === "image")).toBe(true)
})
})
+323
View File
@@ -0,0 +1,323 @@
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Event } from "@opencode-ai/schema/event"
import type { Accessor } from "solid-js"
import { clonePromptParts, type PromptHistoryComment } from "./history/entry"
import type { ImageAttachmentPart, Prompt } from "./state"
import type { ComposerAdapter, ComposerSelection, ComposerSession } from "./adapter"
import { createComposerSubmission } from "./submission-state"
import { buildPromptRequest } from "./request"
import { setCursorPosition } from "./editor/dom"
import { blobDataUrl } from "@/runtime/persistence/drafts"
const submitting = new WeakSet<object>()
type ComposerSubmission = {
id: SessionMessage.ID
mode: "normal" | "shell"
prompt: Prompt
context: ReturnType<ComposerAdapter["state"]["context"]["items"]>
text: string
images: ImageAttachmentPart[]
selection: ComposerSelection
delivery: "steer"
}
type ComposerSubmitInput = {
adapter: ComposerAdapter
mode: Accessor<"normal" | "shell">
editor: () => HTMLDivElement | undefined
queueScroll: () => void
addToHistory: (prompt: Prompt, mode: "normal" | "shell") => void
resetHistory: () => void
setMode: (mode: "normal" | "shell") => void
closePopover: () => void
notify: {
missingSelection: () => void
failed: (kind: "shell" | "command" | "prompt", error: unknown) => void
}
comments: {
capture: () => PromptHistoryComment[]
clear: () => void
restore: (comments: PromptHistoryComment[]) => void
}
}
export function createComposerSubmit(input: ComposerSubmitInput) {
const submit = async (event: globalThis.Event) => {
event.preventDefault()
const submission = createComposerSubmission({
target: input.adapter.state,
prompt: clonePromptParts(input.adapter.state.current()),
context: input.adapter.state.context.items().map((item) => ({
...item,
selection: item.selection ? { ...item.selection } : undefined,
})),
})
const value = readSubmission(input, submission.prompt, submission.context)
if (!value) {
if (input.adapter.working() && input.adapter.kind === "active-session") void input.adapter.interrupt()
return
}
if (submitting.has(input.adapter.state)) return
submitting.add(input.adapter.state)
const comments = input.comments.capture()
try {
const session =
input.adapter.kind === "active-session"
? input.adapter.session()
: await input.adapter.start(value.selection, submission)
if (!session) return
input.addToHistory(value.prompt, value.mode)
input.resetHistory()
const restore = () => restoreSubmission(input, submission, value, comments)
input.adapter.submitted()
if (value.mode === "shell") {
clearSubmission(input, submission)
void sendShell(session, value).catch((error) => failSubmission(input, session, "shell", error, restore))
return
}
const command = findCommand(session, value.text)
if (command) {
clearSubmission(input, submission)
void sendCommand(session, value, command).catch((error) =>
failSubmission(input, session, "command", error, restore, value.id),
)
return
}
submission.context
.filter((item) => !!item.comment?.trim())
.forEach((item) => submission.target().context.remove(item.key))
input.comments.clear()
clearSubmission(input, submission)
void sendPrompt(session, value).catch((error) =>
failSubmission(input, session, "prompt", error, restore, value.id),
)
} finally {
submitting.delete(input.adapter.state)
}
}
return {
submit,
stop: () => (input.adapter.kind === "active-session" ? input.adapter.interrupt() : Promise.resolve()),
}
}
function readSubmission(
input: ComposerSubmitInput,
prompt: Prompt,
context: ComposerSubmission["context"],
): ComposerSubmission | undefined {
const text = prompt.map((part) => ("content" in part ? part.content : "")).join("")
const mode = input.mode()
if (mode === "shell" && !text.trim()) return
const images = prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
const comments = context.filter((item) => !!item.comment?.trim()).length
if (!text.trim() && images.length === 0 && comments === 0) return
const controls = input.adapter.controls()
const model = controls.model.selection.current()
const agent = controls.agents.current
if (!model || !agent) {
input.notify.missingSelection()
return
}
const variant = controls.model.selection.variant.current()
const retry = input.adapter.state.retry.current()
const retryID =
retry &&
retry.agent === agent &&
retry.providerID === model.provider.id &&
retry.modelID === model.id &&
(retry.variant ?? "default") === (variant ?? "default")
? retry.id
: undefined
return {
id: retryID ?? SessionMessage.ID.create(),
mode,
prompt,
context,
text,
images,
selection: {
agent,
model: { modelID: model.id, providerID: model.provider.id },
variant,
},
delivery: "steer",
}
}
function clearSubmission(input: ComposerSubmitInput, submission: ReturnType<typeof createComposerSubmission>) {
submission.clear()
submission.target().mode.set("normal")
input.setMode("normal")
input.closePopover()
}
function restoreSubmission(
input: ComposerSubmitInput,
submission: ReturnType<typeof createComposerSubmission>,
value: ComposerSubmission,
comments: PromptHistoryComment[],
) {
const restored = submission.restore()
if (!restored) return false
restored.target.set(restored.prompt, promptLength(restored.prompt))
restored.target.mode.set(value.mode)
restored.target.context.replaceComments(
restored.context
.filter((item) => !!item.comment?.trim())
.map((item) => ({
type: "file",
path: item.path,
selection: item.selection,
comment: item.comment,
commentID: item.commentID,
commentOrigin: item.commentOrigin,
preview: item.preview,
})),
)
if (value.mode === "normal") {
restored.target.retry.set({
id: value.id,
agent: value.selection.agent,
providerID: value.selection.model.providerID,
modelID: value.selection.model.modelID,
variant: value.selection.variant,
})
}
if (!submission.current(input.adapter.state)) return true
input.comments.restore(comments)
input.setMode(value.mode)
input.closePopover()
requestAnimationFrame(() => {
const editor = input.editor()
if (!editor) return
editor.focus()
setCursorPosition(editor, promptLength(value.prompt))
input.queueScroll()
})
return true
}
async function sendShell(session: ComposerSession, value: ComposerSubmission) {
await session.api.shell({ sessionID: session.id, id: Event.ID.create(), command: value.text })
}
function findCommand(session: ComposerSession, text: string) {
if (!text.startsWith("/")) return
const [name, ...arguments_] = text.split(" ")
const command = name.slice(1)
if (!session.data.location.command.list({ directory: session.directory })?.some((item) => item.name === command))
return
return { command, arguments: arguments_.join(" ") }
}
async function sendCommand(
session: ComposerSession,
value: ComposerSubmission,
command: { command: string; arguments: string },
) {
const request = await buildSubmissionRequest(session, value)
await session.api.command({
sessionID: session.id,
id: value.id,
command: command.command,
arguments: command.arguments,
agent: value.selection.agent,
model: {
id: value.selection.model.modelID,
providerID: value.selection.model.providerID,
variant: value.selection.variant,
},
files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
agents: request.agents,
skills: request.skills,
delivery: value.delivery,
})
}
async function sendPrompt(session: ComposerSession, value: ComposerSubmission) {
const request = await buildSubmissionRequest(session, value)
const current = session.current()
if (current?.agent !== value.selection.agent) {
await session.api.switchAgent({ sessionID: session.id, agent: value.selection.agent })
}
if (
current?.model?.providerID !== value.selection.model.providerID ||
current.model.id !== value.selection.model.modelID ||
(current.model.variant ?? "default") !== (value.selection.variant ?? "default")
) {
await session.api.switchModel({
sessionID: session.id,
model: {
id: value.selection.model.modelID,
providerID: value.selection.model.providerID,
variant: value.selection.variant,
},
})
}
const admission = {
id: value.id,
sessionID: session.id,
delivery: value.delivery,
text: request.text,
files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
agents: request.agents,
skills: request.skills,
metadata: {
displayText: request.displayText,
comments: request.comments,
agent: value.selection.agent,
model: {
...value.selection.model,
...(value.selection.variant ? { variant: value.selection.variant } : {}),
},
},
}
await session.data.session.prompt(admission).catch(() => session.data.session.prompt(admission))
}
async function buildSubmissionRequest(session: ComposerSession, value: ComposerSubmission) {
const images = await Promise.all(
value.images.map(async (attachment) => ({
...attachment,
dataUrl: await blobDataUrl(attachment.blob, attachment.mime),
})),
)
const request = buildPromptRequest({
prompt: value.prompt,
context: value.context,
images,
text: value.text,
sessionDirectory: session.directory,
})
return request
}
function failSubmission(
input: ComposerSubmitInput,
session: ComposerSession,
kind: "shell" | "command" | "prompt",
error: unknown,
restore: () => boolean,
messageID?: string,
) {
if (messageID && session.admitted(messageID)) return
restore()
input.notify.failed(kind, error)
}
function promptLength(prompt: Prompt) {
return prompt.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0)
}
@@ -1,14 +1,14 @@
import { describe, expect, test } from "bun:test"
import type { PromptInputV2PersistedState, PromptInputV2Suggestion } from "./types"
import { createPromptInputV2InteractionState, transitionPromptInputV2 } from "./machine"
import type { ComposerPersistedState, ComposerSuggestion } from "../types"
import { createComposerInteractionState, transitionComposer } from "./machine"
const command: PromptInputV2Suggestion = {
const command: ComposerSuggestion = {
id: "review",
kind: "command",
label: "/review",
}
function persisted(value = ""): PromptInputV2PersistedState {
function persisted(value = ""): ComposerPersistedState {
return {
prompt: [{ type: "text", content: value, start: 0, end: value.length }],
cursor: value.length,
@@ -16,24 +16,24 @@ function persisted(value = ""): PromptInputV2PersistedState {
}
}
describe("prompt input v2 interaction machine", () => {
describe("Composer interaction machine", () => {
test("opens inline commands only when slash is the entire prompt", () => {
const state = createPromptInputV2InteractionState()
const open = transitionPromptInputV2(state, { type: "input.changed", value: "/re" }, persisted())
const closed = transitionPromptInputV2(state, { type: "input.changed", value: "explain /re" }, persisted())
const state = createComposerInteractionState()
const open = transitionComposer(state, { type: "input.changed", value: "/re" }, persisted())
const closed = transitionComposer(state, { type: "input.changed", value: "explain /re" }, persisted())
expect(open.state.popover).toEqual({ type: "command-inline", query: "re" })
expect(closed.state.popover).toEqual({ type: "closed" })
})
test("completes nested slash command names", () => {
const open = transitionPromptInputV2(
createPromptInputV2InteractionState(),
const open = transitionComposer(
createComposerInteractionState(),
{ type: "input.changed", value: "/review/" },
persisted(),
)
const item = { ...command, label: "/review/nested" }
const selected = transitionPromptInputV2(open.state, { type: "popover.select", item }, persisted("/review/"))
const selected = transitionComposer(open.state, { type: "popover.select", item }, persisted("/review/"))
expect(open.state.popover).toEqual({ type: "command-inline", query: "review/" })
expect(selected.commands).toContainEqual({ type: "draft.setText", value: "/review/nested " })
@@ -44,8 +44,8 @@ describe("prompt input v2 interaction machine", () => {
const input = persisted(value)
input.cursor = 9
const result = transitionPromptInputV2(
createPromptInputV2InteractionState(),
const result = transitionComposer(
createComposerInteractionState(),
{ type: "input.changed", value, persist: false },
input,
)
@@ -54,8 +54,8 @@ describe("prompt input v2 interaction machine", () => {
})
test("enters shell mode from an initial exclamation mark", () => {
const result = transitionPromptInputV2(
createPromptInputV2InteractionState(),
const result = transitionComposer(
createComposerInteractionState(),
{ type: "input.changed", value: "!", persist: false },
persisted("!"),
)
@@ -65,8 +65,8 @@ describe("prompt input v2 interaction machine", () => {
})
test("leaves shell mode with escape", () => {
const state = { ...createPromptInputV2InteractionState(), mode: "shell" as const }
const result = transitionPromptInputV2(
const state = { ...createComposerInteractionState(), mode: "shell" as const }
const result = transitionComposer(
state,
{ type: "key.down", key: "Escape", ctrl: false, composing: false, ids: [] },
persisted(),
@@ -77,8 +77,8 @@ describe("prompt input v2 interaction machine", () => {
})
test("leaves shell mode with backspace when empty", () => {
const state = { ...createPromptInputV2InteractionState(), mode: "shell" as const }
const result = transitionPromptInputV2(
const state = { ...createComposerInteractionState(), mode: "shell" as const }
const result = transitionComposer(
state,
{ type: "key.down", key: "Backspace", ctrl: false, composing: false, ids: [], empty: true },
persisted(),
@@ -90,10 +90,10 @@ describe("prompt input v2 interaction machine", () => {
test("closes a popover with ctrl-g before stopping a run", () => {
const state = {
...createPromptInputV2InteractionState(),
...createComposerInteractionState(),
popover: { type: "context" as const, query: "", activeID: "first" },
}
const result = transitionPromptInputV2(
const result = transitionComposer(
state,
{ type: "key.down", key: "g", ctrl: true, composing: false, ids: ["first"] },
persisted(),
@@ -104,8 +104,8 @@ describe("prompt input v2 interaction machine", () => {
})
test("opens the searchable command menu for a populated draft", () => {
const result = transitionPromptInputV2(
createPromptInputV2InteractionState(),
const result = transitionComposer(
createComposerInteractionState(),
{ type: "commands.open" },
persisted("existing text"),
)
@@ -115,12 +115,12 @@ describe("prompt input v2 interaction machine", () => {
})
test("prepends a menu command and preserves existing text as arguments", () => {
const open = transitionPromptInputV2(
createPromptInputV2InteractionState(),
const open = transitionComposer(
createComposerInteractionState(),
{ type: "commands.open" },
persisted("existing text"),
)
const selected = transitionPromptInputV2(
const selected = transitionComposer(
open.state,
{ type: "popover.select", item: command },
persisted("existing text"),
@@ -131,28 +131,28 @@ describe("prompt input v2 interaction machine", () => {
})
test("stores selected context files as prompt file parts", () => {
const item: PromptInputV2Suggestion = {
const item: ComposerSuggestion = {
id: "src/index.ts",
kind: "file",
label: "index.ts",
path: "src/index.ts",
}
const state = {
...createPromptInputV2InteractionState(),
...createComposerInteractionState(),
popover: { type: "context" as const, query: "index" },
}
const selected = transitionPromptInputV2(state, { type: "popover.select", item }, persisted("@index"))
const selected = transitionComposer(state, { type: "popover.select", item }, persisted("@index"))
expect(selected.commands).toContainEqual({ type: "mention.add", item })
})
test("loops active popover items with arrow keys", () => {
const state = {
...createPromptInputV2InteractionState(),
...createComposerInteractionState(),
popover: { type: "context" as const, query: "", activeID: "second" },
}
const result = transitionPromptInputV2(
const result = transitionComposer(
state,
{ type: "key.down", key: "ArrowDown", ctrl: false, composing: false, ids: ["first", "second"] },
persisted(),
@@ -1,6 +1,6 @@
import type { PromptInputV2HistoryEntry, PromptInputV2PersistedState, PromptInputV2Suggestion } from "./types"
import type { ComposerHistoryEntry, ComposerPersistedState, ComposerSuggestion } from "../types"
export type PromptInputV2InteractionState = {
export type ComposerInteractionState = {
mode: "normal" | "shell"
popover:
| { type: "closed" }
@@ -11,10 +11,10 @@ export type PromptInputV2InteractionState = {
focus: "editor" | "command-search" | "external"
activeContextID?: string
historyIndex: number
savedHistory?: PromptInputV2HistoryEntry
savedHistory?: ComposerHistoryEntry
}
export type PromptInputV2InteractionEvent =
export type ComposerInteractionEvent =
| { type: "input.changed"; value: string; persist?: boolean }
| { type: "commands.open" }
| { type: "context.open" }
@@ -22,7 +22,7 @@ export type PromptInputV2InteractionEvent =
| { type: "popover.results"; ids: string[] }
| { type: "popover.active"; id: string }
| { type: "popover.close" }
| { type: "popover.select"; item: PromptInputV2Suggestion }
| { type: "popover.select"; item: ComposerSuggestion }
| { type: "key.down"; key: string; ctrl: boolean; composing: boolean; ids: string[]; empty?: boolean }
| { type: "mode.shell" }
| { type: "mode.normal" }
@@ -32,21 +32,22 @@ export type PromptInputV2InteractionEvent =
| { type: "focus.external" }
| { type: "context.active"; id: string }
export type PromptInputV2InteractionCommand =
export type ComposerInteractionCommand =
| { type: "draft.setText"; value: string }
| { type: "mention.add"; item: PromptInputV2Suggestion }
| { type: "draft.addText"; value: string }
| { type: "mention.add"; item: ComposerSuggestion }
| { type: "popover.filter"; popover: "command" | "context"; query: string }
| { type: "suggestion.select"; id: string }
| { type: "focus.editor" }
| { type: "focus.command-search" }
export type PromptInputV2Transition = {
state: PromptInputV2InteractionState
commands: PromptInputV2InteractionCommand[]
export type ComposerEditorTransition = {
state: ComposerInteractionState
commands: ComposerInteractionCommand[]
handled: boolean
}
export function createPromptInputV2InteractionState(): PromptInputV2InteractionState {
export function createComposerInteractionState(): ComposerInteractionState {
return {
mode: "normal",
popover: { type: "closed" },
@@ -56,14 +57,14 @@ export function createPromptInputV2InteractionState(): PromptInputV2InteractionS
}
}
export function transitionPromptInputV2(
state: PromptInputV2InteractionState,
event: PromptInputV2InteractionEvent,
persisted: PromptInputV2PersistedState,
): PromptInputV2Transition {
export function transitionComposer(
state: ComposerInteractionState,
event: ComposerInteractionEvent,
persisted: ComposerPersistedState,
): ComposerEditorTransition {
if (event.type === "input.changed") return inputChanged(state, event.value, event.persist !== false, persisted.cursor)
if (event.type === "commands.open") return openCommands(state, persisted)
if (event.type === "context.open") return openContext(state, persisted)
if (event.type === "context.open") return openContext(state)
if (event.type === "popover.query") return queryChanged(state, event.value)
if (event.type === "popover.results") return resultsChanged(state, event.ids)
if (event.type === "popover.active") return activeChanged(state, event.id)
@@ -82,12 +83,12 @@ export function transitionPromptInputV2(
}
function inputChanged(
state: PromptInputV2InteractionState,
state: ComposerInteractionState,
value: string,
persist: boolean,
cursor: number | undefined,
): PromptInputV2Transition {
const setText: PromptInputV2InteractionCommand[] = persist ? [{ type: "draft.setText", value }] : []
): ComposerEditorTransition {
const setText: ComposerInteractionCommand[] = persist ? [{ type: "draft.setText", value }] : []
if (state.mode === "normal" && value === "!") {
return changed({ ...state, mode: "shell", popover: { type: "closed" }, focus: "editor" }, [
{ type: "draft.setText", value: "" },
@@ -117,13 +118,10 @@ function inputChanged(
)
}
function openCommands(
state: PromptInputV2InteractionState,
persisted: PromptInputV2PersistedState,
): PromptInputV2Transition {
function openCommands(state: ComposerInteractionState, persisted: ComposerPersistedState): ComposerEditorTransition {
if (!populated(persisted)) {
return changed({ ...state, popover: { type: "command-inline", query: "" }, focus: "editor" }, [
{ type: "draft.setText", value: promptText(persisted) + "/" },
{ type: "draft.addText", value: "/" },
{ type: "popover.filter", popover: "command", query: "" },
{ type: "focus.editor" },
])
@@ -134,18 +132,15 @@ function openCommands(
])
}
function openContext(
state: PromptInputV2InteractionState,
persisted: PromptInputV2PersistedState,
): PromptInputV2Transition {
function openContext(state: ComposerInteractionState): ComposerEditorTransition {
return changed({ ...state, popover: { type: "context", query: "" }, focus: "editor" }, [
{ type: "draft.setText", value: promptText(persisted) + "@" },
{ type: "draft.addText", value: "@" },
{ type: "popover.filter", popover: "context", query: "" },
{ type: "focus.editor" },
])
}
function queryChanged(state: PromptInputV2InteractionState, query: string): PromptInputV2Transition {
function queryChanged(state: ComposerInteractionState, query: string): ComposerEditorTransition {
if (state.popover.type === "closed") return unchanged(state)
const popover = state.popover.type === "context" ? "context" : "command"
return changed({ ...state, popover: { ...state.popover, query, activeID: undefined } }, [
@@ -153,25 +148,25 @@ function queryChanged(state: PromptInputV2InteractionState, query: string): Prom
])
}
function resultsChanged(state: PromptInputV2InteractionState, ids: string[]): PromptInputV2Transition {
function resultsChanged(state: ComposerInteractionState, ids: string[]): ComposerEditorTransition {
if (state.popover.type === "closed") return unchanged(state)
const activeID = state.popover.activeID && ids.includes(state.popover.activeID) ? state.popover.activeID : ids[0]
if (activeID === state.popover.activeID) return unchanged(state)
return changed({ ...state, popover: { ...state.popover, activeID } })
}
function activeChanged(state: PromptInputV2InteractionState, id: string): PromptInputV2Transition {
function activeChanged(state: ComposerInteractionState, id: string): ComposerEditorTransition {
if (state.popover.type === "closed" || state.popover.activeID === id) return unchanged(state)
return changed({ ...state, popover: { ...state.popover, activeID: id } })
}
function suggestionSelected(
state: PromptInputV2InteractionState,
item: PromptInputV2Suggestion,
persisted: PromptInputV2PersistedState,
): PromptInputV2Transition {
state: ComposerInteractionState,
item: ComposerSuggestion,
persisted: ComposerPersistedState,
): ComposerEditorTransition {
const current = promptText(persisted)
const commands: PromptInputV2InteractionCommand[] = []
const commands: ComposerInteractionCommand[] = []
if (item.kind === "command") {
commands.push({
type: "draft.setText",
@@ -190,9 +185,9 @@ function suggestionSelected(
}
function keyDown(
state: PromptInputV2InteractionState,
event: Extract<PromptInputV2InteractionEvent, { type: "key.down" }>,
): PromptInputV2Transition {
state: ComposerInteractionState,
event: Extract<ComposerInteractionEvent, { type: "key.down" }>,
): ComposerEditorTransition {
if (event.ctrl && event.key.toLowerCase() === "g") {
if (state.popover.type === "closed") return unchanged(state)
return changed({ ...state, popover: { type: "closed" }, focus: "editor" }, [{ type: "focus.editor" }], true)
@@ -227,11 +222,11 @@ function keyDown(
return changed({ ...state, popover: { ...state.popover, activeID: event.ids[index] } }, [], true)
}
function promptText(persisted: PromptInputV2PersistedState) {
function promptText(persisted: ComposerPersistedState) {
return persisted.prompt.map((part) => (part.type === "text" ? part.content : "")).join("")
}
function populated(persisted: PromptInputV2PersistedState) {
function populated(persisted: ComposerPersistedState) {
return (
!!promptText(persisted).trim() ||
persisted.context.items.length > 0 ||
@@ -245,17 +240,17 @@ function replaceTrigger(value: string, trigger: "@" | "/", replacement: string)
}
function changed(
state: PromptInputV2InteractionState,
commands: PromptInputV2InteractionCommand[] = [],
state: ComposerInteractionState,
commands: ComposerInteractionCommand[] = [],
handled = false,
): PromptInputV2Transition {
): ComposerEditorTransition {
return { state, commands, handled }
}
function unchanged(
state: PromptInputV2InteractionState,
state: ComposerInteractionState,
handled = false,
commands: PromptInputV2InteractionCommand[] = [],
): PromptInputV2Transition {
commands: ComposerInteractionCommand[] = [],
): ComposerEditorTransition {
return { state, commands, handled }
}
+40
View File
@@ -0,0 +1,40 @@
import type { AgentPart, ComposerStore, FileAttachmentPart, ImageAttachmentPart, Prompt, SkillPart } from "./state"
export type ComposerFilePart = FileAttachmentPart
export type ComposerAgentPart = AgentPart
export type ComposerSkillPart = SkillPart
export type ComposerAttachment = ImageAttachmentPart
export type ComposerPrompt = Prompt
export type ComposerComment = ComposerStore["context"]["items"][number]
export type ComposerPersistedState = ComposerStore
export type ComposerHistoryEntry = {
prompt: ComposerPrompt
metadata?: unknown
}
export type ComposerHistory = {
entries: (mode: "normal" | "shell") => ComposerHistoryEntry[]
add: (prompt: ComposerPrompt, mode: "normal" | "shell") => void
capture?: () => unknown
restore?: (metadata: unknown) => void
}
export type ComposerOption = {
id: string
label: string
providerID?: string
}
export type ComposerSuggestion = {
id: string
kind: "agent" | "command" | "file" | "reference" | "resource" | "skill"
label: string
title?: string
trigger?: string
description?: string
path?: string
keybind?: string[]
recent?: boolean
mention?: ComposerFilePart | ComposerAgentPart | ComposerSkillPart
}
@@ -1,29 +0,0 @@
import { describe, expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createPromptState, DEFAULT_PROMPT } from "./prompt-state"
describe("prompt state initialization", () => {
test("initializes prompt text, cursor, and model together", () => {
createRoot((dispose) => {
const model = { providerID: "anthropic", modelID: "claude", variant: "high" }
const prompt = createPromptState({ prompt: "hello", model })
expect(prompt.current()).toEqual([{ type: "text", content: "hello", start: 0, end: 5 }])
expect(prompt.cursor()).toBe(5)
expect(prompt.model.current()).toEqual(model)
expect(prompt.model.current()).not.toBe(model)
dispose()
})
})
test("uses the default prompt without initial values", () => {
createRoot((dispose) => {
const prompt = createPromptState()
expect(prompt.current()).toEqual(DEFAULT_PROMPT)
expect(prompt.cursor()).toBeUndefined()
expect(prompt.model.current()).toBeUndefined()
dispose()
})
})
})
-289
View File
@@ -1,289 +0,0 @@
import { checksum } from "@opencode-ai/util/encode"
import { batch, createMemo, type Accessor } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store"
import type { FileSelection } from "@/context/file"
import { Persist, persisted } from "@/utils/persist"
import { ServerScope } from "@/utils/server-scope"
import type { BlobReference } from "@/utils/draft-store"
import type { Platform } from "@/context/platform"
interface PartBase {
content: string
start: number
end: number
}
type FilePartSourceText = { value: string; start: number; end: number }
type FilePartSource =
| { text: FilePartSourceText; type: "file"; path: string }
| {
text: FilePartSourceText
type: "symbol"
path: string
range: { start: { line: number; character: number }; end: { line: number; character: number } }
name: string
kind: number
}
| { text: FilePartSourceText; type: "resource"; clientName: string; uri: string }
export interface TextPart extends PartBase {
type: "text"
}
export interface FileAttachmentPart extends PartBase {
type: "file"
path: string
selection?: FileSelection
mime?: string
filename?: string
url?: string
source?: FilePartSource
}
export interface AgentPart extends PartBase {
type: "agent"
name: string
}
export interface ImageAttachmentPart {
type: "image"
id: string
filename: string
sourcePath?: string
mime: string
blob: BlobReference
}
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
export type Prompt = ContentPart[]
export type PromptModel = {
providerID: string
modelID: string
variant?: string | null
}
export type FileContextItem = {
type: "file"
path: string
selection?: FileSelection
comment?: string
commentID?: string
commentOrigin?: "review" | "file"
preview?: string
}
export type ContextItem = FileContextItem
export type PromptScope = { draftID: string } | { dir: string; id?: string }
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
export type PromptStore = {
prompt: Prompt
cursor?: number
model?: PromptModel
context: {
items: (ContextItem & { key: string })[]
}
}
type InitialPrompt = {
prompt?: string
model?: PromptModel
}
function isSelectionEqual(a?: FileSelection, b?: FileSelection) {
if (!a && !b) return true
if (!a || !b) return false
return (
a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar
)
}
function isPartEqual(partA: ContentPart, partB: ContentPart) {
switch (partA.type) {
case "text":
return partB.type === "text" && partA.content === partB.content
case "file":
return (
partB.type === "file" &&
partA.path === partB.path &&
partA.mime === partB.mime &&
partA.filename === partB.filename &&
isSelectionEqual(partA.selection, partB.selection)
)
case "agent":
return partB.type === "agent" && partA.name === partB.name
case "image":
return partB.type === "image" && partA.id === partB.id
}
}
export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
if (promptA.length !== promptB.length) return false
for (let i = 0; i < promptA.length; i++) {
if (!isPartEqual(promptA[i], promptB[i])) return false
}
return true
}
function cloneSelection(selection?: FileSelection) {
if (!selection) return undefined
return { ...selection }
}
function clonePart(part: ContentPart): ContentPart {
if (part.type === "text") return { ...part }
if (part.type === "image") return { ...part }
if (part.type === "agent") return { ...part }
return {
...part,
selection: cloneSelection(part.selection),
}
}
function clonePrompt(prompt: Prompt): Prompt {
return prompt.map(clonePart)
}
function contextItemKey(item: ContextItem) {
if (item.type !== "file") return item.type
const start = item.selection?.startLine
const end = item.selection?.endLine
const key = `${item.type}:${item.path}:${start}:${end}`
if (item.commentID) return `${key}:c=${item.commentID}`
const comment = item.comment?.trim()
if (!comment) return key
const digest = checksum(comment) ?? comment
return `${key}:c=${digest.slice(0, 8)}`
}
export function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
return item.type === "file" && !!item.comment?.trim()
}
function createPromptActions(setStore: SetStoreFunction<PromptStore>) {
return {
set(prompt: Prompt, cursorPosition?: number) {
const next = clonePrompt(prompt)
batch(() => {
setStore("prompt", next)
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
})
},
reset() {
batch(() => {
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
setStore("cursor", 0)
})
},
}
}
function promptTarget(serverScope: ServerScope, scope: PromptScope) {
if ("draftID" in scope) return Persist.prompt(Persist.draft(scope.draftID, "prompt"))
return Persist.prompt({
...Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt"),
...(serverScope === ServerScope.local
? { previousKey: `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2` }
: {}),
})
}
function promptStore(initial?: InitialPrompt): PromptStore {
const text = initial?.prompt
return {
prompt:
text === undefined ? clonePrompt(DEFAULT_PROMPT) : [{ type: "text", content: text, start: 0, end: text.length }],
cursor: text === undefined ? undefined : text.length,
model: initial?.model ? { ...initial.model } : undefined,
context: {
items: [],
},
}
}
function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
const actions = createPromptActions(setStore)
const value = {
store: [() => store, setStore] as [Accessor<PromptStore>, SetStoreFunction<PromptStore>],
current: () => store.prompt,
cursor: createMemo(() => store.cursor),
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
model: {
current: () => store.model,
set: (model: PromptModel | undefined) => setStore("model", model),
},
context: {
items: createMemo(() => store.context.items),
add(item: ContextItem) {
const key = contextItemKey(item)
if (store.context.items.find((x) => x.key === key)) return
setStore("context", "items", (items) => [...items, { key, ...item }])
},
remove(key: string) {
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
},
removeComment(path: string, commentID: string) {
setStore("context", "items", (items) =>
items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)),
)
},
updateComment(path: string, commentID: string, next: Partial<FileContextItem> & { comment?: string }) {
setStore("context", "items", (items) =>
items.map((item) => {
if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item
const value = { ...item, ...next }
return { ...value, key: contextItemKey(value) }
}),
)
},
replaceComments(items: FileContextItem[]) {
setStore("context", "items", (current) => [
...current.filter((item) => !isCommentItem(item)),
...items.map((item) => ({ ...item, key: contextItemKey(item) })),
])
},
},
set: actions.set,
reset: actions.reset,
capture: () => value,
}
return value
}
function createPersistedPrompt(target: ReturnType<typeof promptTarget>, initial?: InitialPrompt, platform?: Platform) {
const [store, setStore, _, ready] = persisted(target, createStore<PromptStore>(promptStore(initial)), platform)
return { ready, ...createPromptStateValue(store, setStore) }
}
export function createPromptSession(
serverScope: ServerScope,
scope: PromptScope,
initial?: InitialPrompt,
platform?: Platform,
) {
return createPersistedPrompt(promptTarget(serverScope, scope), initial, platform)
}
export function createDraftPromptSession(draftID: string, initial?: InitialPrompt) {
return createPersistedPrompt(Persist.prompt(Persist.draft(draftID, "prompt")), initial)
}
export type PromptSession = ReturnType<typeof createPromptSession>
export function createPromptReady(session: Accessor<PromptSession>) {
return Object.defineProperty(() => session().ready(), "promise", {
get: () => session().ready.promise,
}) as (() => boolean) & { readonly promise: Promise<unknown> | undefined }
}
export function createPromptState(initial?: InitialPrompt) {
const [store, setStore] = createStore<PromptStore>(promptStore(initial))
const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
return {
ready,
...createPromptStateValue(store, setStore),
}
}
View File
@@ -1,51 +0,0 @@
import { expect, test } from "bun:test"
import { QueryClient } from "@tanstack/solid-query"
import { ServerScope } from "@/utils/server-scope"
import { createCatalogSync } from "./catalog"
import { pathKey } from "@/utils/path-key"
test("invalidates the catalog for the event location", async () => {
const queryClient = new QueryClient()
const one = [ServerScope.local, "/one", "providers"] as const
const integrations = [ServerScope.local, "/one", "integrations"] as const
const two = [ServerScope.local, "/two", "providers"] as const
queryClient.setQueryData(one, { providers: ["one"] })
queryClient.setQueryData(integrations, { integrations: ["one"] })
queryClient.setQueryData(two, { providers: ["two"] })
const catalog = createCatalogSync({
scope: ServerScope.local,
queryClient,
active: () => [pathKey("/one"), pathKey("/two")],
load: async () => {},
})
catalog.handleEvent({ type: "catalog.updated", directory: "/one" })
await Bun.sleep(0)
expect(queryClient.getQueryState(one)?.isInvalidated).toBe(true)
expect(queryClient.getQueryState(integrations)?.isInvalidated).toBe(true)
expect(queryClient.getQueryState(two)?.isInvalidated).toBe(false)
})
test("invalidates global and active catalogs after connection", async () => {
const queryClient = new QueryClient()
const global = [ServerScope.local, null, "providers"] as const
const active = [ServerScope.local, "/active", "providers"] as const
const passive = [ServerScope.local, "/passive", "providers"] as const
queryClient.setQueryData(global, {})
queryClient.setQueryData(active, {})
queryClient.setQueryData(passive, {})
const catalog = createCatalogSync({
scope: ServerScope.local,
queryClient,
active: () => [pathKey("/active")],
load: async () => {},
})
catalog.handleEvent({ type: "server.connected" })
await Bun.sleep(0)
expect(queryClient.getQueryState(global)?.isInvalidated).toBe(true)
expect(queryClient.getQueryState(active)?.isInvalidated).toBe(true)
expect(queryClient.getQueryState(passive)?.isInvalidated).toBe(false)
})
@@ -1,53 +0,0 @@
import type { QueryClient } from "@tanstack/solid-query"
import type { ServerScope } from "@/utils/server-scope"
import { pathKey, type PathKey } from "@/utils/path-key"
type CatalogEvent = {
type: string
directory?: string
}
export function createCatalogSync(input: {
scope: ServerScope
queryClient: QueryClient
active: () => PathKey[]
load: (directory: PathKey | null) => Promise<void>
}) {
function handleEvent(event: CatalogEvent) {
if (event.type === "server.connected") {
void refreshActive().catch(() => undefined)
return
}
if (
event.type === "catalog.updated" ||
event.type === "integration.updated" ||
event.type === "integration.connection.updated"
) {
void refresh(event.directory ? pathKey(event.directory) : null).catch(() => undefined)
}
}
async function refresh(directory: PathKey | null) {
await Promise.all(
["providers", "integrations"].map((resource) =>
input.queryClient.invalidateQueries({
queryKey: [input.scope, directory, resource],
exact: true,
refetchType: "none",
}),
),
)
await input.load(directory)
}
function refreshActive() {
return Promise.all([null, ...new Set(input.active())].map(refresh)).then(() => undefined)
}
return {
handleEvent,
refresh,
refreshActive,
}
}
+9 -8
View File
@@ -1,9 +1,10 @@
export { AppBaseProviders, AppInterface, preloadRoute } from "./app"
export { ACCEPTED_FILE_EXTENSIONS } from "./constants/file-picker"
export { useCommand } from "./context/command"
export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./context/language"
export { type Platform, PlatformProvider } from "./context/platform"
export { ServerConnection, useServers } from "./context/servers"
export { useTabs } from "./context/tabs"
export { createDraftStore } from "./utils/draft-store"
export { useWslServers } from "./wsl/context"
export { ACCEPTED_FILE_EXTENSIONS } from "./runtime/platform/file-picker"
export { useCommand } from "./shell/commands/command"
export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./runtime/i18n/language"
export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./runtime/platform/platform"
export { ServerConnection, useServers } from "./runtime/server/registry"
export { useTabs } from "./shell/tabs/tabs"
export { createDraftStore } from "./runtime/persistence/drafts"
export { useWslServers } from "./servers/wsl/context"
export { type UpdaterPlatform, type UpdaterState } from "./shell/updates/types"
+13 -101
View File
@@ -1,18 +1,16 @@
// @refresh reload
import * as Sentry from "@sentry/solid"
import { init } from "@sentry/solid"
import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface } from "@/app"
import { loadInitialLocale } from "@/context/language"
import { type Platform, PlatformProvider } from "@/context/platform"
import { createBrowserDraftStore } from "@/utils/draft-store"
import { dict as en } from "@/i18n/en"
import { dict as zh } from "@/i18n/zh"
import { authFromToken } from "@/utils/server"
import { loadInitialLocale } from "@/runtime/i18n/language"
import { PlatformProvider } from "@/runtime/platform/platform"
import { createWebPlatform } from "@/runtime/platform/web"
import en from "@/runtime/i18n/en"
import zh from "@/runtime/i18n/zh"
import { authFromToken } from "@/runtime/server/api"
import pkg from "../package.json"
import { ServerConnection } from "./context/servers"
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
import { ServerConnection } from "@/runtime/server/registry"
const getLocale = () => {
if (typeof navigator !== "object") return "en" as const
@@ -30,85 +28,11 @@ const getRootNotFoundError = () => {
return locale === "zh" ? (zh[key] ?? en[key]) : en[key]
}
const getStorage = (key: string) => {
if (typeof localStorage === "undefined") return null
try {
return localStorage.getItem(key)
} catch {
return null
}
}
const setStorage = (key: string, value: string | null) => {
if (typeof localStorage === "undefined") return
try {
if (value !== null) {
localStorage.setItem(key, value)
return
}
localStorage.removeItem(key)
} catch {
return
}
}
const readDefaultServerUrl = () => getStorage(DEFAULT_SERVER_URL_KEY)
const writeDefaultServerUrl = (url: string | null) => setStorage(DEFAULT_SERVER_URL_KEY, url)
const notify: Platform["notify"] = async (title, description, onClick) => {
if (!("Notification" in window)) return
const permission =
Notification.permission === "default"
? await Notification.requestPermission().catch(() => "denied")
: Notification.permission
if (permission !== "granted") return
const inView = document.visibilityState === "visible" && document.hasFocus()
if (inView) return
const notification = new Notification(title, {
body: description ?? "",
icon: "https://opencode.ai/favicon-96x96-v3.png",
})
notification.onclick = () => {
window.focus()
onClick?.()
notification.close()
}
}
const openExternal: Platform["openExternal"] = (value) => {
if (!URL.canParse(value)) return
const url = new URL(value)
if (url.protocol !== "http:" && url.protocol !== "https:" && url.protocol !== "mailto:") return
window.open(url.href, "_blank", "noopener,noreferrer")
}
const restart: Platform["restart"] = async () => {
window.location.reload()
}
const root = document.getElementById("root")
if (!(root instanceof HTMLElement) && import.meta.env.DEV) {
throw new Error(getRootNotFoundError())
}
const getCurrentUrl = () => {
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return location.origin
}
const getDefaultUrl = () => {
const lsDefault = readDefaultServerUrl()
if (lsDefault) return lsDefault
return getCurrentUrl()
}
const clearAuthToken = () => {
const params = new URLSearchParams(location.search)
if (!params.has("auth_token")) return
@@ -116,22 +40,10 @@ const clearAuthToken = () => {
history.replaceState(null, "", location.pathname + (params.size ? `?${params}` : "") + location.hash)
}
const platform: Platform = {
platform: "web",
draftStore: createBrowserDraftStore(),
version: pkg.version,
openExternal,
restart,
notify,
getDefaultServer: async () => {
const stored = readDefaultServerUrl()
return stored ? ServerConnection.Key.make(stored) : null
},
setDefaultServer: writeDefaultServerUrl,
}
const web = createWebPlatform(pkg.version)
if (import.meta.env.VITE_SENTRY_DSN) {
Sentry.init({
init({
dsn: import.meta.env.VITE_SENTRY_DSN,
environment: import.meta.env.VITE_SENTRY_ENVIRONMENT ?? import.meta.env.MODE,
release: import.meta.env.VITE_SENTRY_RELEASE ?? `web@${pkg.version}`,
@@ -157,16 +69,16 @@ if (root instanceof HTMLElement) {
type: "http",
authToken: !!auth,
http: {
url: getCurrentUrl(),
url: web.currentServerUrl,
...auth,
},
}
render(
() => (
<PlatformProvider value={platform}>
<PlatformProvider value={web.platform}>
<AppBaseProviders locale={locale}>
<AppInterface
defaultServer={ServerConnection.Key.make(getDefaultUrl())}
defaultServer={ServerConnection.Key.make(web.defaultServerUrl)}
canonicalLocalServer={ServerConnection.key(server)}
servers={[server]}
/>
@@ -1,8 +1,8 @@
import { useGlobal, useServerCtx } from "@/context/global"
import { type HomeProjectSelection, useLayout } from "@/context/layout"
import { ServerConnection } from "@/context/servers"
import { useTabs } from "@/context/tabs"
import { toggleHomeProjectSelection } from "@/pages/layout/helpers"
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
import { type HomeProjectSelection, useLayout } from "@/shell/state/layout"
import { ServerConnection } from "@/runtime/server/registry"
import { useTabs } from "@/shell/tabs/tabs"
import { toggleHomeProjectSelection } from "@/shell/layout/helpers"
import { createEffect, createMemo } from "solid-js"
export function createHomeController() {
@@ -1,18 +1,18 @@
import { useDirectoryPicker } from "@/components/directory-picker"
import { useServerActionsController } from "@/components/server/server-management-controller"
import { useSettingsCommand } from "@/components/settings-dialog"
import { type LocalProject } from "@/context/layout"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { ServerConnection } from "@/context/servers"
import { closeHomeProject, errorMessage, homeProjectDirectories } from "@/pages/layout/helpers"
import { Persist, persisted } from "@/utils/persist"
import { showToast } from "@/utils/toast"
import { useDirectoryPicker } from "@/workspaces/selection/picker"
import { useServerActionsController } from "@/servers/registry/controller"
import { useSettingsCommand } from "@/settings/command"
import { type LocalProject } from "@/shell/state/layout"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { ServerConnection } from "@/runtime/server/registry"
import { closeHomeProject, errorMessage, homeProjectDirectories } from "@/shell/layout/helpers"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { showToast } from "@/shell/notifications/toast"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createResource } from "solid-js"
import { createStore } from "solid-js/store"
import type { HomeController } from "./home-controller"
import { useGlobal } from "@/context/global"
import type { HomeController } from "../model"
import { useGlobal } from "@/runtime/server/runtime"
export function createHomeProjectsController(home: HomeController) {
const platform = usePlatform()
@@ -62,8 +62,8 @@ export function createHomeProjectsController(home: HomeController) {
canRemove: (conn: ServerConnection.Any) => serverManagement.connection.canRemove(ServerConnection.key(conn)),
remove: (conn: ServerConnection.Any) => serverManagement.connection.remove(ServerConnection.key(conn)),
edit: (conn: ServerConnection.Http) => {
void import("@/components/settings-v2/dialog-server-v2").then(({ DialogServerV2 }) => {
void dialog.show(() => <DialogServerV2 mode="edit" server={conn} />)
void import("@/servers/connect/dialog").then(({ DialogServer }) => {
void dialog.show(() => <DialogServer mode="edit" server={conn} />)
})
},
focus: home.selection.focusServer,
@@ -76,8 +76,8 @@ export function createHomeProjectsController(home: HomeController) {
add: home.project.add,
openNewSession: home.project.openProjectNewSession,
edit: (conn: ServerConnection.Any, project: LocalProject) => {
void import("@/components/dialog-edit-project-v2").then(({ DialogEditProjectV2 }) => {
void dialog.show(() => <DialogEditProjectV2 server={conn} project={project} />)
void import("@/settings/workspaces/project-dialog").then(({ DialogEditProject }) => {
void dialog.show(() => <DialogEditProject server={conn} project={project} />)
})
},
unseenCount: (conn: ServerConnection.Any, project: LocalProject) => {

Some files were not shown because too many files have changed in this diff Show More